Skip to content

refactor(domain): migrate AgentId to Vogen value object#513

Merged
sytone merged 5 commits into
mainfrom
feat/500-vogen-seed
May 22, 2026
Merged

refactor(domain): migrate AgentId to Vogen value object#513
sytone merged 5 commits into
mainfrom
feat/500-vogen-seed

Conversation

@sytone
Copy link
Copy Markdown
Owner

@sytone sytone commented May 22, 2026

Summary

Phase 1 — Vogen seed PR (the first PR of the BotNexus domain-model cleanup).

Migrates AgentId from a hand-rolled readonly record struct to a Vogen [ValueObject<string>], establishes the Vogen + NetArchTest toolchain, and introduces the architecture-fitness-function project that will enforce the new conventions structurally from this point forward.

Closes #500 (partial — establishes the Vogen seed and migrates the first of the three IDs scoped to that issue; ConversationId and SessionId follow in subsequent PRs so each diff stays reviewable).

Why

The hand-rolled primitive structs in BotNexus.Domain.Primitives had three documented problems (review §F-13):

  1. Silent string conversion on the hottest IDs. AgentId and SessionId had implicit operator string and implicit operator AgentId(string), defeating the entire point of strong typing — someConversationMethod(agentId) compiled silently.
  2. Inconsistent validation rules across siblings. AgentId.From trimmed and rejected whitespace; ChannelAddress.From did neither; ThreadId.From checked neither. Different IDs, different rules.
  3. Auto-generated nonsense XML docs. Verbatim from AgentId.cs: Represents struct. / Executes from.

Vogen fixes all three structurally:

  • The Roslyn analyser (VOG009) blocks default(AgentId) and new AgentId() at compile time.
  • Implicit conversions are opt-in and we leave them off — .Value and .From(...) are explicit.
  • Validation lives in one place (Validate partial method); JSON / EF converters are generated.

What's in this PR

Commit Scope
build(deps): add Vogen and NetArchTest package versions Central versions only.
refactor(domain): delete orphan SessionStatus and AgentIdJsonConverter Closes F-4 dup type; removes the now-obsolete hand-rolled JSON converter.
refactor(domain): migrate AgentId to Vogen value object The main migration — AgentId.cs + ~85 call-site files + 3 dead-defensive-branch removals (see below).
test(architecture): introduce BotNexus.Architecture.Tests with domain fitness functions 5 initial rules covering the conventions this PR establishes.
docs(agents): document Vogen value-object convention Root AGENTS.md + src/domain/AGENTS.md.

Tests migrated (per AGENTS.md rule 7: tests are never net-deleted, they are migrated)

Three test cases that constructed default(AgentId) became uncompilable under the Vogen analyser. They were migrated — not deleted — to AgentIdTests.From_RejectsNull and From_RejectsEmptyOrWhitespace. The scenarios they tested (defensive handling of empty AgentId on the wire) are now structurally prevented, so the matching dead code branches in production were removed in the same commit:

  • AgentDescriptorValidator — empty-AgentId.Value check
  • AgentsController.Update — empty-payload-AgentId backfill branch (route-vs-payload mismatch check is preserved)
  • GatewayHub.NormalizeAgentId — try/catch around AgentId.From (now a pass-through; the SessionId and ChannelKey normalisers retain their try/catch because those primitives are still hand-rolled)

Architecture fitness functions added (§9.4)

5 initial rules in BotNexus.Architecture.Tests:

  • Domain_HasNoProjectReferences
  • AgentId_IsVogenValueObject
  • AgentId_HasNoImplicitStringConversion
  • DomainPrimitives_VogenValueObjectsAreStructs
  • Domain_HasNoTwoPublicTypesWithTheSameSimpleName

More rules are added as later Phase 1 PRs land (Contracts/Abstractions deps, no-string-Ids-on-public-contracts, no-ThreadId after Phase 6b).

What's NOT in this PR (deliberately)

  • ConversationId / SessionId Vogen migration — separate PR each, keeps the diffs reviewable.
  • ChannelKey / ChannelAddress / SenderId / ToolName migration — later in Phase 1.
  • ThreadIddeleted by Phase 6b, not migrated. The Vogen sweep skips it on purpose.
  • Anything from Phases 1.5–8 of the plan.

Validation

  • dotnet build BotNexus.slnx --nologo --tl:off — 0 warnings, 0 errors.
  • dotnet test BotNexus.slnx --nologo --tl:off --no-build3818 passed, 40 skipped (the 5 new architecture tests + the existing 3813).
  • All test projects passed when run individually; one project showed a parallel-execution flake on a busy full-suite run that did not reproduce when re-run.

Plan references

  • Plan §1 — Phase 1 (Collapse duplicate types + seed Vogen tooling)
  • Plan §8.1 — Vogen for all value objects (decision)
  • Plan §9.3 — Phase-0 characterization pin (PrimitiveValidationInconsistencyTests to be added next PR alongside the ConversationId migration)
  • Plan §9.4 — Architecture fitness functions
  • Finding §F-4 — Duplicate SessionStatus (closed by commit 2)
  • Finding §F-13 — Hand-rolled primitives (closed for AgentId by commit 3)

sytone and others added 5 commits May 22, 2026 14:12
Adds central package versions for the Vogen value-object source
generator (8.0.5) and NetArchTest.Rules (1.3.2). Wires Vogen into
src/domain so the source generator is available for the AgentId
migration. NetArchTest is consumed by the new architecture-fitness-
function project.

