Skip to content

refactor(domain): migrate ConversationId and SessionId to Vogen value objects#517

Open
sytone wants to merge 5 commits into
mainfrom
refactor/516-conv-session-id-vogen
Open

refactor(domain): migrate ConversationId and SessionId to Vogen value objects#517
sytone wants to merge 5 commits into
mainfrom
refactor/516-conv-session-id-vogen

Conversation

@sytone
Copy link
Copy Markdown
Owner

@sytone sytone commented May 23, 2026

Summary

Closes the F-13 silent-string-conversion footgun on ConversationId and SessionId — the two highest-traffic identifiers that the AgentId seed (#513) did not touch. Both move to the same Vogen shape AgentId uses:

csharp [ValueObject<string>(conversions: Conversions.SystemTextJson)] public readonly partial struct SessionId { ... }

This finishes Phase 1 of the domain-model refactor (issues #500 / #513): the three highest-traffic IDs in the codebase now have one validation rule, one JSON converter, and zero implicit string casts.

What changed

Layer Change
ConversationId / SessionId Hand-rolled record structs replaced with Vogen-generated value objects. Validate rejects null/empty/whitespace; NormalizeInput trims. The hand-written ConversationIdJsonConverter / SessionIdJsonConverter are deleted — Vogen generates them.
SessionId factories Create / ForSubAgent (x2 overloads) / ForAgentConversation / ForSoul (x2 overloads) / ForCrossAgent preserved with identical output formats; format-pinning tests guard against drift. All factories internally call From(...) (Vogen blocks the public constructor).
SessionId discriminators IsSubAgent / IsAgentConversation / IsSoul preserved. (Scheduled for removal in Phase 5 once SessionType becomes authoritative — out of scope here.)
Production call sites 11 files: typed-to-string DTO crossings use .Value; string-to-typed crossings use .From(...); string.Equals(..., OrdinalIgnoreCase) swapped to Vogen value equality (== / != — SessionIds are GUIDs or structured agent::id::guid strings); ConversationsController.TryParseVirtualCronConversationId reshaped to return out SessionId? (Vogen analyser VOG009 blocks default(SessionId)).
Test sweep 27 test files wrap string ids with SessionId.From(...); the few helpers that still accept string (e.g. CreateHandleMock(string sessionId)) get .Value. SessionStoreEdgeCaseTests + PrimitiveBoundaryTests flip the expected exception from ArgumentException to Vogen.ValueObjectValidationException (the previous TODO comments called this out — this is the follow-up).
New domain tests ConversationIdTests (11 facts) and SessionIdTests (18 facts) — every factory format pinned, JSON round-trip, no-implicit-string reflection check.
Architecture rules 4 new fact methods mirror the existing AgentId rules for the two new value objects: ConversationId_IsVogenValueObject / ConversationId_HasNoImplicitStringConversion and the same pair for SessionId.

Validation

  • dotnet build BotNexus.slnx --nologo --tl:off — 0 errors, 0 warnings (TreatWarningsAsErrors=true).
  • dotnet test BotNexus.slnx --nologo --tl:off --no-build — full sweep green:
    • BotNexus.Domain.Tests: 221 / 221
    • BotNexus.Architecture.Tests: 14 / 14 (4 new rules)
    • BotNexus.Gateway.Tests: 1633 / 1633
    • All other projects green.

Commits

  1. refactor(domain): migrate ConversationId and SessionId to Vogen value objects
  2. test(domain): pin ConversationId and SessionId behaviour under Vogen
  3. refactor(gateway): use typed-id equality and explicit boundaries for SessionId
  4. test(gateway): wrap string ids with SessionId.From at typed-id call sites
  5. test(architecture): extend Vogen fitness functions to ConversationId and SessionId

Out of scope (next PR)

Closes #516.

sytone and others added 5 commits May 22, 2026 17:42
… objects

Closes the F-13 silent-string-conversion footgun on the two highest-traffic
identifiers that the AgentId seed (#513) did not touch. Mirrors the
`[ValueObject<string>(conversions: Conversions.SystemTextJson)]` shape used
by AgentId so Vogen owns construction, JSON serialization, and equality.

Both types preserve their public API:

- `ConversationId.Create()` still emits `c_<32-char-guid>`.
- `SessionId.Create / ForSubAgent (x2) / ForAgentConversation / ForSoul (x2)
  / ForCrossAgent` keep identical output formats; all factories internally
  call `From(...)` because Vogen blocks the public constructor.
- `IsSubAgent / IsAgentConversation / IsSoul` discriminator properties
  remain (they are scheduled for removal in Phase 5 once SessionType is the
  authoritative source — out of scope for this PR).

Validation is now centralised in `Validate` + `NormalizeInput` partial
methods (reject null/empty/whitespace, trim on input). The legacy
`ArgumentException` is replaced by `Vogen.ValueObjectValidationException`;
existing tests are updated in matching commits.

The hand-written `ConversationIdJsonConverter` and `SessionIdJsonConverter`
are deleted — Vogen generates the equivalent `JsonConverter` from the
`Conversions.SystemTextJson` option.

Refs #516.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Rewrites the dedicated tests for both identifiers so they exercise the
Vogen-generated shape end-to-end:

- Validation: null, empty, and whitespace inputs reject with
  `Vogen.ValueObjectValidationException` (built-in null check is asserted
  by exception type only — Vogen owns the message text).
- Trim semantics from `NormalizeInput` are pinned.
- `Create()` format is pinned (`c_<32-char-guid>` for ConversationId; bare
  32-char N-formatted guid for SessionId).
- Every SessionId structured factory has a format-pinning fact:
  `ForSubAgent` / `ForAgentConversation` / `ForSoul` (both DateOnly and
  DateTimeOffset overloads) / `ForCrossAgent`. The `ForSoul` test uses a
  `DateTimeOffset` with a +5:00 offset to confirm UTC conversion.
- `System.Text.Json` round-trip is asserted on both types.
- A reflection-based check confirms no implicit string conversion operator
  exists — the architecture rules in a sibling commit enforce the same
  invariant structurally.

Updates `PrimitiveBoundaryTests` for the consequential behaviour change:
`SessionId.ForSubAgent` with null/empty/whitespace parent now flows through
Vogen `From(...)` and throws `ValueObjectValidationException` instead of
`ArgumentException`. The shared whitespace-rejection fact is amended for
both ConversationId and SessionId; the still-hand-rolled primitives
(SenderId, ToolName, ChannelKey) continue to throw `ArgumentException`
pending their later Vogen wave.

Refs #516.

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

Sweep of production call sites that previously relied on the implicit
`string <-> SessionId` / `string <-> ConversationId` operators removed
by the Vogen migration. No behavioural change.

Boundary patterns applied:

- Typed-to-string DTO crossings (e.g. `InboundMessage.SessionId` is still
  `string?` until the channels Vogen wave) take `.Value` explicitly so
  the boundary is visible (`MemoryIndexer`, `HeartbeatAction`,
  `AgentPromptActionStub`, `FileSessionStore` filename helpers,
  `ChannelHistoryController`, `DefaultSubAgentManager`,
  `InProcessIsolationStrategy`).
- String-to-typed crossings wrap explicitly via `SessionId.From(...)` /
  `ConversationId.From(...)` instead of the deleted implicit cast
  (`GatewayHub`, `DefaultSubAgentManager`).
- `string.Equals(..., OrdinalIgnoreCase)` calls on session ids are
  replaced with Vogen value equality (`==` / `!=`) — SessionIds are
  GUIDs or structured `agent::id::guid` strings so case sensitivity is
  the correct comparison. (`SubAgentManageTool`, `SessionsController`.)
- `ConversationsController.TryParseVirtualCronConversationId` no longer
  assigns `default(SessionId)` (rejected by Vogen analyser VOG009).
  Signature is now `[NotNullWhen(true)] out SessionId?` returning
  `null`; the two callers unwrap with `.Value`.

Refs #516.

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

Sweep across 27 test files that previously relied on the implicit
`string -> SessionId` cast removed by the Vogen migration. Three
patterns applied:

- `SessionId.From(...)`: most call sites — store APIs like
  `GetAsync` / `DeleteAsync` / `ArchiveAsync` and constructors of
  `GatewaySession` now take a typed `SessionId`.
- `.Value` to bare string: a handful of sites pass a SessionId into a
  helper that still takes `string` (e.g. `InboundMessage.SessionId` is
  `string?` until the channels Vogen wave;
  `CreateHandleMock(string agentId, string sessionId)` in
  `DefaultAgentSupervisorTests`).
- Equality update in `SessionStoreEdgeCaseTests`: the expected exception
  on `SessionId.From(null/""/" ")` flips from `ArgumentException` to
  `Vogen.ValueObjectValidationException` (the comment that called this
  out as a follow-up is removed — this is the follow-up).

In `CronSchedulerTests` one site that wrapped a string just to satisfy
a `string`-typed parameter is simplified — the wrapping was already
redundant.

No new test cases; no behavioural change beyond the boundary visibility
the Vogen migration enforces.

Refs #516.

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

Adds four new fact methods that mirror the existing AgentId rules so the
two newly-Vogen-migrated identifiers are structurally locked the same way
AgentId is:

- `ConversationId_IsVogenValueObject` / `SessionId_IsVogenValueObject`
  assert `[ValueObject<string>]` is present.
- `ConversationId_HasNoImplicitStringConversion` /
  `SessionId_HasNoImplicitStringConversion` assert no
  `op_Implicit` exists.

The generic `DomainPrimitives_VogenValueObjectsAreStructs` rule already
covered the struct-shape invariant for any future value object.

Refs #516.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

[Platform] Primitive obsession Phase 1 (complete) -- migrate ConversationId and SessionId to Vogen

1 participant