Skip to main content

Keystone — Spike Build Log

Purpose: a real, lived record of what building the OpenIddict spike actually took — problems hit, decisions made, and how they were resolved. This is the "Side 2" evidence keystone-framing.md §5 item 1 and §10 item 2 call for: a genuine data point to weigh against entra-cost-tally.md's Side 1 (Entra's real historical cost), not a reasoned-in-the-abstract estimate.

Structure: one dated entry per spike item. This entry covers keystone-spike-scope.md §2 item 1 only — OpenIddict issuing real tokens end to end for a test client. Items 2–6 get their own entries below as they're built.

How to use this document: the friction log (Section 3) is the part worth weighing most heavily against Entra's incident list — it's the closest analogue to Entra's own "~18 incidents over five months." Section 6 has blanks for actual time spent, which only you can fill in; everything else here is derived from the build itself.


Entry 1 — Spike item 1: OpenIddict token issuance end to end

Date: 2026-08-23 Scope: keystone-spike-scope.md §2 item 1 — client_credentials grant, machine-to-machine, no interactive login yet.

1. What was actually built

  • Full solution skeleton matching RoundTrip's Clean Architecture conventions: Keystone.API.{SharedKernel, Core, UseCases, Infrastructure, Web} plus four test projects (UnitTests, IntegrationTests, FunctionalTests, Architecture.Tests — the last as an agreed stub, no real rules yet)
  • Central Package Management (Directory.Packages.props, Directory.Build.props) mirrored from RoundTrip, with Keystone-specific additions clearly separated (OpenIddict 7.6.0, the Microsoft.Extensions.Configuration.* family)
  • KeystoneDbContext (EF Core) with OpenIddict's EF store attached via UseOpenIddict()
  • A real, applied EF Core migration (InitialOpenIddictSchema) — all four OpenIddict tables (Applications, Authorizations, Scopes, Tokens), FKs, and indexes, verified against the actual generated Up() method
  • KeystoneDbContextFactory — an IDesignTimeDbContextFactory<KeystoneDbContext> decoupling migrations from the full web host starting successfully
  • TokenEndpoint (FastEndpoints) — hand-written handling of the client_credentials grant, since EnableTokenEndpointPassthrough() means OpenIddict expects application code to own this, not issue tokens automatically
  • ProtectedPingEndpoint — a genuinely protected endpoint (AuthSchemes set explicitly to OpenIddict's validation scheme), the real proof that issued tokens are load-bearing
  • Local dev environment: docker-compose.yml + .env for SQL Server, dotnet user-secrets for the connection string, a corrected .gitignore
  • DomainException.cs (Core) — ported from RoundTrip's real file, sealed, two constructors

2. Sequence of work (condensed)

  1. Reviewed all five project docs (framing, spike-scope, db-design, api-design, external-login-proposal) to confirm shared understanding before any code
  2. Scaffolded User/ExternalLogin entity shapes in chat (design discussion only — not yet written as files)
  3. Requested and reviewed RoundTrip's real SharedKernel source files, DomainException.cs, and four real .csproj files to replace earlier inference with verified copies
  4. Scaffolded the full solution structure file-by-file: Directory.Build.propsDirectory.Packages.props.slnx → each project's .csproj (some verbatim copies, some inferred where no RoundTrip source existed)
  5. Wrote Program.cs (OpenIddict server config, EF wiring, exception middleware, dev-only test-client seeding) and DomainException.cs
  6. Re-scoped mid-build: caught that item 1 doesn't need User/ExternalLogin/SharedKernel at all — deferred to item 2, unblocked Program.cs with a genuinely minimal KeystoneDbContext
  7. Stood up the four test projects (real copies from RoundTrip, minus dead RoundTrip-specific references)
  8. Local dev environment: Docker Compose, user secrets, .gitignore cleanup
  9. Generated the first migration, hit and fixed a chain of real runtime/build issues (Section 3) across several dotnet run/dotnet ef attempts
  10. First successful end-to-end token issuance
  11. Verified the full checklist: discovery document, real token, wrong-secret rejection, protected-endpoint enforcement (both directions)

3. Friction log — the real cost data

#IssueRoot causeCategoryResolution
1Guard clauses (Ardalis.GuardClauses) used inside User's aggregate methodsWritten before DomainException.cs's actual doc comment ("never throw ArgumentException... from an aggregate") was seenInference riskRewrote all User factory/mutator methods to throw DomainException instead
2EntityBase<TId>.Id has a public setter, conflicting with an initially-proposed private-set immutable IdConvention mismatch — RoundTrip's real base class wasn't seen until after the initial designInference riskMatched RoundTrip's convention (public setter, discipline-based immutability) rather than fighting the base class
3Mediator.SourceGenerator referenced in both UseCases and IntegrationTests → duplicate generated types (CS0436) once IntegrationTests pulled in UseCasesInferred UseCases.csproj diverged from Core.csproj's real, verified Mediator.Abstractions-only patternInference riskRemoved the generator from UseCases; noted it belongs only at the composition root (Web) once real dispatch is needed
4FastEndpoints throws at startup with zero endpoints, which silently broke EF Core's design-time host-building strategy for dotnet ef migrations add, surfacing as a confusing DbContext construction failureGeneric FastEndpoints + EF Core tooling interactionFramework interactionImplemented IDesignTimeDbContextFactory<KeystoneDbContext> — genuinely more robust than a workaround
5KeystoneDbContextFactory used ConfigurationBuilder/AddUserSecrets/AddEnvironmentVariables without their packages referenced or pinnedWrote code without checking Infrastructure.csproj's actual references firstInference riskAdded 2 new PackageVersion pins + 3 PackageReference entries
6KeystoneDbContextFactory never called .UseOpenIddict(), unlike Program.cs — migration generated from it captured an incomplete model, causing a runtime PendingModelChangesWarningDesign-time context builder didn't mirror runtime DbContext configuration exactlyInference risk + known EF Core gotcha classAdded the missing call, removed and regenerated the migration
7OpenIddict's server enforces HTTPS-only by default; local Kestrel ran plain HTTP → every /connect/token request rejected (ID2083)Generic, well-documented OpenIddict defaultFramework behaviorDisableTransportSecurityRequirement(), explicitly scoped to Development only
8No endpoint owned /connect/token at all — EnableTokenEndpointPassthrough() means OpenIddict expects application code to issue the token, not do it automaticallyReal, scoped implementation work that simply hadn't been done yetExpected work, not a bugHand-wrote TokenEndpoint, including OpenIddict's claim-destinations rule (only sub is auto-included in the access token)
9using static OpenIddictConstants; collided with FastEndpoints' own inherited Claims(params string[]) method → confusing "cannot choose method from method group"Narrow, real naming collision between the two librariesFramework interactionDropped the static using, fully qualified OpenIddictConstants.Claims.*
10Initial docker run command failed on shell quotingMulti-line command pasted into terminalTooling/environmentSwitched to docker-compose.yml + .env
11RoundTrip's real .gitignore had a garbled, duplicated JetBrains Rider sectionInherited debt from the source template, not introduced during this buildTooling/environmentCleaned up before adopting for Keystone
12dotnet-ef tools (10.0.9) older than runtime (10.0.10)Routine tooling driftTooling, trivialNoted, not yet acted on — no actual failure caused

Reading this table for the cost question: items 4, 7, 8, 9 are the ones that would recur for any team adopting this exact stack (OpenIddict + EF Core + FastEndpoints), regardless of who builds it — that's the closest analogue to Entra's own undocumented-surprise incidents. Items 1, 2, 3, 5, 6 are largely a cost of this specific build process (porting conventions from a codebase without full source visibility at every step) — plausibly a one-time cost that wouldn't recur once Keystone's own conventions are established and documented. Items 10–12 are ordinary environment setup, not architecture-specific at all.

4. Real architectural/scoping decisions made (deliberate, not bugs)

  • Item 1 re-scoped mid-build to explicitly exclude User/ExternalLogin/SharedKernel — deferred to item 2
  • KeystoneDbContext deliberately carries zero DbSets for item 1's scope
  • Aspire (Aspire.Hosting.*) confirmed present-but-unused in RoundTrip's real conventions (referenced in Directory.Packages.props, absent from every real test project) — dropped entirely for Keystone's local dev; Testcontainers is the verified real pattern
  • Guard-clause convention settled: Ardalis.GuardClauses outside aggregates (Application layer) only; DomainException exclusively inside Core aggregates — directly carrying forward the TRA-420 lesson about exception middleware needing to catch both
  • No global default authentication scheme registered in Program.csAuthSchemes(...) set explicitly per protected endpoint instead, since OpenIddict deliberately refuses to auto-register as default, and a global default would need revisiting once item 2 adds cookie-based login anyway
  • No .SetResources()/aud claim yet — deliberately deferred rather than inventing an undiscussed resource-server identifier
  • OpenIddict pinned at 7.6.0 (stable, confirmed native net10.0 support) over the 8.0.0-preview line

5. What's proven (checklist recap)

  • ✅ Real discovery document, correct issuer/token endpoint
  • ✅ Genuine token issued (encrypted JWE, not a stub)
  • ✅ Claims correctly round-trip through the server's own validation (sub verified via the protected endpoint, stronger proof than a visual JWT decode given encryption)
  • ✅ Rejected with wrong secret — spec-correct 401/invalid_client
  • ✅ Protected endpoint enforces real auth both directions (401 with no token, 200 with a valid one)
  • 🟡 Not yet done: one full teardown (docker compose down -v) → fresh migrate → fresh seed → fresh run, as a single reproducibility confirmation

6. Time/effort — fill in actual hours

(Only you have real wall-clock data — the rounds-of-correction counts above are a rough proxy at best, not a substitute.)

CategoryActual time spent
Reading/absorbing project docs before starting(not broken out)
Solution/project scaffolding (Sections 1–2, steps 1–8)(not broken out)
Debugging the friction log (Section 3, items 1–12)(not broken out)
Local environment setup (Docker, secrets, gitignore)(not broken out)
Total, item 1~6 hours (ground zero → item 1 complete, 2026-08-23)

7. Open items carried into later entries

  • Spike item 2 — UseCases/handler, password hashing, and the actual /account/register endpoint still ahead (data layer done, see Entry 2)
  • Spike items 3–6 (keystone-spike-scope.md §2) not started
  • Architecture.Tests still a true stub — no real layering rules yet
  • xunit.runner.json referenced by two test projects but never created
  • Full teardown/rebuild reproducibility pass (Section 5)

Entry 2 — Spike item 2 (in progress): data layer

Date: 2026-08-24 Scope: the data-modeling half of item 2 only — SharedKernel base classes, User/ExternalLogin entities, EF configuration, and a real applied migration. Registration/login logic itself (UseCases, password hashing, the actual endpoint) is not yet built.

1. What was actually built

  • Minimal SharedKernel port (6 files, namespace-swapped from the real RoundTrip source shown earlier): IDomainEvent, DomainEventBase, IHasDomainEvents, HasDomainEventsBase, EntityBase/EntityBase<TId>/EntityBase<T,TId>, IAggregateRoot — deliberately excluding ValueObject, the repository interfaces, and the Mediator dispatch plumbing, none of which item 2's entities need yet
  • Real User/ExternalLogin entities in Keystone.API.Core.Aggregates.UserContext — built on EntityBase<Guid>/IAggregateRoot, every validation path throwing DomainException (no ArgumentException anywhere in the aggregate)
  • KeystoneDbContext updated with real DbSet<User> and DbSet<ExternalLogin>, plus ApplyConfigurationsFromAssembly for auto-discovering EF configurations
  • UserConfiguration.cs, ExternalLoginConfiguration.cs, DataSchemaConstants.cs in Infrastructure/Data/Config/ — unique index on Email, composite unique index on (Provider, ProviderKey), bounded string lengths on all indexed columns
  • A real migration (AddUserAndExternalLogin), generated and applied against the live database

2. Sequence of work (condensed)

  1. Ported the 6 minimal SharedKernel files
  2. Wrote User.cs/ExternalLogin.cs — initially in a flat Users/ folder (wrong guess)
  3. Corrected to Aggregates/UserContext/ after a real RoundTrip folder screenshot
  4. Added DbSet<User> to KeystoneDbContext — initially without DbSet<ExternalLogin> (wrong reasoning)
  5. Corrected after a real RoundTrip DbSet example (ClientAddress has its own DbSet despite being a child entity)
  6. Requested RoundTrip's real Infrastructure/Data folder screenshot before guessing at the EF configuration file location — confirmed Data/Config/, one file per entity
  7. Wrote both IEntityTypeConfiguration classes, catching a real SQL Server constraint proactively (no unique index on unbounded nvarchar(max)) before it could cause a failed migration
  8. Asked for and received DataSchemaConstants.cs's real (minimal — one constant) content before deciding how to use the pattern
  9. Generated and applied the real migration

3. Friction log

#IssueRoot causeCategoryResolution
1Entity files initially placed in a flat Users/ folderGuessed the folder convention instead of checking a real example firstInference riskCorrected to Aggregates/UserContext/ after a screenshot
2DbSet<ExternalLogin> initially omitted, reasoning child entities don't get their own DbSetReasoned from the IAggregateRoot-constrained IRepository<T> pattern without checking a real DbContext exampleInference riskCorrected after seeing RoundTrip's real DbSet list — child entities do get DbSets, just never Repositorys
3Email/Provider/ProviderKey would have failed to migrate as unique-indexed columns if left unboundedSQL Server hard constraint: no unique index on nvarchar(max)Framework/database behavior — caught proactively, not hit as a failureExplicit HasMaxLength() added before the migration was ever generated

Worth naming directly: friction declined this session compared to item 1's. Two of three items above were real wrong guesses, same category as item 1's inference-risk mistakes — but the config-file-location question and the DataSchemaConstants content were both asked before guessing, rather than guessed-then-corrected. That's the earlier friction log's own lesson being applied in real time, and it shows up as fewer, cheaper corrections this round. Worth watching whether this trend holds into item 3+ — a real, if early, data point for the "self-authored friction I have visibility into" framing from keystone-framing.md §5.

4. Real decisions made

  • DataSchemaConstants kept genuinely minimal — one constant (EMAIL_MAX_LENGTH), matching RoundTrip's own restraint (a single constant despite eight aggregate contexts) rather than preemptively building out a large shared-constants file
  • PasswordHash's length deliberately not tied to EMAIL_MAX_LENGTH even though both currently equal 256 — avoiding coupling two unrelated fields through a coincidental shared number
  • Repository pattern clarified: DbSet access is broader than repository access — child entities (ExternalLogin, like RoundTrip's ClientAddress) get DbSets but never their own Repository, which stays root-only per the IAggregateRoot constraint

5. What's proven

  • SharedKernel base classes compile and are genuinely exercised (EntityBase<Guid>'s generic constraint satisfied for the first time)
  • User/ExternalLogin compile against real DomainException-only validation
  • ✅ EF configuration compiles, migration generates real CREATE TABLE/CREATE UNIQUE INDEX statements
  • ✅ Migration applies cleanly against the live database

6. Time — fill in actual hours

CategoryActual time spent
SharedKernel port + entity rewrite20 min
Correction cycles (folder, DbSet)30 min
EF configuration + migration20 min
Total, Entry 21 hour

7. Open items carried forward

  • UseCases layer, password hashing decision, /account/register endpoint — the actual registration/login logic itself
  • Mediator.SourceGenerator still needs to land in Web once real command/handler dispatch is needed
  • Everything carried forward from Entry 1's own open-items list

Entry 3 — Spike item 2: registration + minimal login

Date: 2026-08-25 Scope: the full business-logic half of item 2 — transaction/domain-event dispatch plumbing, PBKDF2 password hashing, the complete registration path, and a minimal login (credential verification only). Not the full interactive OpenIddict flow (GET /connect/authorize → hosted login page → cookie auth → authorize flow resuming → real token) described in keystone-api-design.md's sequence diagram — that's a deliberately separate, still-unstarted piece of comparable size to this whole entry.

1. What was actually built

  • Transaction/domain-event dispatch plumbing: 5 more SharedKernel files (IUnitOfWork, IDomainEventPublisher, IReadRepository, IRepository, LoggingBehavior), TransactionBehavior + IQuery<T> marker in UseCases, concrete UnitOfWork/EfRepository/MediatorDomainEventPublisher in Infrastructure
  • Pbkdf2PasswordHasher — OWASP 2026 parameters (600,000 iterations, HMAC-SHA256, 16-byte salt), self-describing packed format, constant-time comparison
  • Full registration path: UserByEmailSpec, RegisterWithPasswordCommand/Handler, RegisterEndpoint (POST /account/register)
  • MediatorConfig.cs (trimmed to Logging + Transaction only) and all DI wiring in Program.cs
  • Minimal login: LoginQuery/Handler (implements IQuery<T>, not a command — never mutates state), LoginEndpoint (POST /account/login)
  • Mediator.Abstractions/Mediator.SourceGenerator finally added to Web.csproj — the composition root, as predicted when the generator was pulled out of UseCases several sessions back

2. Sequence of work (condensed)

  1. Reviewed real UnitOfWork.cs/EfRepository.cs before writing anything; confirmed PBKDF2 for password hashing
  2. Built the transaction/dispatch plumbing (5 SharedKernel files, TransactionBehavior, concrete Infrastructure implementations)
  3. Researched current OWASP PBKDF2 guidance and the KeyDerivation package's real version before writing the hasher
  4. Wrote Pbkdf2PasswordHasher, then the registration command/handler
  5. Wired MediatorConfig/Program.cs; requested real FastEndpoints examples before writing the endpoint
  6. Wrote RegisterEndpoint, matching the real manual Result-to-HTTP translation convention rather than an invented one
  7. Hit a real runtime regression (user secrets empty on a fresh run) — diagnosed, worked around, root cause not fully confirmed
  8. Verified registration end-to-end via curl, then via a direct database row export (DataGrip CSV)
  9. Scoped login down to a minimal credential-check (Option 2), built it, verified the anti-enumeration design with byte-for-byte response comparison

3. Friction log

#IssueRoot causeCategoryResolution
1EfRepository.cs's real source has zero using statementsRelies on RoundTrip's own GlobalUsings.cs, which Keystone doesn't havePorting gap, not a guessExplicit using statements added — the one file tonight that isn't a faithful compile-as-is copy
2TransactionBehavior referenced IQuery<T> with no using for itSame missing-GlobalUsings.cs issue as #1Porting gapCaught and fixed in the same turn, before any build attempt
3Assumed Ardalis.Result.AspNetCore was the real HTTP-translation mechanismPackage is pinned but never actually used anywhere in RoundTrip — same "pinned but dead" pattern as AspireInference risk, avoidedAsked for real endpoint examples before writing RegisterEndpoint; found manual switch-based translation is the actual convention
4Real endpoints use result.Type/ResultType, which doesn't match Ardalis.Result's documented native .Status/ResultStatus APIPossibly a custom extension (C# 14 introduced extension properties, which Keystone's net10.0 target supports) — source not available to confirmUnresolved, carried forwardUsed the confirmed-native .Status/ResultStatus instead of guessing at an unconfirmed custom extension
5User secrets returned empty on a fresh dotnet run, despite having worked for all of item 1 and Entry 2's database workNot fully confirmed. A UserSecretsId mismatch between Web/Infrastructure was proposed but never actually verified as the causeEnvironment regression, real but not root-causedRe-ran dotnet user-secrets set; started working immediately after

Two deliberate, unresolved items being carried forward rather than silently fixed: the .Status vs .Type discrepancy (#4), and a real style split between item 1's endpoints (FastEndpoints' Send.* API) and item 2's endpoints (raw HttpContext.Response manipulation, matching the real RoundTrip convention discovered this session). Both are flagged, neither retroactively changed.

4. Real decisions made

  • RegisterWithPasswordCommandHandler lets DomainException propagate to the global exception middleware rather than catching and re-wrapping it into a Result — avoids duplicating logic the middleware already owns
  • LoginQuery returns the identical failure result whether the user doesn't exist, has no local password (external-login-only), or the password is wrong — a deliberate anti-enumeration choice, verified by direct response comparison, not just status-code matching
  • LoginQuery implements IQuery<T> (skipping TransactionBehavior's transaction wrap) because this minimal version never mutates state — explicitly noted that a fuller login tracking last-login timestamps or failed attempts would need to become a real command
  • Mediator pipeline kept to just Logging + Transaction, per the earlier explicit call not to port RoundTrip's tenant/subscription/caching behaviors

5. What's proven

  • ✅ Full registration path, verified two ways: curl (200/409/400 all correct) and a direct database export — confirmed the stored PasswordHash is exactly 52 bytes packed (4-byte iteration count + 16-byte salt + 32-byte subkey), not plaintext, not an approximation
  • DomainException-propagation-to-middleware design holds under a real request (blank email → 400, not 500)
  • ✅ Minimal login: correct credentials return the exact registered userId; wrong password and nonexistent email produce byte-for-byte identical 401 responses

6. Time — fill in actual hours

CategoryActual time spent
Total, Entry 3 (one continuous session)3 hours

7. Open items carried forward

  • The full interactive OpenIddict login flow (cookie auth, AllowAuthorizationCodeFlow(), /connect/authorize resuming) — genuinely unstarted, comparable in size to this entire entry
  • .Status/ResultStatus vs RoundTrip's real .Type/ResultType — unresolved; would need RoundTrip's actual extension source to match exactly
  • Endpoint style split between item 1 (Send.*) and item 2 (raw HttpContext.Response) — undecided which becomes the going-forward convention
  • User secrets regression — worked around, root cause not fully confirmed
  • Everything else carried forward from Entries 1 and 2

Entry 4 — Spike item 2: the interactive OpenIddict login flow

Date: 2026-08-26 Scope: the piece explicitly deferred at the end of Entry 3 — the real, full interactive login flow (cookie authentication, AllowAuthorizationCodeFlow(), a bare-minimum hosted login form, /connect/authorize resuming, TokenEndpoint's authorization_code branch), verified end to end against a live server via OIDC Debugger.

1. What was actually built

  • Cookie authentication scheme (Keystone.LoginCookie), registered by name only, never as a global default — same pattern already established for OpenIddict's own validation scheme
  • OpenIddict server config extended: AllowAuthorizationCodeFlow(), SetAuthorizationEndpointUris(), EnableAuthorizationEndpointPassthrough()
  • A second seeded test client, spike-interactive-client — ultimately a genuine public client (PKCE only, no secret), after an initial confidential-client attempt proved architecturally wrong
  • AuthorizeEndpoint (GET /connect/authorize) — checks for a valid login cookie, redirects to the login form if absent, or issues the authorization code via SignInAsync if present
  • GetLoginFormEndpoint (GET /account/login) — bare, unstyled HTML form, no CSS/JS, per explicit direction given the lack of any product conversation about login presentation
  • LoginEndpoint rewritten — supersedes Entry 3's JSON-API version entirely; now verifies credentials, sets the login cookie, and redirects back into the original /connect/authorize request, with explicit open-redirect protection (IsLocalUrl check) on top of FastEndpoints' own built-in guard
  • TokenEndpoint's new authorization_code branch — genuinely simpler than the client_credentials branch, since OpenIddict already reconstructed the principal when the code was issued
  • CORS policy, scoped specifically to https://oidcdebugger.com (not a wildcard) — needed for OIDC Debugger's own automatic token-exchange JavaScript to actually complete

2. Sequence of work (condensed)

  1. Laid out the full flow's real requirements before writing code; confirmed OIDC Debugger's exact redirect URI via search rather than assume it
  2. Built foundational config (Stage A: cookie scheme, authorization code flow, second seeded client) — clean build
  3. Built the new endpoints (Stage B: AuthorizeEndpoint, login form, rewritten LoginEndpoint, TokenEndpoint's new branch) — clean build
  4. First real test: 415 on login form submission
  5. Two wrong guesses at Send.RedirectAsync's real signature (position, then name) before going to the actual installed source via Rider — real signature has no CancellationToken parameter at all
  6. Fixed, rebuilt — same 415 persisted
  7. Found AllowFormData() via FastEndpoints' docs, applied with no arguments — 415 persisted a third time
  8. Deterministic curl reproduction (ruling out the browser as a variable) confirmed the bug was genuinely server-side
  9. Went to the real source again — found AllowFormData(bool urlEncoded = false); the default is false (multipart), and a plain HTML form sends application/x-www-form-urlencoded. Fixed with AllowFormData(true)curl test succeeded
  10. Real browser flow: got a genuine authorization code, but no visible code_verifier — OIDC Debugger performs PKCE token exchange automatically in-browser rather than displaying it
  11. Diagnosed a real architectural mismatch: the seeded client had a secret (confidential), but the automatic exchange assumes a public client. Corrected: reseeded as genuinely public — also the architecturally correct choice, since a browser-based SPA can't keep a secret confidential anyway
  12. Hit a real FK constraint deleting the old client row — fixed by deleting child OpenIddictTokens/OpenIddictAuthorizations rows first
  13. Fresh flow attempt reached the exchange step but showed nothing — browser console revealed a real CORS block and a real server-side 500
  14. Server logs revealed the actual root cause of the 500: a previous TokenEndpoint edit (the authorization_code branch) had never actually been applied to the real file — a gap in the chat-based code-handoff process itself, not a logic bug
  15. Handed over the complete file to eliminate ambiguity, rather than another partial diff
  16. Next attempt used a stale, orphaned authorization code (tied to the now-deleted confidential client) — needed a fully fresh OIDC Debugger run, not just a corrected command
  17. Next attempt returned "already redeemed" — diagnosed correctly: CORS blocks the browser from reading a response, not the request from being processed; the earlier CORS-blocked automatic exchange had already succeeded server-side and consumed the single-use code invisibly
  18. Added CORS scoped to OIDC Debugger's origin — caught and self-corrected a middleware-ordering mistake (UseCors initially placed after auth instead of before) before the user ever hit it
  19. Final fresh run succeeded — real access_token and id_token, decoded and verified precisely

3. Friction log

#IssueRoot causeCategoryResolution
1Send.RedirectAsync — first guess: CancellationToken as second positional argAssumed from external-login-proposal.md's own example call, which turned out to be inaccurate (that doc's own caveat: "limited-context... demo project")Reference material was wrong, not verifiedReal signature confirmed via Rider: no CT parameter exists at all
2Send.RedirectAsync — second guess: named cancellation: parameterAssumed the parameter existed under a different name/position without checkingRepeated same class of mistake as #1Same fix as #1
3AllowFormData() called with no arguments still returned 415FastEndpoints' own doc example didn't make the default value of urlEncoded obvious in contextDocumentation gap, not verified against real signature firstReal signature confirmed via Rider: urlEncoded defaults to false (multipart); needed AllowFormData(true)
4Seeded spike-interactive-client as confidential (with a secret)Copied client_credentials client's shape by habit, without considering that a browser-based interactive flow needs a different client typeArchitectural inference riskReseeded as genuinely public (PKCE only) — also the objectively correct design, matching the eventual real SPA client
5FK constraint violation deleting the old client rowChild OpenIddictAuthorizations/OpenIddictTokens rows existed from the first (confidential-client) flow attemptExpected relational integrity, not really a bugDeleted child rows first, in FK-dependency order
6Real 500 during the token exchangeA previous TokenEndpoint edit (the authorization_code branch) was never actually applied to the real fileProcess gap — chat-based code handoff, not a code or framework bugHanded over the complete file instead of a partial diff, to remove ambiguity
7"Authorization code has already been redeemed"CORS blocked the browser from reading a response that had already succeeded server-side — the code was genuinely, correctly single-use and had already been consumedReal OAuth security property working correctly, surfaced via a CORS gapAdded CORS scoped to OIDC Debugger's origin, letting the exchange actually complete visibly
8UseCors initially placed after UseAuthentication/UseAuthorizationContradicted my own stated reasoning about standard middleware orderingSelf-caught errorCorrected before the user ever ran it

Worth naming directly: this was the highest-friction session of the project so far, and it's worth being honest about why rather than averaging it away. Items 1–3 are the same failure mode repeating — asserting a third-party API's shape from memory or a doc snippet instead of checking the real, installed source first, exactly the mistake this log's own Entry 1 already named as a category to watch for. Item 6 is a new, distinct risk category this session surfaced for the first time: the collaboration model itself has a gap — code shown in chat doesn't automatically become real code in the project, and a missed edit can look identical to a genuine logic bug from the error message alone. Worth watching for specifically going forward, not just filed away.

4. Real decisions made

  • spike-interactive-client is a genuine public client (no secret, PKCE required) — not just a fix for OIDC Debugger's assumptions, but the architecturally correct choice for a browser-based client that can't keep a secret confidential
  • CORS scoped narrowly to https://oidcdebugger.com specifically, not a wildcard — real production CORS for the eventual admin app is a distinct, later decision
  • LoginEndpoint deliberately keeps its own IsLocalUrl open-redirect check even after discovering FastEndpoints has a built-in one — defense in depth; the built-in guard throws, while the explicit check gracefully falls back to /
  • Login form kept genuinely bare (no CSS/JS) per explicit direction, since no product conversation about presentation has happened yet

5. What's proven

  • ✅ The full interactive authorization code flow, PKCE required, end to end against a live server
  • ✅ Real id_token, decoded and verified precisely: correct iss, correct aud (the first meaningful audience claim in the project), and — most importantly — a sub claim matching the exact same User.Id from registration and manual login testing, confirming identity propagated correctly through every hop (cookie → authorize → code → token)
  • ✅ OpenIddict's automatic security bindings (nonce echoed, at_hash present) came through correctly as a byproduct of the flow being wired properly — not something explicitly coded

6. Time

CategoryActual time spent
Total, Entry 4 (one continuous session)2 hours 20 minutes

7. Open items carried forward

  • .Status/ResultStatus vs RoundTrip's real .Type/ResultType — still unresolved
  • Endpoint style split (item 1's Send.* vs item 2's raw HttpContext.Response) — still undecided
  • User secrets regression from Entry 3 — still not root-caused
  • Real CORS policy for the eventual admin app (distinct from the OIDC-Debugger-scoped policy added here)
  • Spike items 3–6 (keystone-spike-scope.md §2) — not started
  • Architecture.Tests still a true stub, xunit.runner.json still missing, full teardown/rebuild reproducibility pass still not done

Entry 5 — Automated test suite: Architecture.Tests and User unit tests

Date: 2026-08-27 Scope: the automated-testing gap flagged at the end of Entry 4 and in the overall spike status check — spike-scope.md §5 calls for "a real xUnit test suite... run every time, not verified by hand," but every proof through Entry 4 was manual curl/browser testing. This entry: Architecture.Tests genuinely built out (previously a true stub), and User's full domain surface covered in UnitTests.

1. What was actually built

  • ArchitectureAssemblies.cs/ArchitectureNamespaces.cs — assembly/namespace marker helpers, corrected to match RoundTrip's real accessibility (internal, not public) once the actual files were shared
  • LayerDependencyTests.cs — layer-isolation rules via NetArchTest (Core/UseCases/Infrastructure must not depend on outer layers), adapted from RoundTrip's real file
  • LayerConventionTests.cs — naming-convention rules (namespace-prefix-per-layer, Core interfaces start with I, *Extensions classes must be static), adapted from RoundTrip's real file
  • UserTests.cs — full domain coverage for User: both registration paths, LinkExternalLogin, RemoveExternalLogin (including the last-credential guard), SetPasswordHash, Activate/Deactivate, and the EF-required private constructor — 27 tests total, matching RoundTrip's real ServiceTicketTests.cs conventions (#region-grouped, method-name-first naming, Should.Throw<DomainException>() used specifically rather than the looser Exception, since testing the exception type is testing the actual architectural guarantee)

2. Sequence of work (condensed)

  1. Requested a real RoundTrip unit/integration/architecture test file before writing anything, per the established discipline
  2. Received CreateTicketHandlerTests.cs, CustomWebApplicationFactory.cs, LayerDependencyTests.cs, LayerConventionTests.cs — confirmed Shouldly/NSubstitute conventions, the Action_Scenario_Expected naming pattern, and got a complete, adaptable template for Architecture.Tests
  3. Built Architecture.Tests out fully (4 files) using inferred ArchitectureAssemblies/ArchitectureNamespaces marker types, since the real versions weren't available yet
  4. Received the real ServiceTicketTests.cs — revealed User's simpler state model needs none of ServiceTicket's reflection-based backing-field tricks, since every User invariant is reachable through its own public API
  5. Built UserTests.cs (27 tests) matching the real conventions
  6. Received the real ArchitectureAssemblies.cs/ArchitectureNamespaces.cs — revealed a real, previously-missed architectural convention (InfrastructureServiceExtensions, ServiceConfigs — each layer owning its own DI registration, orchestrated centrally, rather than Program.cs doing everything inline). Corrected accessibility; deferred the actual Program.cs refactor as its own future task, by explicit agreement
  7. First test run: reported 33 total instead of the expected 36 — traced precisely to LayerDependencyTests.cs's 3 tests being entirely absent
  8. User reported replacing that file — second run: now 30 total, with the opposite 6 tests (LayerConventionTests) missing instead
  9. Traced to the actual root cause: LayerConventionTests.cs had never existed as its own file at all — only one Architecture.Tests file had ever been created, and content had been shuffled between what should have been two separate files
  10. Created LayerConventionTests.cs as a genuinely new file — final run: 36/36 passing, every test individually confirmed by name
  11. User raised the underlying process issue directly: inconsistent practice around showing full file content as visible chat code blocks (sometimes shown, sometimes left to the file-creation tool's own summary) made it easy to miss whether a file's content had actually landed correctly — directly named as the cause of the repeated mixup

3. Friction log

#IssueRoot causeCategoryResolution
1Test run reported 33 instead of 36; traced to LayerDependencyTests.cs's tests being entirely absentFile likely never created, or created with different content than shownProcess gap (same category as Entry 4, item 6)Re-sent the file content
2Second run reported 30 instead of 36; the other file's tests (LayerConventionTests) now missingLayerConventionTests.cs had never actually existed as its own file — only one Architecture.Tests file had ever been createdProcess gapCreated LayerConventionTests.cs as a genuinely new file, confirmed by explicit checklist of all 4 expected files
3Root cause of #1 and #2, named directly by the user: inconsistent practice showing full code as a visible chat code block vs. relying on the file-creation tool's own light-grey summaryMixed practice across the session — some files shown fully in chat, some notProcess gap, now with a concrete fixGoing forward: every file's actual content is always shown in a visible code block in the response text, with no exceptions

This is the same category Entry 4 flagged as a new risk (item 6 there) — recurring here, but this time with a real, permanent, adopted fix rather than just a name for the risk. Worth watching whether it actually recurs after tonight, since that's the real test of whether the fix holds.

4. Real decisions made

  • Program.cs's registration logic should eventually be refactored into InfrastructureServiceExtensions/ServiceConfigs, matching RoundTrip's real, discovered convention — deferred as its own future task, not folded into tonight's testing work
  • Should.Throw<DomainException>() used specifically (not the looser Should.Throw<Exception>()) throughout UserTests.cs, since confirming the exception type is confirming the actual architectural guarantee established early in this project
  • No reflection-based state manipulation needed for User's tests, unlike ServiceTicket's — a genuine, positive reflection of User's simpler state model

5. What's proven

  • Architecture.Tests — 9 tests, all passing, genuinely enforcing layer isolation and naming conventions against the real codebase for the first time
  • User's complete domain surface — 27 tests, all passing: every DomainException guard, every happy path, the EF-required private constructor
  • 36/36 total, individually confirmed by name via --logger "console;verbosity=detailed" — not just a summary count, given tonight's own lesson about what a summary count can hide

6. Time — fill in actual hours

CategoryActual time spent
Total, Entry 51 hour

7. Open items carried forward

  • Pbkdf2PasswordHasher unit tests — next natural piece, independent of tonight's work
  • IntegrationTests/FunctionalTests — still both completely empty
  • The Program.csServiceConfigs/InfrastructureServiceExtensions refactor — real, deferred, not yet scheduled
  • Everything else carried forward from Entries 1–4

Entry 6 — Full automated test suite: hasher, IntegrationTests, FunctionalTests

Date: 2026-08-28 Scope: the remaining automated-testing gaps from Entry 5 — Pbkdf2PasswordHasher unit tests, and the two still-empty test projects (IntegrationTests, FunctionalTests) fully built out against real handlers and real HTTP endpoints.

1. What was actually built

  • Pbkdf2PasswordHasherTests.cs — 7 tests: hash/verify round trip, wrong password, salting uniqueness (two hashes of the same password must differ), both distinct malformed-input branches (invalid base64 vs. valid-but-wrong-length), and two tests locking in the exact packed format (52 bytes, embedded 600,000 iteration count)
  • IntegrationTestBase.cs — adapted from RoundTrip's real file, genuinely smaller: no tenant context, no ambient current-user service, no raw-SQL seed data, no substituted fakes — Keystone's handlers take everything as direct parameters, and any "existing user" a test needs gets created through the real handler being tested
  • RegisterWithPasswordCommandHandlerTests.cs / LoginQueryHandlerTests.cs — 6 integration tests total, against a real Testcontainers SQL Server database, constructing handlers directly (bypassing Mediator, matching RoundTrip's own real convention)
  • CustomWebApplicationFactory.cs — adapted from RoundTrip's real file, explicit usings added (same missing-GlobalUsings.cs situation as EfRepository.cs months ago), no SeedData, UseOpenIddict() applied proactively this time
  • RegisterFunctionalTests.cs / LoginFunctionalTests.cs — 6 functional tests total, real HTTP round trips via WebApplicationFactory, including the exact AllowFormData(true) content-type requirement and cookie-setting behavior discovered through manual testing weeks ago
  • appsettings.Testing.json — a placeholder connection string, needed to satisfy Program.cs's own eager startup validation before ConfigureWebHost gets a chance to swap in the real Testcontainers connection string

2. Sequence of work (condensed)

  1. Wrote and ran Pbkdf2PasswordHasherTests.cs — 7/7 passing, 43 total
  2. Requested and received RoundTrip's real IntegrationTestBase.cs before writing anything — revealed the fresh-container-per-test-method design, and several real simplifications available to Keystone
  3. Built IntegrationTestBase.cs, confirmed it compiles standalone
  4. Built both integration test classes — first run failed with PendingModelChangesWarning
  5. Diagnosed precisely: IntegrationTestBase's DbContext configuration was missing .UseOpenIddict() — the exact same bug as the original KeystoneDbContextFactory issue from months ago, reintroduced by not carrying the lesson forward into a new file. Fixed immediately
  6. Reran — 49/49 passing
  7. Before writing CustomWebApplicationFactory.cs, proactively caught a real problem: it sets the environment to "Testing", but Program.cs's transport-security exception only checked IsDevelopment() — meaning OpenIddict would enforce HTTPS-only inside the test host the same way it did during manual interactive-flow testing weeks ago. Fixed before running anything, applying the earlier lesson proactively for once
  8. Built CustomWebApplicationFactory.cs and both functional test classes — caught and fixed a missing using System.Net.Http.Json; before the user even built
  9. First functional test run failed anyway — a second, different consequence of the "Testing" environment name: Program.cs's eager connection-string check runs before ConfigureWebHost ever gets a chance to intervene, and ASP.NET Core only loads user secrets under "Development" specifically — so the connection string was genuinely absent
  10. Fixed with appsettings.Testing.json's placeholder value — reran — 55/55 passing

3. Friction log

#IssueRoot causeCategoryResolution
1PendingModelChangesWarning in IntegrationTestBaseMissing .UseOpenIddict() on the DbContext configurationSame root cause as a bug from months ago, reintroduced — the lesson wasn't carried forward into a new fileAdded the missing call
2"Testing" environment bypasses the transport-security exceptionProgram.cs's check only tested IsDevelopment()Environment-name side effect, caught proactively before running anythingExtended the check to include IsEnvironment("Testing")
3"Testing" environment has no connection string at allASP.NET Core only loads user secrets under "Development"; Program.cs's eager validation runs before WebApplicationFactory's ConfigureWebHost can interveneEnvironment-name side effect, found reactively via a real test failureAdded appsettings.Testing.json with a placeholder value, never actually connected to

Item 1 is worth flagging directly: this is the second time the exact same UseOpenIddict() omission has caused this exact same warning, in a different file, months apart. That's a real, specific pattern worth internalizing rather than treating each occurrence as a one-off: any new place a KeystoneDbContext gets configured needs this call, and it's evidently easy to forget under different framing (a design-time factory vs. a test base class look like unrelated concerns, but the underlying requirement is identical).

Items 2 and 3 are a matched pair worth understanding together: choosing "Testing" as a distinct ASP.NET Core environment name (matching RoundTrip's own real convention) has real, non-obvious consequences beyond its intended purpose — it silently opts out of both IsDevelopment()-gated logic and user-secrets loading. Two separate, real bugs from one naming choice; worth remembering if a third environment-specific gap ever surfaces.

4. Real decisions made

  • Both new test-infrastructure files (IntegrationTestBase, CustomWebApplicationFactory) deliberately smaller than RoundTrip's real versions — no tenant context, no ambient current-user service, no seed data, no substituted fakes, since Keystone's handlers genuinely don't need any of it
  • Kept the full (if unused-by-these-specific-tests) Mediator pipeline registered in IntegrationTestBase, matching RoundTrip's own real convention of registering it for completeness even when a given test constructs handlers directly
  • Functional tests use unique, Guid-based emails per test rather than fixed values — IClassFixture shares one database across all tests in a class, unlike IntegrationTestBase's fresh-per-test isolation, so this avoids cross-test collisions
  • appsettings.Testing.json's connection string is deliberately a placeholder, never meant to resolve to a real database — its only job is satisfying Program.cs's own startup check before ConfigureWebHost replaces it

5. What's proven

  • Pbkdf2PasswordHasher's complete contract — round trip, salting, both malformed-input branches, exact byte format, embedded iteration count
  • RegisterWithPasswordCommandHandler/LoginQueryHandler against a real (not mocked) database — persistence, hashing, duplicate detection, anti-enumeration
  • ✅ The full HTTP pipeline, end to end, for both registration and login — including the exact AllowFormData(true) requirement and cookie-setting behavior that took real debugging effort to discover manually weeks ago, now regression-protected automatically
  • 55/55 tests passing — 27 domain + 7 hasher + 9 architecture + 6 integration + 6 functional

6. Time — fill in actual hours

CategoryActual time spent
Total, Entry 61 hour 15 min

7. Open items carried forward

  • The Program.csServiceConfigs/InfrastructureServiceExtensions refactor — still deferred, still not scheduled
  • Spike items 3–6 (keystone-spike-scope.md §2) — Entra federation flow and FK-compatibility checks, both still fully unstarted
  • .Status/ResultStatus vs RoundTrip's real .Type/ResultType — still unresolved
  • Endpoint style split (item 1's Send.* vs item 2's raw HttpContext.Response) — still undecided
  • User secrets regression from Entry 3 — still not root-caused
  • Real CORS policy for the eventual admin app — still not built
  • xunit.runner.json — still referenced by two test projects, never created

Entry 7 — Program.cs refactor completed; spike item 3 started

Date: 2026-08-29 Scope: two distinct pieces in one session — completing and verifying the Program.cs refactor deferred since a few sessions back, then starting spike item 3 (the optional Microsoft Entra federation flow).

1. What was actually built

Program.cs refactor (requested real source: LoggerConfigs.cs, MediatorConfig.cs, MiddlewareConfig.cs, OptionConfigs.cs, ServiceConfigs.cs, InfrastructureServiceExtensions.cs):

  • InfrastructureServiceExtensions.cs — Infrastructure now owns its own DI registration (KeystoneDbContext, repositories, UnitOfWork, domain-event publisher, password hasher); Web no longer references any of Infrastructure's concrete types directly
  • OpenIddictConfig.cs — the full AddOpenIddict() block, relocated verbatim
  • GlobalExceptionMiddleware.cs — the TRA-420 exception handling, now a real middleware class matching RoundTrip's UseMiddleware<T>() convention (was an inline lambda)
  • SeedData.cs — test-client seeding, relocated verbatim
  • MiddlewareConfig.cs — the full pipeline as one awaited method, plus a new Database:ApplyMigrationsOnStartup config flag replacing unconditional migrate-on-every-boot
  • ServiceConfigs.cs — the single orchestrator; Program.cs is now five lines
  • Added Ardalis.GuardClauses to Infrastructure.csproj for the connection-string guard, matching RoundTrip's real pattern
  • Verified, not just built: clean build, full 55/55 test suite still passing, and a manual client_credentials token request confirmed the fully refactored composition root actually works end to end — the one path none of the 55 tests exercise directly

Spike item 3 start (the optional Entra federation flow, keystone-external-login-proposal.md §3):

  • Resolved a real, structural question: RoundTrip's existing CIAM tenant (roundtripapp.onmicrosoft.com) cannot host the multi-tenant app registration this flow needs — confirmed via Microsoft's own docs, a hard technical limitation, not just a semantic mismatch. Created a new app registration (Keystone – Entra Federation) in Traxs's own workforce tenant (Traxs Group LLC) instead — the objectively correct home, not a workaround
  • App registration configured: multi-tenant ("Allow all tenants"), Web platform, redirect URI http://localhost:5285/account/login/external-callback
  • ExternalLoginConfig.cs — the ExternalCookie scheme + MicrosoftEntra OpenID Connect client registration, matching the proposal doc's real code exactly, including AadIssuerValidator for multi-tenant issuer validation (verified as a real, current package before pinning)
  • Added Microsoft.IdentityModel.Validators (8.16.0)

2. Sequence of work (condensed)

  1. Requested and received six real RoundTrip config files before writing anything
  2. Built all six Keystone equivalents, in dependency order (Infrastructure first)
  3. Rewrote Program.cs to a five-line composition root
  4. Verified: clean build → full test suite (55/55) → manual token request against the refactored path
  5. Discussed reusing RoundTrip's existing Entra tenant for item 3 — determined this doesn't work (CIAM tenants can't host multi-tenant app registrations at all, confirmed via search)
  6. Created the app registration in the correct tenant (Traxs's own workforce tenant)
  7. Verified package existence for Microsoft.IdentityModel.Validators before pinning a version
  8. Built ExternalLoginConfig.cs, wired into ServiceConfigs
  9. Attempted to set user secrets for the new Entra values — hit UserSecretsId missing entirely from Keystone.API.Web.csproj
  10. Regenerated it, re-set all values — discovered Keystone.API.Infrastructure.csproj had also lost its UserSecretsId (previously a copy of Web's, for KeystoneDbContextFactory's benefit)
  11. Fixed both, verified via dotnet user-secrets list on both projects independently this time, not assumed

3. Friction log

#IssueRoot causeCategoryResolution
1Keystone.API.Web.csproj missing <UserSecretsId> entirelyUnconfirmed — likely the same class of issue as Entry 3's unsolved user-secrets regression, now recurring with stronger evidence (a completely absent property, not just an empty store)Real, still not root-causeddotnet user-secrets init to regenerate
2Keystone.API.Infrastructure.csproj also missing <UserSecretsId>Same unconfirmed root cause as #1 — two independent files losing the same MSBuild propertyReal, still not root-causedManually copied Web's new GUID into Infrastructure's .csproj; verified via dotnet user-secrets list on both projects separately this time

Worth naming plainly: this is now a two-project recurrence of an issue whose actual mechanism was never identified in Entry 3. Not caused by any edit made through this chat — no tool call in this session touched <UserSecretsId> in either file. Something in the local IDE/build workflow is dropping this property, and it's happened at least twice now across two different files. Worth treating as a known, unresolved environmental risk rather than a one-off, and checking dotnet user-secrets list proactively if anything configuration-related misbehaves in future sessions.

4. Real decisions made

  • Entra app registration lives in Traxs's own workforce tenant, not RoundTrip's CIAM tenant — a hard technical constraint (CIAM tenants don't support multi-tenant app registrations at all), not a preference
  • New working agreement: Directory.Packages.props is never replaced wholesale going forward — only specific new <PackageVersion> lines are given, since the person has bumped versions independently over time and a full-file replacement would silently revert those changes

5. What's proven

  • ✅ The Program.cs refactor holds under real verification — full test suite plus a manual token request through the actual refactored path
  • Microsoft.IdentityModel.Validators/AadIssuerValidator confirmed as the real, current, correct tool for multi-tenant issuer validation before any code was written against it
  • 🟡 ExternalLoginConfig.cs compiles-pending — not yet build-verified as of this entry

6. Time — fill in actual hours

CategoryActual time spent
Total, Entry 71 hour 45 min

7. Open items carried forward

  • Build verification of ExternalLoginConfig.cs — next immediate step
  • The actual challenge (GET /account/login/external/{provider}) and callback (GET /account/login/external-callback) endpoints — not yet started
  • The linking-not-coupling guardrails (verified-email-required, no auto-merge, confirm-link flow) — not yet implemented against real endpoints
  • The UserSecretsId-disappearance issue — still genuinely unresolved after two occurrences
  • Spike items 4–6 — FK-compatibility checks against RoundTrip's/Waypoint's real schemas — not started
  • Everything else carried forward from prior entries

Entry 8 — Spike item 3 completed: Microsoft Entra federation proven end to end

Date: 2026-08-30 Scope: completing spike item 3 from where Entry 7 left off — the actual challenge/callback endpoints, the linking logic, and getting the whole flow working against real Microsoft Entra infrastructure, not a local test tool. This was the most failure-prone piece built so far, and the friction log below reflects that honestly.

1. What was actually built

  • UserByExternalLoginSpec — lookup by (Provider, ProviderKey) against the composite unique index
  • ProcessExternalLoginCommand/Handler — all three linking-not-coupling cases from the proposal doc. Case 3 deliberately diverges from the proposal doc: no pre-existing-invite requirement (that was correct for RoundTrip's tenant-scoped model, not for Keystone as the central credential store) — a verified external identity registers a new User directly via RegisterFromExternalLogin, with product entitlement left entirely to Command Center (keystone-framing.md §9)
  • ExternalLoginChallengeEndpoint / ExternalLoginCallbackEndpoint — Cases 1 and 3 fully implemented; Case 2 (existing account by email) correctly detects the conflict and refuses to auto-merge, but stops at a plain message rather than a real confirm-link flow — honest, deliberately incomplete
  • ExternalLoginConfig.cs — the actual OIDC client registration, ultimately correct after several real fixes (below)

2. Sequence of work (condensed)

  1. Built the command/handler/spec and both endpoints against the proposal doc's real code pattern
  2. First build failure: AddExternalLoginConfig not found — the entire ExternalLoginConfig class had never actually been created in the real project, despite being discussed
  3. Created it — hit CS0234, Microsoft.AspNetCore.Authentication.OpenIdConnect missing entirely. Confirmed via the real compiler /reference: list that, unlike Cookies/OAuth, this handler isn't part of the ASP.NET Core shared framework and needs its own package
  4. New working agreement adopted here: Directory.Packages.props is never replaced wholesale going forward — only specific new lines given, since versions get bumped independently over time
  5. Clean build — first real browser test returned 204 No Content with a Location header present but the browser never navigating. Diagnosed as FastEndpoints' own "auto-send" step overwriting ChallengeAsync's real 302
  6. First fix attempt (Response.StartAsync()) resolved the 204 but very likely caused the next bug — a new AuthenticationFailureException: message.State is null or empty on the callback
  7. Found the correct, documented fix instead: DontAutoSendResponse() in Configure() — doesn't touch response-commit timing at all, just tells FastEndpoints to leave the response alone
  8. The state error persisted anyway — real root cause eventually found via the callback request's own DevTools routing metadata: CallbackPath and our own application's post-sign-in RedirectUri were configured as the identical path, causing the OIDC middleware to intercept its own redirect target a second time and fail validating a request with no code/state at all
  9. Fixed by removing the explicit CallbackPath override (defaulting to ASP.NET Core's standard /signin-oidc) — required a coordinated update to the Entra app registration's redirect URI to match, or Microsoft would reject the request outright
  10. Real success: the OIDC handshake completed cleanly — but hit "Microsoft didn't return the identity details we need." Used a temporary claims-dump diagnostic (same technique that resolved earlier FastEndpoints API uncertainty) rather than guess
  11. Found: email was present, just under a legacy WS-Federation-mapped claim name, not the plain OIDC name — MapInboundClaims = false fixes it
  12. Separately found: email_verified isn't provided at all for work/school accounts — a real, deliberate policy decision, not a workaround: a successful sign-in against a real organizational tenant is the verification for this provider, since the tenant's own IT department already controls who gets that email (fundamentally different reasoning than a personal Microsoft account, which needs its own separate proof-of-ownership flow)
  13. Same null-check error recurred after the MapInboundClaims fix — re-ran the claims diagnostic and found MapInboundClaims = false had a side effect on a different claim: objectidentifier's long-form URI was itself a product of the same mapping, and the raw claim is actually the short oid
  14. Fixed the providerKey lookup to use oid directly — full, real success: real Users/ExternalLogins rows, real Keystone.Login cookie, confirmed against actual DataGrip output
  15. Re-ran the entire flow a second time to prove Case 1 (already-linked) — confirmed no duplicate row in either table, the existing link correctly reused via oid lookup

3. Friction log

#IssueRoot causeCategoryResolution
1AddExternalLoginConfig not foundThe entire ExternalLoginConfig.cs file had never been created in the real projectProcess gap — same category as prior sessions' file-sync incidentsCreated the file for real
2CS0234, AddOpenIdConnect missingMicrosoft.AspNetCore.Authentication.OpenIdConnect isn't part of the shared framework (unlike Cookies/OAuth) — needs its own packageReal framework knowledge gap, confirmed via the actual compiler reference list rather than guessedAdded the package
3204 No Content instead of 302 on the challenge endpointFastEndpoints' own auto-send step doesn't recognize ChallengeAsync as having handled the responseReal, confirmed FastEndpoints behavior (verified via a real GitHub issue, not assumed)DontAutoSendResponse()
4Response.StartAsync() (first fix attempt) very likely caused a new state-validation failureForcing an early response commit likely interfered with OIDC's own cookie-writing timingSelf-inflicted, caught and corrected before compounding furtherReplaced with the documented DontAutoSendResponse() instead
5message.State is null or empty, persisting after fix #3CallbackPath and our own application RedirectUri were the same path — the OIDC middleware intercepted its own redirect target a second timeReal, structural configuration mistakeRemoved CallbackPath override; required a coordinated Entra app registration update
6"Microsoft didn't return the identity details we need"email claim present but under a legacy WS-Fed-mapped nameReal ASP.NET Core default behavior, confirmed via diagnostic claims dump rather than guessedMapInboundClaims = false
7Same error recurred after fix #6MapInboundClaims = false also changed objectidentifier's claim name from a long URI to the raw short oid — a side effect of the same setting on a different claimReal, non-obvious interaction between two claims sharing one mapping mechanismChanged providerKey lookup to oid

This entry has the highest concentration of genuinely hard-won, non-obvious findings of any session so far. Worth naming the pattern that made it tractable rather than a repeat of earlier guessing mistakes: every one of items 3, 6, and 7 was resolved by looking at real evidence (a GitHub issue, an actual claims dump, real routing metadata) rather than asserting behavior from memory — the same discipline that resolved the RedirectAsync/AllowFormData confusion months ago, now applied consistently under much higher-stakes, real-external-system conditions.

4. Real decisions made

  • Case 3 registers new users directly rather than requiring a pre-existing invite — a deliberate, reasoned divergence from the proposal doc, not an oversight
  • EmailVerifiedByProvider is hardcoded true for the MicrosoftEntra provider specifically, with the reasoning documented directly in code — organizational tenant control is the verification, structurally different from a personal Microsoft account
  • Directory.Packages.props is never replaced wholesale going forward — specific lines only

5. What's proven

  • The full Entra federation flow, end to end, against real Microsoft infrastructure — not a local test tool. Real app registration, real sign-in, real tokens, real claims
  • ✅ Case 3 (brand-new person) — confirmed via real Users/ExternalLogins rows and a real session cookie
  • ✅ Case 1 (already-linked) — confirmed via a second full run producing zero duplicate rows
  • 🟡 Case 2 (existing account by email) — conflict detection confirmed correct; the actual confirm-link flow remains unbuilt, honestly flagged rather than silently expanded into this session

6. Time — fill in actual hours

CategoryActual time spent
Total, Entry 83 1/2 hours

7. Open items carried forward

  • The real Case 2 confirm-link flow (password confirmation before linking) — not built
  • Spike items 4–6 — FK-compatibility checks against RoundTrip's/Waypoint's real schemas — not started
  • .Status/ResultStatus vs RoundTrip's real .Type/ResultType — still unresolved
  • Endpoint style split (item 1's Send.* vs item 2's raw HttpContext.Response) — still undecided
  • User secrets UserSecretsId disappearance — still genuinely unresolved, two occurrences now
  • Real CORS policy for the eventual admin app — still not built
  • xunit.runner.json — still referenced by two test projects, never created
  • No automated test coverage yet for ProcessExternalLoginCommand or the external-login endpoints — everything proven manually this session

Entry 9 — FunctionalTests fix, Send.*/HttpContext audit, .Status vs .Type resolved, UserSecretsId root cause found

Date: 2026-08-31 Scope: three smaller, previously-open items closed out, plus one recurring test-suite fix — deliberately lighter-weight work following Entry 8's dense Entra federation session.

1. What was actually built / decided

FunctionalTests fix: ExternalLoginConfig's ArgumentException.ThrowIfNullOrWhiteSpace guards on Authentication:MicrosoftEntra:ClientId/ClientSecret were throwing under the Testing environment, which (as already established for the connection string) doesn't load user secrets. Added placeholder values to appsettings.Testing.json — same shape as the earlier connection-string fix. All 55 tests passing again.

Send.* vs raw HttpContext audit — every endpoint checked against the rule refined last session (DontAutoSendResponse() for auth-bypass calls):

  • RegisterEndpoint.cs's success path was a genuine violation (raw HttpContext.Response, no auth justification at all) — fixed to Send.OkAsync
  • First attempt at fixing the error path (Send.ResponseAsync with a differently-shaped anonymous object) failed to compile — confirmed the method is real but strictly typed to the endpoint's declared TResponse
  • Verified via FastEndpoints' own GitHub issues (#303, #338) that this is a known, acknowledged framework limitation, not a gap in understanding — sending a differently-shaped error object with a custom status code genuinely requires raw HttpContext writing. Reverted the error path back to its original form, now understood as correct, not a violation
  • This added a third legitimate category to the rule: raw HttpContext when FastEndpoints has no native method for what's needed at all (a differently-shaped error body, or GetLoginFormEndpoint's raw HTML with no Send.* equivalent) — not just "auth bypass"
  • TokenEndpoint/AuthorizeEndpoint deliberately left untouched despite matching the pattern that broke ExternalLoginChallengeEndpoint last session — both are proven working across many real runs, and the exact mechanism that protects them (best guess: OpenIddict's own SignInAsync writes body content immediately, unlike ChallengeAsync's headers-only redirect) isn't confirmed enough to risk changing load-bearing code

.Status/.Type resolved — requested and received RoundTrip's real Result.cs/ResultType.cs/PagedResult.cs. Major finding, bigger than expected: RoundTrip's Result<T> isn't the Ardalis.Result package at all — it's a small, hand-rolled class in RoundTrip.API.UseCases, with real shape differences beyond naming (.Type/ResultType vs .Status/ResultStatus; a single string Error vs an Errors collection; a PaymentRequired case that exists only because RoundTrip has real subscription billing, which Keystone deliberately doesn't). Decision: stay on Ardalis.Result — migrating would mean a real multi-file refactor across every handler/endpoint/test for a naming preference alone, which the spike's own proportional-effort discipline doesn't call for. Updated the stale speculative comment in RegisterEndpoint.cs to reflect the confirmed finding.

UserSecretsId disappearance — real root cause finally identified, after two unexplained occurrences across Entries 7 and 8: full .csproj file contents were pasted/replaced wholesale during earlier package-addition work, without preserving the <UserSecretsId> line — a machine-generated value that only ever existed in the real project file, never visible to Claude. This explains both occurrences precisely, via the same mechanism, at different points. New working agreement: the existing Directory.Packages.props no-full-replace rule now extends to .csproj files generally — any file that can carry user/machine-specific state gets targeted edits only, never wholesale replacement.

2. Friction log

#IssueRoot causeCategoryResolution
1All 6 FunctionalTests failingExternalLoginConfig's startup guards throwing under Testing environment, no secrets configured thereRecurring pattern — any new required-config value needs a Testing placeholderAdded placeholders to appsettings.Testing.json
2Send.ResponseAsync compile failureAssumed a generic "any object + status code" method existed; it's strictly typed to TResponseReal FastEndpoints API gap, confirmed via the actual compiler errorReverted to raw HttpContext — confirmed correct via FastEndpoints' own GitHub issues, not a violation
3Unrelated NullReferenceException in GetLoginForm_Returns_Html immediately after the RegisterEndpoint fixSuspected parallel-test-execution race in FastEndpoints' static endpoint-metadata caching — the failing test has no relationship to the file that changedLikely test-suite flakiness, not confirmed with certaintyReran with no code changes — passed clean. Worth watching if it recurs
4UserSecretsId missing, twice, across two different filesRoot cause finally found: full .csproj replacements during earlier package-addition work silently dropped a GUID that only ever existed in the real fileProcess gap — same category as the Directory.Packages.props concern, just not recognized as the same risk at the timeNew agreement: .csproj files get targeted edits only, same as Directory.Packages.props

3. What's proven / resolved

  • ✅ Full test suite (55/55) restored after the Testing-environment config gap
  • Send.*/HttpContext convention now has a real, three-part rule instead of a vague preference, verified against FastEndpoints' own acknowledged limitations rather than assumed
  • .Status/.Type question closed permanently — a deliberate, justified divergence from RoundTrip, not an unresolved gap
  • UserSecretsId mystery closed with a credible, specific mechanism — not "still unexplained"

4. Time — fill in actual hours

CategoryActual time spent
Total, Entry 91 1/2 hour

5. Open items carried forward

  • The real Case 2 confirm-link flow — not built
  • Spike items 4–6 — FK-compatibility checks against RoundTrip's/Waypoint's real schemas — not started
  • Real CORS policy for the eventual admin app — still not built
  • xunit.runner.json — still referenced by two test projects, never created
  • No automated test coverage yet for ProcessExternalLoginCommand or the external-login endpoints
  • Worth a longer-term watch: whether the GetLoginForm_Returns_Html flakiness (friction item 3) recurs, which would justify actually digging into FastEndpoints' static caching under parallel test execution rather than treating it as a one-off

Entry 10 — Full test coverage for the external login flow; Case 2 confirm-link built and tested

Date: 2026-09-01 Scope: closing the "no automated coverage" gap left by Entry 8's manual-only Entra federation work, then building and immediately testing the Case 2 confirm-link flow that had been deliberately deferred since that same entry.

  1. Sequencing decision

Chose test coverage before Case 2, deliberately — locking in Cases 1 and 3 with real tests before adding more complexity on top, rather than compounding untested surface area. Matches keystone-spike-scope.md §5's own stated discipline directly: "a real xUnit test suite... run every time, not verified by hand."

  1. What was actually built

Test coverage for spike item 3 (Entra federation):

ProcessExternalLoginCommandHandlerTests — 5 integration tests: Case 3 (new registration), Case 1 (idempotent re-run, no duplicate), unverified email rejection, Case 2 conflict detection with no auto-merge, and a regression lock confirming two different providers sharing a raw key value never cross-resolve ExternalLoginFunctionalTests — 3 functional tests, scope deliberately bounded to the challenge endpoint's redirect construction (a live Microsoft sign-in can't realistically be automated). Directly protects against Entry 8's 204-vs-302 bug and confirms the real client_id/response_type in the outgoing URL

ConfirmLinkCommand/Handler — verifies the existing account's password before linking, per the proposal doc's "never auto-merge" guardrail GetConfirmLinkFormEndpoint / ConfirmLinkEndpoint — the actual password-confirmation form and its POST handler ExternalLoginCallbackEndpoint's Conflict branch now redirects here for real, replacing Entry 8's placeholder message Deliberate scope limit, named explicitly: if the existing account has no password at all (registered via a different external provider only), that's treated as an anti-enumeration failure rather than building a separate recovery path — real, additional scope intentionally left out

Test coverage for Case 2:

ConfirmLinkCommandHandlerTests — 3 integration tests: correct-password success, wrong-password failure, and the anti-enumeration pairing (nonexistent email vs. no-password-set account, same failure) ConfirmLinkFunctionalTests — 3 functional tests, full HTTP coverage this time, since this flow (unlike the Entra callback) has no external dependency at all

  1. Friction log

Issue Root cause Category Resolution

1 Console.WriteLine diagnostic never appeared in test output (Rider or terminal) xUnit's test host doesn't reliably capture raw console output Real environmental knowledge gap Switched to ITestOutputHelper, xUnit's actual purpose-built mechanism for this 2 response_mode=query assertion failed — genuinely absent from the real URL Wrong assumption in the test, not a bug in the app: query is already the OAuth spec default for response_type=code, so ASP.NET Core doesn't write the redundant parameter Test-writing error, corrected via real evidence rather than forcing a false positive Removed the assertion; the setting still does its real job, it just isn't literal text in this request 3 DbUpdateConcurrencyException linking a new ExternalLogin to an already-tracked User First fix attempt (swap UpdateAsync for SaveChangesAsync) was wrong — same error persisted. Real root cause: neither User.Id nor ExternalLogin.Id had .ValueGeneratedNever() configured, a latent gap since day one that never manifested because every prior entity creation went through AddAsync on a whole new graph at once Real, previously-hidden EF Core configuration gap, genuinely deeper than the first fix attempt addressed Added .ValueGeneratedNever() to both configurations (not just the one that triggered it)

Item 3 is worth calling out as the most valuable finding of this session — not because it was hard to fix once found, but because it's the kind of gap that could have caused silent, confusing failures in a completely different part of the codebase later, had this particular test not happened to exercise the specific "fetch tracked entity, mutate a collection, save" pattern for the first time.

  1. What's proven ✅ 69 total tests, up from 55 — the entire external-login surface (Cases 1, 2, and 3) now has real automated protection, not just Entry 8's manual verification ✅ A previously-invisible EF Core configuration gap found and fixed proactively across both affected entities, not just the one that surfaced it ✅ Case 2 (confirm-link) is now genuinely complete — the one piece of spike item 3 left honestly incomplete since Entry 8

  2. Time — fill in actual hours

CategoryActual time spent
Total, Entry 10:2 1/2 hours
  1. Open items carried forward Spike items 4–6 — FK-compatibility checks against RoundTrip's/Waypoint's real schemas — not started Real CORS policy for the eventual admin app — still not built xunit.runner.json — still referenced by two test projects, never created Worth a longer-term watch: whether the GetLoginForm_Returns_Html flakiness from Entry 9 recurs

Entry 11 — Spike items 5 and 6 confirmed against real, current schema — spike-scope.md §2 now fully complete

Date: 2026-09-01

Scope:

The two remaining numbered spike items — confirming KeystoneUserId can replace EntraObjectId on RoundTrip's TenantUsers/Technician (item 5) and Waypoint's WaypointUsers (item 6) — done against genuinely current source files, per keystone-db-design.md §7's own explicit warning against checking against a stale mental model.

  1. What was actually done

Item 5

RoundTrip, confirmed. Requested and received real, current TenantUser.cs, Technician.cs, TechnicianConfiguration.cs, TenantContext.cs. EntraObjectId on both aggregates is Guid, private-set, assigned once in Create(), never mutated — a rename to KeystoneUserId requires zero structural change. The composite (TenantId, EntraObjectId) unique indexes remain correct as-is: tenant-scoped uniqueness (not global) correctly allows one real person to be a TenantUser in more than one RoundTrip customer account, since the shared CIAM tenant serves everyone — the same reasoning holds under KeystoneUserId. The structural guardrail from keystone-db-design.md §4 (TenantUser/Technician never cross-resolving) holds up in the real files: two genuinely separate tables, two separate indexes, nothing shared between them.

Real, adjacent finding: both aggregates have independent IsActive fields with the same documented meaning ("cannot log in"). Once Keystone owns "can this credential authenticate at all," these local fields need a clearly different meaning (e.g., tenant/role-membership status) — otherwise this quietly recreates the exact dual-IsActive ambiguity TRA-434 already found and fixed once, just one layer up. Not a blocker for item 5; flagged for whoever does the real migration later.

Tangent — Command Center's own schema. Discussed informally (not built): a Tenants/Subscription/Entitlement/Products shape. Real feedback given: "Entitlement" as commonly understood doesn't match keystone-framing.md §4's actual requirement — the Founders Program price lock needs to apply to products a tenant hasn't subscribed to yet, which a plain per-product access table can't represent. Likely needs its own concept (something like a PriceCommitment/RateLock table keyed by TenantId alone). Deliberately not designed further — explicitly out of scope for this spike (keystone-db-design.md §6), and the person confirmed they were just thinking out loud, not ready to go deeper.

Item 6

Waypoint, confirmed. Requested and received real, current Workspace.cs, WaypointUser.cs, WaypointUserConfiguration.cs, WorkspaceConfiguration.cs. Same conclusion as item 5, and genuinely simpler: WaypointUser.EntraObjectId is shape-identical to RoundTrip's fields, and Waypoint has no TenantUser/Technician-style split at all — there's nothing else for a WaypointUser to cross-resolve with, so that guardrail isn't just satisfied, it's not even applicable.

Real finding, sharper than the existing record (WAY-49) described it. WorkspaceConfiguration.cs has a unique index on TenantId alone. Since every Waypoint user currently authenticates against the single shared CIAM tenant, every user's TenantId claim is the same constant value — combined with this index, that's not just "no mechanism for two customers to get different IDs" (as WAY-49 originally framed it), it's a hard structural block: the very first attempt to provision a second real Workspace would fail outright with a unique-constraint violation, not surface as quiet cross-customer confusion. Added a comment to WAY-49 in Linear capturing this precisely, clearly sourced as incidental to this FK check rather than new dedicated investigation, and not overstating the issue's own scope (the fix and urgency assessment are otherwise unchanged).

  1. What's proven

✅ keystone-spike-scope.md §2 is now fully complete — all six numbered items done, and every one of them proven against real evidence (real tokens, a real Microsoft tenant, real current schema files) rather than assumption at any point ✅ Both FK-compatibility checks confirm zero structural changes needed on either RoundTrip's or Waypoint's side ✅ A real, more severe version of WAY-49 documented and captured in Linear where it belongs, rather than left buried in this session's context

  1. Time — fill in actual hours
CategoryActual time spent
Total, Entry 11:1 hour
  1. Open items carried forward

keystone-framing.md §8's fuller "ready to use" bar — two real gaps beyond the spike's own six items: Command Center entitlement delegation isn't even stubbed (spike-scope.md §3 says this should be trivial — a hardcoded "yes"), and password reset isn't built at all (the framing doc's own named minimum for "nobody gets permanently locked out") Real CORS policy for the eventual admin app — still not built xunit.runner.json — still referenced by two test projects, never created IsActive dual-meaning risk on RoundTrip's TenantUser/Technician — flagged for the real migration, not urgent now WAY-49 — logged with a sharper finding; still Waypoint's own backlog item, not Keystone's


This is exactly the kind of real, current evidence the db-design doc's own warning called for — worth working through carefully rather than rushing to a verdict.

FK compatibility — item 5/6

Confirmed: KeystoneUserId (Guid) can replace EntraObjectId (Guid) on both TenantUsers and Technicians with no structural change. Here's the actual reasoning, not just the conclusion:

  • Both fields are shape-identical to what they'd become: Guid, private set, set once in Create(), never mutated anywhere else in either aggregate (confirmed — no method touches EntraObjectId after construction in either file). A straight rename to KeystoneUserId changes nothing about how either aggregate behaves.
  • The composite indexes stay correct, not just compatible. Both are (TenantId, EntraObjectId) — scoped per tenant, not globally unique. That's not incidental: it's what allows one real person to legitimately be a TenantUser in more than one RoundTrip customer account (a consultant working two client accounts, say), since roundtripapp.onmicrosoft.com is one shared CIAM tenant serving everyone. KeystoneUserId represents the same "one global person" concept, just more centrally — the same reasoning holds, so (TenantId, KeystoneUserId) is still the right shape.
  • The structural guardrail from keystone-db-design.md §4 holds up in the real files, not just in theory. TenantUser and Technician remain two genuinely separate aggregate roots with two separate tables and two separate composite indexes — nothing shared between them that could let a KeystoneUserId cross-resolve from one into the other.

One real thing worth flagging, adjacent to the FK question but not blocking it: both aggregates still have their own IsActive, independently toggled, and both doc comments say the same thing — "cannot log in." Once Keystone owns "can this credential authenticate at all" via its own User.IsActive, RoundTrip's local IsActive fields will need a clearly different meaning (e.g., "active in this particular tenant/role" rather than "can authenticate") — otherwise this quietly recreates the exact dual-IsActive ambiguity TRA-434 already found and fixed once, just one layer up. Not a blocker for this compatibility check, but worth someone's attention before the real migration happens.