Skip to content

feat(gateway)!: flip Session.ConversationId to non-nullable (P9-B-2, closes #627)#641

Merged
sytone merged 6 commits into
mainfrom
feat/627-session-conversation-nonnullable
May 29, 2026
Merged

feat(gateway)!: flip Session.ConversationId to non-nullable (P9-B-2, closes #627)#641
sytone merged 6 commits into
mainfrom
feat/627-session-conversation-nonnullable

Conversation

@sytone
Copy link
Copy Markdown
Owner

@sytone sytone commented May 29, 2026

Summary

Closes #627. Refs umbrella #613 (P9).

P9-B-2 flips Session.ConversationId from ConversationId? to non-nullable ConversationId per maintainer directive G-1: every session in the system MUST belong to a conversation. The unset sentinel default(ConversationId) is short-lived (in-memory construction only) and is distinguished from a stamped value via ConversationId.IsInitialized(). The save path stamps via LegacyConversationResolver before the writer runs; the writer fails loud if it ever sees an uninitialized value.

Commits

  1. feat(domain)!: flip Session.ConversationId to non-nullable (P9-B-2) — domain shape change
  2. feat(sessions): SQLite fail-loud guard + save-time ConversationId backfill — writer enforces invariant, save path stamps via resolver
  3. feat(sessions): FileStore eager startup sweep + pre-bind ordering invariant — MigrateOrphanedSessionsAsync with pre-bind-most-recent-Active ordering
  4. fix(gateway): use IsInitialized() guard instead of nullable pattern — 10 callsites that became silent always-true after the flip
  5. test(architecture): pin Session.ConversationId non-nullable invariant — new arch fitness function (7/7)
  6. test(gateway): migrate fixtures + add eager-sweep tests — 19 test files migrated + 21 new eager-sweep tests

The ?.ConversationId is { } regression we caught

Pre-flip, session.ConversationId is { } convId was a nullable pattern that correctly unwrapped the Some-case. Post-flip, the pattern silently became ALWAYS-TRUE because the property is a non-nullable Vogen struct. The orphan-fallback / unset-fallback branches at 10 callsites were unreachable. The hub test ResetSession_OrphanSession_NoConversation_SealsInPlace_DoesNotArchive caught this via Moq Strict on IConversationResetService.ResetActiveSessionAsync — without that test the regression would have shipped silently.

Fixed pattern is uniform: session is not null && session.ConversationId.IsInitialized() then read session.ConversationId directly. The new arch fitness function pins this against revert.

Critique sweep

  • security-review (gpt-5.5): clean. Verified XPIA boundaries, fail-loud DoS surface, CrossWorldFederation 5 ownership invariants, LegacyConversationResolver hardening from PR feat(sessions): backfill orphan sessions to per-agent legacy conversations #616 still holds.
  • bug-hunt (gpt-5.3-codex): clean. Verified every callsite migrated (zero grep hits on the regression pattern), MigrationInvocationCount increments inside the lock-gated path, InMemoryConversationStore correctly shared per-fixture, SQLite reload reads from disk not cache, INSERT INTO sessions only appears at one writer, Vogen-null sidecar handling correct.

Validation

Out of scope (separate follow-ups)

sytone and others added 6 commits May 29, 2026 15:56
Per maintainer directive G-1 + W-4: Session.ConversationId is now a
non-nullable Vogen `ConversationId` value object instead of
`ConversationId?`. Every session in the system MUST belong to a
conversation. The unset-sentinel is `default(ConversationId)` and is
distinguished from a stamped value via `ConversationId.IsInitialized()`.

Domain shape:
- `Session.ConversationId` is now `ConversationId` (non-nullable).
- `GatewaySession.ConversationId` proxy mirrors the new shape.
- `ConversationId` Vogen reconfig: keep `IsInitialized()` generation
  on for the sentinel discriminator and add `required` enforcement at
  the persistence boundary.

Callers can no longer treat `session.ConversationId` as nullable. Use
`session.ConversationId.IsInitialized()` to discriminate the
unset-sentinel case from a stamped value. The save path stamps via
`LegacyConversationResolver` before the writer runs (see follow-up
commit) so the in-memory unset window is short-lived during construction
only.

Refs #613, closes #627.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…kfill

After flipping Session.ConversationId to non-nullable (P9-B-2), the
store layer enforces that every persisted session has a stamped
`ConversationId`. The save path stamps via `LegacyConversationResolver`
before the writer runs; the writer throws if it ever sees an
uninitialized value.

Changes:
- `SqliteSessionStore.UpsertSessionAsync` now throws
  `InvalidOperationException` if `session.ConversationId.IsInitialized()`
  is false. This is fail-loud-by-design: it surfaces composition errors
  (resolver not wired) and bypass attempts immediately rather than
  silently writing NULL.
- `SaveAsync` / `ArchiveAsync` unconditionally call
  `EnsureConversationIdStampedAsync` (in `SessionStoreBase`) before
  the writer, so the throw only fires when the resolver is missing or
  someone bypasses `SaveAsync`.
- `SessionStoreBase.EnsureConversationIdStampedAsync` is the single
  entry point for save-time backfill. It is idempotent and re-entrant.
- `InMemorySessionStore` mirrors the same shape for parity.
- `SqliteSessionStore` ctor now accepts an optional `IConversationStore`
  to power the resolver wiring.

Refs #613, refs #627.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ariant

`FileSessionStore` now eagerly migrates orphan sessions at startup so
that the first load returns sessions with a stamped `ConversationId`
under the new non-nullable invariant.

`MigrateOrphanedSessionsAsync` runs once per process under
`_migrationLock` + `_migrated` gate. It:

1. Pre-binds the most-recently-updated **Active** orphan per agent to
   its legacy conversation FIRST via
   `BindActiveSessionIfNoneAsync(OrderByDescending(UpdatedAt).First())`.
   This MUST happen before the per-orphan stamp loop, otherwise the
   load-time backfill in `LoadFromFileAsync` would call
   `BindActiveSessionIfNoneAsync` for the first orphan it touches in
   alphabetical (filename) order and pin the wrong ActiveSessionId.
2. Walks every orphan file, stamps `Session.ConversationId` via the
   resolver, and rewrites the file with the Vogen-safe nullable shape
   for back-compat sidecar columns (`value.IsInitialized() ? value :
   (T?)null`).
3. Sealed / Suspended / Expired orphans get stamped but NOT bound — only
   Active orphans contribute to `ActiveSessionId`.

Includes a `MigrationInvocationCount` probe counter
(`Interlocked.Increment` inside the lock-gated path) used by tests to
prove the eager sweep ran exactly once per process.

The pre-bind step makes the per-orphan `BindActiveSessionIfNoneAsync`
call inside the loop a no-op (mirror-back path in the resolver), so the
two binding sites are idempotent against each other.

Refs #613, refs #627.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n Session.ConversationId

After P9-B-2 flipped `Session.ConversationId` to non-nullable, every
existing callsite that used `session.ConversationId is { } convId`
(nullable pattern) silently became ALWAYS-TRUE because `ConversationId`
is now a non-nullable Vogen struct. The orphan-fallback / unset-fallback
branches in those callsites were unreachable.

Fixed callsites (use `session.ConversationId.IsInitialized()` to
discriminate the unset-sentinel case, then read `session.ConversationId`
directly):

- `GatewayHub.ResetSession` (orphan-session check) —
  detected by `ResetSession_OrphanSession_NoConversation_SealsInPlace_DoesNotArchive`
  via Moq Strict on `IConversationResetService.ResetActiveSessionAsync`.
- `ConversationsController` linked-conversation lookup.
- `CrossWorldFederationController` ConversationId reuse refusal gate.
- `SessionsController` ConversationId resolution.
- `GatewayHost` (ask-user interceptor + fan-out stale-binding mute).
- `InProcessIsolationStrategy.ResolveConversationIdAsync`.
- `WorkspaceContextBuilder` system-prompt context resolution.
- `DefaultConversationRouter` ConversationId routing.
- `CronTrigger` ConversationId scheduling.
- `VirtualWorld` scenario harness ConversationId helper.

Note: `CronTrigger` `request?.ConversationId is { }` is correct as-is
because `IInternalTrigger.ConversationId` (the request DTO field) is
genuinely `ConversationId?` and only the `Session.ConversationId` site
needed the IsInitialized fix.

Refs #613, refs #627.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
New architecture fitness function with reflection + source fences that:

1. Asserts via reflection that `Session.ConversationId` is
   `ConversationId` (not `ConversationId?`), with the same check on
   `GatewaySession.ConversationId`.
2. Bans the nullable patterns that became silent always-true after the
   flip: `session.ConversationId is null`, `== null`, `.HasValue`,
   `?.Value`, etc. across `src/`.
3. Sanctions the IsInitialized discriminator with a 3-entry allowlist
   covering the legitimate usages (the only callsites the flip needs).
4. Self-tests that all source fences match at least one symbol — prevents
   silent neutralization the way our prior regex fences regressed when
   return types went generic (e.g. `Task<T>` not matched by `\w+`).
5. Self-tests prove non-vacuity of the allowlist itself.

7/7 GREEN against the post-P9-B-2 source tree.

Refs #613, refs #627.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…il-loud guard

The P9-B-2 fail-loud guard in `SqliteSessionStore.UpsertSessionAsync`
caused every test that constructed a store without an
`IConversationStore` to fail with
`InvalidOperationException: Refusing to persist session 'X' for agent
'Y' with an unset ConversationId.`

Migrated test fixtures to inject a shared per-fixture
`InMemoryConversationStore` so save-time backfill via
`LegacyConversationResolver` can stamp `Session.ConversationId`
before reaching the writer. Each fixture exposes the conversation store
as a property so reload/round-trip tests can use the same in-memory
state across multiple `CreateStore()` invocations.

Files migrated:
- `SqliteSessionStoreTests`, `FileSessionStoreTests`,
  `SessionStoreBaseContractTests`, `SessionStoreExistenceQueryTests`,
  `SqliteSessionStoreSessionParticipantBackCompatTests`,
  `SqliteSessionStoreConversationIdTests` — primary store fixtures.
- `Integration/SessionCompactionIntegrationTests` — `SqliteStoreFixture`
  now shares an `InMemoryConversationStore` across the write→reload
  test that proves persisted state survives a fresh store instance.
- `Integration/SignalRConversationRoutingTests`,
  `SignalRReliabilityTests`, `SignalRThreadRoutingTests` — composed
  fixtures.
- `ConversationsControllerHistoryTests`,
  `CrossWorldFederationControllerTests`,
  `GatewayHostTests`, `SessionsControllerTests`,
  `GatewaySessionBehaviorSnapshotTests`,
  `AgentExchangeConversationTests`, `SubAgentEagerConversationPinTests`,
  `DefaultConversationRouterTests` — controller/integration tests.

New tests in `LegacyConversationBackfillTests`:
- 21 eager-sweep tests covering `MigrateOrphanedSessionsAsync` invariants:
  pre-bind-most-recent-Active-orphan ordering, per-status stamping
  (Active binds + stamps; Sealed/Suspended/Expired stamp without binding),
  idempotency via `MigrationInvocationCount` probe counter, single-run
  per process under `_migrationLock`, deterministic ordering when
  multiple Active orphans share the same UpdatedAt.

Final state: Gateway.Tests 1995 / 0 / 1-skip GREEN.

Refs #613, closes #627.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sytone sytone merged commit dd552bb into main May 29, 2026
10 checks passed
@sytone sytone deleted the feat/627-session-conversation-nonnullable branch May 29, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Gateway] P9-B-2 — Flip Session.ConversationId to non-nullable + FileStore eager startup sweep

1 participant