Refs: #500

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Removes two unused public types ahead of the AgentId Vogen migration:

- BotNexus.Domain.Primitives.SessionStatus (smart-enum class) was an
  orphan duplicate of the Gateway SessionStatus enum used everywhere
  (aliased as GatewaySessionStatus). The class is missing the Expired
  value and is referenced by nothing in production. Removing it
  collapses the duplicate-types confusion called out in F-4.

- BotNexus.Domain.Serialization.AgentIdJsonConverter — Vogen generates
  the System.Text.Json converter from [ValueObject(conversions:
  Conversions.SystemTextJson)], so the hand-rolled converter is no
  longer wired in. Its companion test file is deleted with it.

Refs: #500 (Phase 1 — Vogen seed PR, finding F-4 and F-13)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Migrates BotNexus.Domain.Primitives.AgentId from a hand-rolled
readonly record struct to a Vogen-generated [ValueObject<string>]
with System.Text.Json conversion.

Why this change matters (F-13):

* Closes the silent-cast footgun. The previous AgentId had implicit
  operator string and implicit operator AgentId(string), defeating the
  whole point of a typed identifier. Vogen does not emit implicit
  conversions, so call sites must use .Value (out) and .From(...) (in)
  explicitly.
* Closes 'default(AgentId) is constructable' — the Vogen analyser
  (VOG009) rejects default(AgentId) and new AgentId() at compile time.
* Replaces the auto-generated nonsense XML docs ('Represents struct.',
  'Executes from.') with a single accurate validation contract comment.
* Replaces the hand-written AgentIdJsonConverter (deleted in the
  previous commit) with the Vogen-generated converter.
* Unifies validation: AgentId.From(null/empty/whitespace) throws
  ValueObjectValidationException consistently.

Call-site changes:

* Production: ~25 files updated to use AgentId.From(...) at boundaries
  (deserialisation, REST/SignalR contracts, persistence) and .Value
  when serialising back to string. Three GatewayHub normalizers stay
  hand-rolled for the still-string-typed SessionId and ChannelKey
  primitives; NormalizeAgentId is now a pass-through.
* Production dead-code removal: three defensive branches that handled
  the old 'default(AgentId)' or 'AgentId with empty Value' cases are
  removed (AgentDescriptorValidator, AgentsController.Update empty-
  payload branch, GatewayHub.NormalizeAgentId try/catch). The route-
  vs-payload mismatch check in AgentsController.Update is preserved.
* Tests: ~60 files updated with the same wrap/unwrap pattern. Three
  test cases that constructed default(AgentId) (which is now blocked
  by Vogen analyser) are migrated to AgentIdTests.From_RejectsNull /
  From_RejectsEmptyOrWhitespace — the scenario is now structurally
  prevented rather than runtime-checked.
* Test exception expectations updated where AgentId.From now throws
  ValueObjectValidationException instead of ArgumentException; cross-
  primitive tests now expect per-primitive exception types until
  SessionId/ChannelKey are also Vogen-migrated (future PRs).

SessionId, ConversationId, ChannelKey, SenderId, ToolName,
ChannelAddress, ThreadId remain hand-rolled and will be migrated
(or deleted, in ThreadId's case) in subsequent PRs of Phase 1.

Refs: #500 (Phase 1 — Vogen seed PR, finding F-13)

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

Adds a new test project that enforces structural conventions at build
time, rather than relying on documentation discipline alone. This is
the antidote to the kind of drift catalogued in the domain-model
review (AGENTS.md says one thing, code says another — F-8, F-13,
recurrence of F-4).

Initial rules (5):

* Domain_HasNoProjectReferences — BotNexus.Domain references only
  framework assemblies and Vogen's source-generator runtime types.
* AgentId_IsVogenValueObject — the AgentId type carries
  [ValueObjectAttribute<>].
* AgentId_HasNoImplicitStringConversion — defends against re-adding
  the implicit operator string footgun that motivated this migration.
* DomainPrimitives_VogenValueObjectsAreStructs — Vogen value objects
  in Primitives/ must be value types (catches accidental class usage).
* Domain_HasNoTwoPublicTypesWithTheSameSimpleName — defends against
  recurrence of the SessionStatus duplicate types (F-4).

The project uses NetArchTest.Rules for one rule and direct reflection
for the others (the reflection-based rules read more clearly than the
fluent API would for attribute lookup and implicit-operator scans).

Future Phase 1 PRs will add rules covering Contracts/Abstractions
dependencies, no-string-Ids-on-public-contracts, and no-ThreadId
(after Phase 6b deletes it). Per the plan §9.4.

Refs: #500 (Phase 1 — Vogen seed PR, §9.4)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the 'all new value objects use Vogen' convention to the root
AGENTS.md and a worked AgentId example to src/domain/AGENTS.md.

The convention:

* New domain identifiers and scalar value objects are defined as
  Vogen [ValueObject<T>] in BotNexus.Domain.Primitives.
* No implicit operator T to/from the backing primitive — .Value and
  .From(...) are explicit so the type boundary stays visible.
* Validation lives in the Validate partial method; the JSON converter
  is generated via Conversions.SystemTextJson.
* New hand-rolled value-object structs will be rejected by the
  architecture fitness functions in BotNexus.Architecture.Tests.

Refs: #500 (Phase 1 — Vogen seed PR, §8.1)

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 -- create AgentId, ConversationId, SessionId value objects with Vogen

1 participant