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, theMicrosoft.Extensions.Configuration.*family) KeystoneDbContext(EF Core) with OpenIddict's EF store attached viaUseOpenIddict()- A real, applied EF Core migration (
InitialOpenIddictSchema) — all four OpenIddict tables (Applications, Authorizations, Scopes, Tokens), FKs, and indexes, verified against the actual generatedUp()method KeystoneDbContextFactory— anIDesignTimeDbContextFactory<KeystoneDbContext>decoupling migrations from the full web host starting successfullyTokenEndpoint(FastEndpoints) — hand-written handling of theclient_credentialsgrant, sinceEnableTokenEndpointPassthrough()means OpenIddict expects application code to own this, not issue tokens automaticallyProtectedPingEndpoint— a genuinely protected endpoint (AuthSchemesset explicitly to OpenIddict's validation scheme), the real proof that issued tokens are load-bearing- Local dev environment:
docker-compose.yml+.envfor SQL Server,dotnet user-secretsfor the connection string, a corrected.gitignore DomainException.cs(Core) — ported from RoundTrip's real file, sealed, two constructors
2. Sequence of work (condensed)
- Reviewed all five project docs (framing, spike-scope, db-design, api-design, external-login-proposal) to confirm shared understanding before any code
- Scaffolded
User/ExternalLoginentity shapes in chat (design discussion only — not yet written as files) - Requested and reviewed RoundTrip's real
SharedKernelsource files,DomainException.cs, and four real.csprojfiles to replace earlier inference with verified copies - Scaffolded the full solution structure file-by-file:
Directory.Build.props→Directory.Packages.props→.slnx→ each project's.csproj(some verbatim copies, some inferred where no RoundTrip source existed) - Wrote
Program.cs(OpenIddict server config, EF wiring, exception middleware, dev-only test-client seeding) andDomainException.cs - Re-scoped mid-build: caught that item 1 doesn't need
User/ExternalLogin/SharedKernelat all — deferred to item 2, unblockedProgram.cswith a genuinely minimalKeystoneDbContext - Stood up the four test projects (real copies from RoundTrip, minus dead RoundTrip-specific references)
- Local dev environment: Docker Compose, user secrets,
.gitignorecleanup - Generated the first migration, hit and fixed a chain of real runtime/build issues (Section 3) across several
dotnet run/dotnet efattempts - First successful end-to-end token issuance
- Verified the full checklist: discovery document, real token, wrong-secret rejection, protected-endpoint enforcement (both directions)
3. Friction log — the real cost data
| # | Issue | Root cause | Category | Resolution |
|---|---|---|---|---|
| 1 | Guard clauses (Ardalis.GuardClauses) used inside User's aggregate methods | Written before DomainException.cs's actual doc comment ("never throw ArgumentException... from an aggregate") was seen | Inference risk | Rewrote all User factory/mutator methods to throw DomainException instead |
| 2 | EntityBase<TId>.Id has a public setter, conflicting with an initially-proposed private-set immutable Id | Convention mismatch — RoundTrip's real base class wasn't seen until after the initial design | Inference risk | Matched RoundTrip's convention (public setter, discipline-based immutability) rather than fighting the base class |
| 3 | Mediator.SourceGenerator referenced in both UseCases and IntegrationTests → duplicate generated types (CS0436) once IntegrationTests pulled in UseCases | Inferred UseCases.csproj diverged from Core.csproj's real, verified Mediator.Abstractions-only pattern | Inference risk | Removed the generator from UseCases; noted it belongs only at the composition root (Web) once real dispatch is needed |
| 4 | FastEndpoints 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 failure | Generic FastEndpoints + EF Core tooling interaction | Framework interaction | Implemented IDesignTimeDbContextFactory<KeystoneDbContext> — genuinely more robust than a workaround |
| 5 | KeystoneDbContextFactory used ConfigurationBuilder/AddUserSecrets/AddEnvironmentVariables without their packages referenced or pinned | Wrote code without checking Infrastructure.csproj's actual references first | Inference risk | Added 2 new PackageVersion pins + 3 PackageReference entries |
| 6 | KeystoneDbContextFactory never called .UseOpenIddict(), unlike Program.cs — migration generated from it captured an incomplete model, causing a runtime PendingModelChangesWarning | Design-time context builder didn't mirror runtime DbContext configuration exactly | Inference risk + known EF Core gotcha class | Added the missing call, removed and regenerated the migration |
| 7 | OpenIddict's server enforces HTTPS-only by default; local Kestrel ran plain HTTP → every /connect/token request rejected (ID2083) | Generic, well-documented OpenIddict default | Framework behavior | DisableTransportSecurityRequirement(), explicitly scoped to Development only |
| 8 | No endpoint owned /connect/token at all — EnableTokenEndpointPassthrough() means OpenIddict expects application code to issue the token, not do it automatically | Real, scoped implementation work that simply hadn't been done yet | Expected work, not a bug | Hand-wrote TokenEndpoint, including OpenIddict's claim-destinations rule (only sub is auto-included in the access token) |
| 9 | using 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 libraries | Framework interaction | Dropped the static using, fully qualified OpenIddictConstants.Claims.* |
| 10 | Initial docker run command failed on shell quoting | Multi-line command pasted into terminal | Tooling/environment | Switched to docker-compose.yml + .env |
| 11 | RoundTrip's real .gitignore had a garbled, duplicated JetBrains Rider section | Inherited debt from the source template, not introduced during this build | Tooling/environment | Cleaned up before adopting for Keystone |
| 12 | dotnet-ef tools (10.0.9) older than runtime (10.0.10) | Routine tooling drift | Tooling, trivial | Noted, 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 KeystoneDbContextdeliberately carries zeroDbSets for item 1's scope- Aspire (
Aspire.Hosting.*) confirmed present-but-unused in RoundTrip's real conventions (referenced inDirectory.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.GuardClausesoutside aggregates (Application layer) only;DomainExceptionexclusively insideCoreaggregates — directly carrying forward the TRA-420 lesson about exception middleware needing to catch both - No global default authentication scheme registered in
Program.cs—AuthSchemes(...)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()/audclaim yet — deliberately deferred rather than inventing an undiscussed resource-server identifier - OpenIddict pinned at 7.6.0 (stable, confirmed native
net10.0support) over the8.0.0-previewline
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 (
subverified 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 (
401with no token,200with 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.)
| Category | Actual 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/registerendpoint still ahead (data layer done, see Entry 2) - Spike items 3–6 (
keystone-spike-scope.md§2) not started Architecture.Testsstill a true stub — no real layering rules yetxunit.runner.jsonreferenced 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
SharedKernelport (6 files, namespace-swapped from the real RoundTrip source shown earlier):IDomainEvent,DomainEventBase,IHasDomainEvents,HasDomainEventsBase,EntityBase/EntityBase<TId>/EntityBase<T,TId>,IAggregateRoot— deliberately excludingValueObject, the repository interfaces, and the Mediator dispatch plumbing, none of which item 2's entities need yet - Real
User/ExternalLoginentities inKeystone.API.Core.Aggregates.UserContext— built onEntityBase<Guid>/IAggregateRoot, every validation path throwingDomainException(noArgumentExceptionanywhere in the aggregate) KeystoneDbContextupdated with realDbSet<User>andDbSet<ExternalLogin>, plusApplyConfigurationsFromAssemblyfor auto-discovering EF configurationsUserConfiguration.cs,ExternalLoginConfiguration.cs,DataSchemaConstants.csinInfrastructure/Data/Config/— unique index onEmail, 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)
- Ported the 6 minimal
SharedKernelfiles - Wrote
User.cs/ExternalLogin.cs— initially in a flatUsers/folder (wrong guess) - Corrected to
Aggregates/UserContext/after a real RoundTrip folder screenshot - Added
DbSet<User>toKeystoneDbContext— initially withoutDbSet<ExternalLogin>(wrong reasoning) - Corrected after a real RoundTrip
DbSetexample (ClientAddresshas its ownDbSetdespite being a child entity) - Requested RoundTrip's real
Infrastructure/Datafolder screenshot before guessing at the EF configuration file location — confirmedData/Config/, one file per entity - Wrote both
IEntityTypeConfigurationclasses, catching a real SQL Server constraint proactively (no unique index on unboundednvarchar(max)) before it could cause a failed migration - Asked for and received
DataSchemaConstants.cs's real (minimal — one constant) content before deciding how to use the pattern - Generated and applied the real migration
3. Friction log
| # | Issue | Root cause | Category | Resolution |
|---|---|---|---|---|
| 1 | Entity files initially placed in a flat Users/ folder | Guessed the folder convention instead of checking a real example first | Inference risk | Corrected to Aggregates/UserContext/ after a screenshot |
| 2 | DbSet<ExternalLogin> initially omitted, reasoning child entities don't get their own DbSet | Reasoned from the IAggregateRoot-constrained IRepository<T> pattern without checking a real DbContext example | Inference risk | Corrected after seeing RoundTrip's real DbSet list — child entities do get DbSets, just never Repositorys |
| 3 | Email/Provider/ProviderKey would have failed to migrate as unique-indexed columns if left unbounded | SQL Server hard constraint: no unique index on nvarchar(max) | Framework/database behavior — caught proactively, not hit as a failure | Explicit 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
DataSchemaConstantskept 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 filePasswordHash's length deliberately not tied toEMAIL_MAX_LENGTHeven though both currently equal 256 — avoiding coupling two unrelated fields through a coincidental shared number- Repository pattern clarified:
DbSetaccess is broader than repository access — child entities (ExternalLogin, like RoundTrip'sClientAddress) getDbSets but never their ownRepository, which stays root-only per theIAggregateRootconstraint
5. What's proven
- ✅
SharedKernelbase classes compile and are genuinely exercised (EntityBase<Guid>'s generic constraint satisfied for the first time) - ✅
User/ExternalLogincompile against realDomainException-only validation - ✅ EF configuration compiles, migration generates real
CREATE TABLE/CREATE UNIQUE INDEXstatements - ✅ Migration applies cleanly against the live database
6. Time — fill in actual hours
| Category | Actual time spent |
|---|---|
| SharedKernel port + entity rewrite | 20 min |
| Correction cycles (folder, DbSet) | 30 min |
| EF configuration + migration | 20 min |
| Total, Entry 2 | 1 hour |
7. Open items carried forward
UseCaseslayer, password hashing decision,/account/registerendpoint — the actual registration/login logic itselfMediator.SourceGeneratorstill needs to land inWebonce 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
SharedKernelfiles (IUnitOfWork,IDomainEventPublisher,IReadRepository,IRepository,LoggingBehavior),TransactionBehavior+IQuery<T>marker inUseCases, concreteUnitOfWork/EfRepository/MediatorDomainEventPublisherinInfrastructure 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 inProgram.cs- Minimal login:
LoginQuery/Handler (implementsIQuery<T>, not a command — never mutates state),LoginEndpoint(POST /account/login) Mediator.Abstractions/Mediator.SourceGeneratorfinally added toWeb.csproj— the composition root, as predicted when the generator was pulled out ofUseCasesseveral sessions back
2. Sequence of work (condensed)
- Reviewed real
UnitOfWork.cs/EfRepository.csbefore writing anything; confirmed PBKDF2 for password hashing - Built the transaction/dispatch plumbing (5
SharedKernelfiles,TransactionBehavior, concreteInfrastructureimplementations) - Researched current OWASP PBKDF2 guidance and the
KeyDerivationpackage's real version before writing the hasher - Wrote
Pbkdf2PasswordHasher, then the registration command/handler - Wired
MediatorConfig/Program.cs; requested real FastEndpoints examples before writing the endpoint - Wrote
RegisterEndpoint, matching the real manual Result-to-HTTP translation convention rather than an invented one - Hit a real runtime regression (user secrets empty on a fresh run) — diagnosed, worked around, root cause not fully confirmed
- Verified registration end-to-end via curl, then via a direct database row export (DataGrip CSV)
- 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
| # | Issue | Root cause | Category | Resolution |
|---|---|---|---|---|
| 1 | EfRepository.cs's real source has zero using statements | Relies on RoundTrip's own GlobalUsings.cs, which Keystone doesn't have | Porting gap, not a guess | Explicit using statements added — the one file tonight that isn't a faithful compile-as-is copy |
| 2 | TransactionBehavior referenced IQuery<T> with no using for it | Same missing-GlobalUsings.cs issue as #1 | Porting gap | Caught and fixed in the same turn, before any build attempt |
| 3 | Assumed Ardalis.Result.AspNetCore was the real HTTP-translation mechanism | Package is pinned but never actually used anywhere in RoundTrip — same "pinned but dead" pattern as Aspire | Inference risk, avoided | Asked for real endpoint examples before writing RegisterEndpoint; found manual switch-based translation is the actual convention |
| 4 | Real endpoints use result.Type/ResultType, which doesn't match Ardalis.Result's documented native .Status/ResultStatus API | Possibly a custom extension (C# 14 introduced extension properties, which Keystone's net10.0 target supports) — source not available to confirm | Unresolved, carried forward | Used the confirmed-native .Status/ResultStatus instead of guessing at an unconfirmed custom extension |
| 5 | User secrets returned empty on a fresh dotnet run, despite having worked for all of item 1 and Entry 2's database work | Not fully confirmed. A UserSecretsId mismatch between Web/Infrastructure was proposed but never actually verified as the cause | Environment regression, real but not root-caused | Re-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
RegisterWithPasswordCommandHandlerletsDomainExceptionpropagate to the global exception middleware rather than catching and re-wrapping it into aResult— avoids duplicating logic the middleware already ownsLoginQueryreturns 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 matchingLoginQueryimplementsIQuery<T>(skippingTransactionBehavior'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/400all correct) and a direct database export — confirmed the storedPasswordHashis 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, not500) - ✅ Minimal login: correct credentials return the exact registered
userId; wrong password and nonexistent email produce byte-for-byte identical401responses
6. Time — fill in actual hours
| Category | Actual 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/authorizeresuming) — genuinely unstarted, comparable in size to this entire entry .Status/ResultStatusvs 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 (rawHttpContext.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 viaSignInAsyncif presentGetLoginFormEndpoint(GET /account/login) — bare, unstyled HTML form, no CSS/JS, per explicit direction given the lack of any product conversation about login presentationLoginEndpointrewritten — supersedes Entry 3's JSON-API version entirely; now verifies credentials, sets the login cookie, and redirects back into the original/connect/authorizerequest, with explicit open-redirect protection (IsLocalUrlcheck) on top of FastEndpoints' own built-in guardTokenEndpoint's newauthorization_codebranch — genuinely simpler than theclient_credentialsbranch, 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)
- Laid out the full flow's real requirements before writing code; confirmed OIDC Debugger's exact redirect URI via search rather than assume it
- Built foundational config (Stage A: cookie scheme, authorization code flow, second seeded client) — clean build
- Built the new endpoints (Stage B:
AuthorizeEndpoint, login form, rewrittenLoginEndpoint,TokenEndpoint's new branch) — clean build - First real test:
415on login form submission - Two wrong guesses at
Send.RedirectAsync's real signature (position, then name) before going to the actual installed source via Rider — real signature has noCancellationTokenparameter at all - Fixed, rebuilt — same
415persisted - Found
AllowFormData()via FastEndpoints' docs, applied with no arguments —415persisted a third time - Deterministic
curlreproduction (ruling out the browser as a variable) confirmed the bug was genuinely server-side - Went to the real source again — found
AllowFormData(bool urlEncoded = false); the default isfalse(multipart), and a plain HTML form sendsapplication/x-www-form-urlencoded. Fixed withAllowFormData(true)—curltest succeeded - 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 - 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
- Hit a real FK constraint deleting the old client row — fixed by deleting child
OpenIddictTokens/OpenIddictAuthorizationsrows first - Fresh flow attempt reached the exchange step but showed nothing — browser console revealed a real CORS block and a real server-side
500 - Server logs revealed the actual root cause of the
500: a previousTokenEndpointedit (theauthorization_codebranch) had never actually been applied to the real file — a gap in the chat-based code-handoff process itself, not a logic bug - Handed over the complete file to eliminate ambiguity, rather than another partial diff
- 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
- 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
- Added CORS scoped to OIDC Debugger's origin — caught and self-corrected a middleware-ordering mistake (
UseCorsinitially placed after auth instead of before) before the user ever hit it - Final fresh run succeeded — real
access_tokenandid_token, decoded and verified precisely
3. Friction log
| # | Issue | Root cause | Category | Resolution |
|---|---|---|---|---|
| 1 | Send.RedirectAsync — first guess: CancellationToken as second positional arg | Assumed 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 verified | Real signature confirmed via Rider: no CT parameter exists at all |
| 2 | Send.RedirectAsync — second guess: named cancellation: parameter | Assumed the parameter existed under a different name/position without checking | Repeated same class of mistake as #1 | Same fix as #1 |
| 3 | AllowFormData() called with no arguments still returned 415 | FastEndpoints' own doc example didn't make the default value of urlEncoded obvious in context | Documentation gap, not verified against real signature first | Real signature confirmed via Rider: urlEncoded defaults to false (multipart); needed AllowFormData(true) |
| 4 | Seeded 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 type | Architectural inference risk | Reseeded as genuinely public (PKCE only) — also the objectively correct design, matching the eventual real SPA client |
| 5 | FK constraint violation deleting the old client row | Child OpenIddictAuthorizations/OpenIddictTokens rows existed from the first (confidential-client) flow attempt | Expected relational integrity, not really a bug | Deleted child rows first, in FK-dependency order |
| 6 | Real 500 during the token exchange | A previous TokenEndpoint edit (the authorization_code branch) was never actually applied to the real file | Process gap — chat-based code handoff, not a code or framework bug | Handed 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 consumed | Real OAuth security property working correctly, surfaced via a CORS gap | Added CORS scoped to OIDC Debugger's origin, letting the exchange actually complete visibly |
| 8 | UseCors initially placed after UseAuthentication/UseAuthorization | Contradicted my own stated reasoning about standard middleware ordering | Self-caught error | Corrected 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-clientis 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.comspecifically, not a wildcard — real production CORS for the eventual admin app is a distinct, later decision LoginEndpointdeliberately keeps its ownIsLocalUrlopen-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: correctiss, correctaud(the first meaningful audience claim in the project), and — most importantly — asubclaim matching the exact sameUser.Idfrom registration and manual login testing, confirming identity propagated correctly through every hop (cookie → authorize → code → token) - ✅ OpenIddict's automatic security bindings (
nonceechoed,at_hashpresent) came through correctly as a byproduct of the flow being wired properly — not something explicitly coded
6. Time
| Category | Actual time spent |
|---|---|
| Total, Entry 4 (one continuous session) | 2 hours 20 minutes |
7. Open items carried forward
.Status/ResultStatusvs RoundTrip's real.Type/ResultType— still unresolved- Endpoint style split (item 1's
Send.*vs item 2's rawHttpContext.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.Testsstill a true stub,xunit.runner.jsonstill 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, notpublic) once the actual files were sharedLayerDependencyTests.cs— layer-isolation rules viaNetArchTest(Core/UseCases/Infrastructure must not depend on outer layers), adapted from RoundTrip's real fileLayerConventionTests.cs— naming-convention rules (namespace-prefix-per-layer, Core interfaces start withI,*Extensionsclasses must be static), adapted from RoundTrip's real fileUserTests.cs— full domain coverage forUser: 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 realServiceTicketTests.csconventions (#region-grouped, method-name-first naming,Should.Throw<DomainException>()used specifically rather than the looserException, since testing the exception type is testing the actual architectural guarantee)
2. Sequence of work (condensed)
- Requested a real RoundTrip unit/integration/architecture test file before writing anything, per the established discipline
- Received
CreateTicketHandlerTests.cs,CustomWebApplicationFactory.cs,LayerDependencyTests.cs,LayerConventionTests.cs— confirmedShouldly/NSubstituteconventions, theAction_Scenario_Expectednaming pattern, and got a complete, adaptable template forArchitecture.Tests - Built
Architecture.Testsout fully (4 files) using inferredArchitectureAssemblies/ArchitectureNamespacesmarker types, since the real versions weren't available yet - Received the real
ServiceTicketTests.cs— revealedUser's simpler state model needs none ofServiceTicket's reflection-based backing-field tricks, since everyUserinvariant is reachable through its own public API - Built
UserTests.cs(27 tests) matching the real conventions - 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 thanProgram.csdoing everything inline). Corrected accessibility; deferred the actualProgram.csrefactor as its own future task, by explicit agreement - First test run: reported 33 total instead of the expected 36 — traced precisely to
LayerDependencyTests.cs's 3 tests being entirely absent - User reported replacing that file — second run: now 30 total, with the opposite 6 tests (
LayerConventionTests) missing instead - Traced to the actual root cause:
LayerConventionTests.cshad 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 - Created
LayerConventionTests.csas a genuinely new file — final run: 36/36 passing, every test individually confirmed by name - 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
| # | Issue | Root cause | Category | Resolution |
|---|---|---|---|---|
| 1 | Test run reported 33 instead of 36; traced to LayerDependencyTests.cs's tests being entirely absent | File likely never created, or created with different content than shown | Process gap (same category as Entry 4, item 6) | Re-sent the file content |
| 2 | Second run reported 30 instead of 36; the other file's tests (LayerConventionTests) now missing | LayerConventionTests.cs had never actually existed as its own file — only one Architecture.Tests file had ever been created | Process gap | Created LayerConventionTests.cs as a genuinely new file, confirmed by explicit checklist of all 4 expected files |
| 3 | Root 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 summary | Mixed practice across the session — some files shown fully in chat, some not | Process gap, now with a concrete fix | Going 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 intoInfrastructureServiceExtensions/ServiceConfigs, matching RoundTrip's real, discovered convention — deferred as its own future task, not folded into tonight's testing workShould.Throw<DomainException>()used specifically (not the looserShould.Throw<Exception>()) throughoutUserTests.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, unlikeServiceTicket's — a genuine, positive reflection ofUser'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: everyDomainExceptionguard, 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
| Category | Actual time spent |
|---|---|
| Total, Entry 5 | 1 hour |
7. Open items carried forward
Pbkdf2PasswordHasherunit tests — next natural piece, independent of tonight's workIntegrationTests/FunctionalTests— still both completely empty- The
Program.cs→ServiceConfigs/InfrastructureServiceExtensionsrefactor — 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 testedRegisterWithPasswordCommandHandlerTests.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, explicitusings added (same missing-GlobalUsings.cssituation asEfRepository.csmonths ago), noSeedData,UseOpenIddict()applied proactively this timeRegisterFunctionalTests.cs/LoginFunctionalTests.cs— 6 functional tests total, real HTTP round trips viaWebApplicationFactory, including the exactAllowFormData(true)content-type requirement and cookie-setting behavior discovered through manual testing weeks agoappsettings.Testing.json— a placeholder connection string, needed to satisfyProgram.cs's own eager startup validation beforeConfigureWebHostgets a chance to swap in the real Testcontainers connection string
2. Sequence of work (condensed)
- Wrote and ran
Pbkdf2PasswordHasherTests.cs— 7/7 passing, 43 total - Requested and received RoundTrip's real
IntegrationTestBase.csbefore writing anything — revealed the fresh-container-per-test-method design, and several real simplifications available to Keystone - Built
IntegrationTestBase.cs, confirmed it compiles standalone - Built both integration test classes — first run failed with
PendingModelChangesWarning - Diagnosed precisely:
IntegrationTestBase'sDbContextconfiguration was missing.UseOpenIddict()— the exact same bug as the originalKeystoneDbContextFactoryissue from months ago, reintroduced by not carrying the lesson forward into a new file. Fixed immediately - Reran — 49/49 passing
- Before writing
CustomWebApplicationFactory.cs, proactively caught a real problem: it sets the environment to"Testing", butProgram.cs's transport-security exception only checkedIsDevelopment()— 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 - Built
CustomWebApplicationFactory.csand both functional test classes — caught and fixed a missingusing System.Net.Http.Json;before the user even built - First functional test run failed anyway — a second, different consequence of the
"Testing"environment name:Program.cs's eager connection-string check runs beforeConfigureWebHostever gets a chance to intervene, and ASP.NET Core only loads user secrets under"Development"specifically — so the connection string was genuinely absent - Fixed with
appsettings.Testing.json's placeholder value — reran — 55/55 passing
3. Friction log
| # | Issue | Root cause | Category | Resolution |
|---|---|---|---|---|
| 1 | PendingModelChangesWarning in IntegrationTestBase | Missing .UseOpenIddict() on the DbContext configuration | Same root cause as a bug from months ago, reintroduced — the lesson wasn't carried forward into a new file | Added the missing call |
| 2 | "Testing" environment bypasses the transport-security exception | Program.cs's check only tested IsDevelopment() | Environment-name side effect, caught proactively before running anything | Extended the check to include IsEnvironment("Testing") |
| 3 | "Testing" environment has no connection string at all | ASP.NET Core only loads user secrets under "Development"; Program.cs's eager validation runs before WebApplicationFactory's ConfigureWebHost can intervene | Environment-name side effect, found reactively via a real test failure | Added 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 —IClassFixtureshares one database across all tests in a class, unlikeIntegrationTestBase'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 satisfyingProgram.cs's own startup check beforeConfigureWebHostreplaces it
5. What's proven
- ✅
Pbkdf2PasswordHasher's complete contract — round trip, salting, both malformed-input branches, exact byte format, embedded iteration count - ✅
RegisterWithPasswordCommandHandler/LoginQueryHandleragainst 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
| Category | Actual time spent |
|---|---|
| Total, Entry 6 | 1 hour 15 min |
7. Open items carried forward
- The
Program.cs→ServiceConfigs/InfrastructureServiceExtensionsrefactor — 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/ResultStatusvs RoundTrip's real.Type/ResultType— still unresolved- Endpoint style split (item 1's
Send.*vs item 2's rawHttpContext.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);Webno longer references any of Infrastructure's concrete types directlyOpenIddictConfig.cs— the fullAddOpenIddict()block, relocated verbatimGlobalExceptionMiddleware.cs— the TRA-420 exception handling, now a real middleware class matching RoundTrip'sUseMiddleware<T>()convention (was an inline lambda)SeedData.cs— test-client seeding, relocated verbatimMiddlewareConfig.cs— the full pipeline as one awaited method, plus a newDatabase:ApplyMigrationsOnStartupconfig flag replacing unconditional migrate-on-every-bootServiceConfigs.cs— the single orchestrator;Program.csis now five lines- Added
Ardalis.GuardClausestoInfrastructure.csprojfor 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_credentialstoken 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— theExternalCookiescheme +MicrosoftEntraOpenID Connect client registration, matching the proposal doc's real code exactly, includingAadIssuerValidatorfor multi-tenant issuer validation (verified as a real, current package before pinning)- Added
Microsoft.IdentityModel.Validators(8.16.0)
2. Sequence of work (condensed)
- Requested and received six real RoundTrip config files before writing anything
- Built all six Keystone equivalents, in dependency order (Infrastructure first)
- Rewrote
Program.csto a five-line composition root - Verified: clean build → full test suite (55/55) → manual token request against the refactored path
- 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)
- Created the app registration in the correct tenant (Traxs's own workforce tenant)
- Verified package existence for
Microsoft.IdentityModel.Validatorsbefore pinning a version - Built
ExternalLoginConfig.cs, wired intoServiceConfigs - Attempted to set user secrets for the new Entra values — hit
UserSecretsIdmissing entirely fromKeystone.API.Web.csproj - Regenerated it, re-set all values — discovered
Keystone.API.Infrastructure.csprojhad also lost itsUserSecretsId(previously a copy of Web's, forKeystoneDbContextFactory's benefit) - Fixed both, verified via
dotnet user-secrets liston both projects independently this time, not assumed
3. Friction log
| # | Issue | Root cause | Category | Resolution |
|---|---|---|---|---|
| 1 | Keystone.API.Web.csproj missing <UserSecretsId> entirely | Unconfirmed — 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-caused | dotnet user-secrets init to regenerate |
| 2 | Keystone.API.Infrastructure.csproj also missing <UserSecretsId> | Same unconfirmed root cause as #1 — two independent files losing the same MSBuild property | Real, still not root-caused | Manually 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.propsis 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.csrefactor holds under real verification — full test suite plus a manual token request through the actual refactored path - ✅
Microsoft.IdentityModel.Validators/AadIssuerValidatorconfirmed as the real, current, correct tool for multi-tenant issuer validation before any code was written against it - 🟡
ExternalLoginConfig.cscompiles-pending — not yet build-verified as of this entry
6. Time — fill in actual hours
| Category | Actual time spent |
|---|---|
| Total, Entry 7 | 1 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 indexProcessExternalLoginCommand/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 newUserdirectly viaRegisterFromExternalLogin, 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 incompleteExternalLoginConfig.cs— the actual OIDC client registration, ultimately correct after several real fixes (below)
2. Sequence of work (condensed)
- Built the command/handler/spec and both endpoints against the proposal doc's real code pattern
- First build failure:
AddExternalLoginConfignot found — the entireExternalLoginConfigclass had never actually been created in the real project, despite being discussed - Created it — hit
CS0234,Microsoft.AspNetCore.Authentication.OpenIdConnectmissing 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 - New working agreement adopted here:
Directory.Packages.propsis never replaced wholesale going forward — only specific new lines given, since versions get bumped independently over time - Clean build — first real browser test returned
204 No Contentwith aLocationheader present but the browser never navigating. Diagnosed as FastEndpoints' own "auto-send" step overwritingChallengeAsync's real302 - First fix attempt (
Response.StartAsync()) resolved the204but very likely caused the next bug — a newAuthenticationFailureException: message.State is null or emptyon the callback - Found the correct, documented fix instead:
DontAutoSendResponse()inConfigure()— doesn't touch response-commit timing at all, just tells FastEndpoints to leave the response alone - The state error persisted anyway — real root cause eventually found via the callback request's own DevTools routing metadata:
CallbackPathand our own application's post-sign-inRedirectUriwere configured as the identical path, causing the OIDC middleware to intercept its own redirect target a second time and fail validating a request with nocode/stateat all - Fixed by removing the explicit
CallbackPathoverride (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 - 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
- Found:
emailwas present, just under a legacy WS-Federation-mapped claim name, not the plain OIDC name —MapInboundClaims = falsefixes it - Separately found:
email_verifiedisn'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) - Same null-check error recurred after the
MapInboundClaimsfix — re-ran the claims diagnostic and foundMapInboundClaims = falsehad 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 shortoid - Fixed the
providerKeylookup to useoiddirectly — full, real success: realUsers/ExternalLoginsrows, realKeystone.Logincookie, confirmed against actual DataGrip output - 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
oidlookup
3. Friction log
| # | Issue | Root cause | Category | Resolution |
|---|---|---|---|---|
| 1 | AddExternalLoginConfig not found | The entire ExternalLoginConfig.cs file had never been created in the real project | Process gap — same category as prior sessions' file-sync incidents | Created the file for real |
| 2 | CS0234, AddOpenIdConnect missing | Microsoft.AspNetCore.Authentication.OpenIdConnect isn't part of the shared framework (unlike Cookies/OAuth) — needs its own package | Real framework knowledge gap, confirmed via the actual compiler reference list rather than guessed | Added the package |
| 3 | 204 No Content instead of 302 on the challenge endpoint | FastEndpoints' own auto-send step doesn't recognize ChallengeAsync as having handled the response | Real, confirmed FastEndpoints behavior (verified via a real GitHub issue, not assumed) | DontAutoSendResponse() |
| 4 | Response.StartAsync() (first fix attempt) very likely caused a new state-validation failure | Forcing an early response commit likely interfered with OIDC's own cookie-writing timing | Self-inflicted, caught and corrected before compounding further | Replaced with the documented DontAutoSendResponse() instead |
| 5 | message.State is null or empty, persisting after fix #3 | CallbackPath and our own application RedirectUri were the same path — the OIDC middleware intercepted its own redirect target a second time | Real, structural configuration mistake | Removed 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 name | Real ASP.NET Core default behavior, confirmed via diagnostic claims dump rather than guessed | MapInboundClaims = false |
| 7 | Same error recurred after fix #6 | MapInboundClaims = 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 claim | Real, non-obvious interaction between two claims sharing one mapping mechanism | Changed 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
EmailVerifiedByProvideris hardcodedtruefor theMicrosoftEntraprovider specifically, with the reasoning documented directly in code — organizational tenant control is the verification, structurally different from a personal Microsoft accountDirectory.Packages.propsis 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/ExternalLoginsrows 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
| Category | Actual time spent |
|---|---|
| Total, Entry 8 | 3 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/ResultStatusvs RoundTrip's real.Type/ResultType— still unresolved- Endpoint style split (item 1's
Send.*vs item 2's rawHttpContext.Response) — still undecided - User secrets
UserSecretsIddisappearance — 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
ProcessExternalLoginCommandor 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 (rawHttpContext.Response, no auth justification at all) — fixed toSend.OkAsync- First attempt at fixing the error path (
Send.ResponseAsyncwith a differently-shaped anonymous object) failed to compile — confirmed the method is real but strictly typed to the endpoint's declaredTResponse - 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
HttpContextwriting. 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
HttpContextwhen FastEndpoints has no native method for what's needed at all (a differently-shaped error body, orGetLoginFormEndpoint's raw HTML with noSend.*equivalent) — not just "auth bypass" TokenEndpoint/AuthorizeEndpointdeliberately left untouched despite matching the pattern that brokeExternalLoginChallengeEndpointlast session — both are proven working across many real runs, and the exact mechanism that protects them (best guess: OpenIddict's ownSignInAsyncwrites body content immediately, unlikeChallengeAsync'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
| # | Issue | Root cause | Category | Resolution |
|---|---|---|---|---|
| 1 | All 6 FunctionalTests failing | ExternalLoginConfig's startup guards throwing under Testing environment, no secrets configured there | Recurring pattern — any new required-config value needs a Testing placeholder | Added placeholders to appsettings.Testing.json |
| 2 | Send.ResponseAsync compile failure | Assumed a generic "any object + status code" method existed; it's strictly typed to TResponse | Real FastEndpoints API gap, confirmed via the actual compiler error | Reverted to raw HttpContext — confirmed correct via FastEndpoints' own GitHub issues, not a violation |
| 3 | Unrelated NullReferenceException in GetLoginForm_Returns_Html immediately after the RegisterEndpoint fix | Suspected parallel-test-execution race in FastEndpoints' static endpoint-metadata caching — the failing test has no relationship to the file that changed | Likely test-suite flakiness, not confirmed with certainty | Reran with no code changes — passed clean. Worth watching if it recurs |
| 4 | UserSecretsId missing, twice, across two different files | Root cause finally found: full .csproj replacements during earlier package-addition work silently dropped a GUID that only ever existed in the real file | Process gap — same category as the Directory.Packages.props concern, just not recognized as the same risk at the time | New 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.*/HttpContextconvention now has a real, three-part rule instead of a vague preference, verified against FastEndpoints' own acknowledged limitations rather than assumed - ✅
.Status/.Typequestion closed permanently — a deliberate, justified divergence from RoundTrip, not an unresolved gap - ✅
UserSecretsIdmystery closed with a credible, specific mechanism — not "still unexplained"
4. Time — fill in actual hours
| Category | Actual time spent |
|---|---|
| Total, Entry 9 | 1 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
ProcessExternalLoginCommandor the external-login endpoints - Worth a longer-term watch: whether the
GetLoginForm_Returns_Htmlflakiness (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.
- 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."
- 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
Case 2 confirm-link flow, finally built:
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
- 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.
-
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
-
Time — fill in actual hours
| Category | Actual time spent |
|---|---|
| Total, Entry 10: | 2 1/2 hours |
- 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.
- 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).
- 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
- Time — fill in actual hours
| Category | Actual time spent |
|---|---|
| Total, Entry 11: | 1 hour |
- 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 inCreate(), never mutated anywhere else in either aggregate (confirmed — no method touchesEntraObjectIdafter construction in either file). A straight rename toKeystoneUserIdchanges 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 aTenantUserin more than one RoundTrip customer account (a consultant working two client accounts, say), sinceroundtripapp.onmicrosoft.comis one shared CIAM tenant serving everyone.KeystoneUserIdrepresents 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.TenantUserandTechnicianremain two genuinely separate aggregate roots with two separate tables and two separate composite indexes — nothing shared between them that could let aKeystoneUserIdcross-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.