Architecture
Wayd follows Clean Architecture with Domain-Driven Design (DDD) principles, organized as a modular monolith with a shared database.
Clean Architecture Layers
Dependency Rules
- Domain (innermost) - Zero external dependencies. Contains entities, value objects, domain events, and interfaces.
- Application - Depends on its own Domain and the Common projects only. Contains commands, queries, handlers, DTOs, and validators.
- Infrastructure - Depends on Application and Domain. Contains EF Core, authentication, background jobs, and external integrations.
- Web API - Depends on all layers. Thin controllers that delegate to handlers via
IDispatcher(Wolverine).
Services are isolated from each other as well: a service's Domain depends on no other service's Domain, and
its Application depends on its own Domain and the Wayd.Common* projects, never on another service's Domain
or Application. Application projects may reference Wayd.Integrations.Abstractions but no concrete
integration, and the Wayd.Integrations.* projects depend only on the Wayd.Common* projects. A module
that needs another module's data asks through a request in Common.Application/Requests (see
Reading another domain at query time).
Architecture tests
Wayd.ArchitectureTests enforces these rules with NetArchTest and reflection, so breaking one fails the
build's test run rather than a review. Run it after changing a project reference.
| Test class | What it holds |
|---|---|
CrossProjectDependencyTests | The layer and service-isolation rules above |
DatabaseContextTests | Domain projects never reference EF Core or a context; Application projects use the IXxxDbContext interfaces, never WaydDbContext; contexts and entity configurations live in Infrastructure |
CqrsPatternTests | Commands and queries are public sealed records named *Command / *Query; handlers are public and named *Handler; each command or query has exactly one handler (Wolverine silently combines duplicates); handlers never inject another handler; command handlers return Result; only the dispatch seams touch Wolverine's IMessageBus, and only the SaveChanges drain touches IEventPublisher |
ServiceLayeringTests | The service and saga tiers in Services and sagas |
TransactionScopeTests | Only the context opens a transaction, and nothing turns on a retrying execution strategy — see What a save commits |
EventCoverageTests | An evented aggregate changes only through events — see Domain Events |
FileStructureTests, TestProjectTests | Projects sit in src/ and tests/ folders, test projects are named after the project they test, reference it, and use xUnit |
Solution Structure
Wayd/
Wayd.Common/ # Shared libraries and base abstractions
src/
Wayd.Common # Utilities, extensions, helpers
Wayd.Common.Domain # Base entities, domain events, the shared Employee, interfaces
Wayd.Common.Application # Dispatch interfaces, cross-module requests, persistence interfaces
tests/
Wayd.Tests.Shared # Shared test utilities, traits and cross-cutting fakers
Wayd.TestData.Core # PrivateConstructorFaker<T>
Wayd.Tests.Containers # The shared SQL Server Testcontainers image
Wayd.Services/ # Vertical slice domain services
Wayd.Work/ # Work items, sprints and the work sync
Wayd.Organization/ # Teams and employees
Wayd.Planning/ # Planning intervals, PI iterations and sprint mappings
Wayd.ProductManagement/ # Product catalog, versions, releases, deployments
Wayd.ProjectPortfolioManagement/ # Portfolios, programs, projects, tasks
Wayd.StrategicManagement/ # Visions, strategies, strategic themes
Wayd.AppIntegration/ # Integration configuration and the sync runners
Wayd.Links/ # Cross-entity relationships
Wayd.Infrastructure/ # EF Core, migrations, auth, messaging, jobs
Wayd.Integrations/ # External system clients (Azure DevOps, Microsoft Graph, Workday)
Wayd.Web/ # Presentation layer: Wayd.Web.Api, wayd.web.reactclient, Wayd.Mcp
Wayd.Tools/ # Developer tools (data generation)
Wayd.AppHost/ # .NET Aspire host for local development
Wayd.ArchitectureTests/ # Architecture rules, at the root with no src/ or tests/ folder
Key Patterns
CQRS with Wolverine
All business operations are modeled as commands (writes) or queries (reads), dispatched through IDispatcher (backed by Wolverine). Controllers are thin and contain no business logic. Handlers must be public — Wolverine generates the code that invokes them and cannot call internal types.
Handler dependencies are constructor-inlined by Wolverine's codegen, and service location is disabled (ServiceLocationPolicy.NotAllowed). A dependency whose registered implementation is internal (public interface, internal class) cannot be inlined; it must be added to the AlwaysUseServiceLocationFor<T>() allow-list in WolverineConfiguration — or the implementation made public. Getting this wrong fails with an InvalidServiceLocationException naming the offending type: in CI when codegen write generates the handler tree, and locally the first time the handler is dispatched, since local runs compile handlers at runtime.
// Command
public sealed record CreateWorkItemCommand(string Title, Guid WorkspaceId) : ICommand<Guid>;
// Query
public sealed record GetWorkItemQuery(Guid Id) : IQuery<WorkItemDto>;
Result Pattern
Command handlers return Result or Result<T> from CSharpFunctionalExtensions instead of throwing exceptions:
public async Task<Result<Guid>> Handle(CreateWorkItemCommand request, CancellationToken ct)
{
var workspace = await _workDbContext.Workspaces.FindAsync([request.WorkspaceId], ct);
if (workspace is null)
return Result.Failure<Guid>("Workspace not found.");
var workItem = workspace.CreateWorkItem(request.Title);
await _workDbContext.SaveChangesAsync(ct);
return Result.Success(workItem.Id);
}
Every command handler's Handle returns Task<Result> or Task<Result<T>>: under Wolverine, a value a
published handler returns is cascaded as a new message, and a Result never is. Query handlers return
Task<TResponse>, because they are only ever invoked: usually a DTO that is null when nothing matched, and
a Result<TDto> only when the query can fail for another reason.
Services and sagas
Below and above the handlers sit two more tiers, and dependencies flow one way through them:
saga → handlers → service → DbContext / domain
- A service (a class implementing
IScopedServiceorITransientService, which also registers it) does one job against the database and the domain. It may take framework primitives —IDateTimeProvider,ICurrentUser,IUserService— but never another service and neverIDispatcher, so the graph below a handler stays a tree. - A saga (
ISaga) coordinates several handlers to complete one piece of work, so unlike a service it may holdIDispatcher. The sync runners (IWorkSyncRunner,IPeopleSyncRunner) andIAzureDevOpsInitManagerare sagas. - A handler never injects another handler; it dispatches through
IDispatcherinstead.
ServiceLayeringTests enforces the service rules, with an exact list of the violations that predate it.
Identity vs Principal
The current caller is split into two abstractions: ICurrentUser is pure identity — who is calling (user id, name, email, employee link, authenticated) — and ICurrentPrincipal answers what the caller may do (HasPermission / HasAnyPermission), deriving the permission set from the identity plus the permission store and caching it per request/message scope. Inject the one that matches the question being asked; don't add permission or claim APIs to ICurrentUser.
ICurrentUser.Kind classifies the caller as one of three ActorKinds: User (an authenticated request, or a background scope acting on a specific user's behalf), Anonymous (a live HTTP request with no authenticated user — denied all permissions), or System (no HTTP request and no acting user: scheduled jobs, durable message delivery, startup work — granted all permissions). System scopes are audited with the well-known SystemIdentity.UserId instead of an empty value, so platform-initiated writes are always attributable.
Only real user ids are propagated across execution boundaries (job parameters, message envelope headers); a scope with neither an HTTP context nor a propagated user id resolves to System by definition — the "nobody = system" rule lives in CurrentUser.Kind and nowhere else. This rests on one assumption worth knowing: background work never originates from anonymous callers. Anonymity doesn't survive a detach — once execution leaves the HTTP context with no user id, an anonymous origin is indistinguishable from a platform origin, so work enqueued by an anonymous endpoint would run as System (grant-all). Today no anonymous endpoint enqueues work; if one ever must, propagate an explicit anonymous marker so Kind can resolve it correctly across the boundary.
Permission-based vs membership-based authorization
Most of the application authorizes on the permission claim alone: a [MustHavePermission] attribute on the controller decides whether the caller may reach the endpoint, and the handler does the work.
PPM is the exception. Changing a project, program, or portfolio additionally requires that the caller leads the record — Owner or Manager on it or on an ancestor. That question can only be answered against the record itself, so it is enforced in the domain, not at the controller:
- The command record implements
IRequireLinkedEmployee— a managed PPM action is always attributable to an employee. - The handler resolves a
PpmActorviaResolvePpmActor(ICurrentUser, CancellationToken)onICurrentPrincipal, pairing the employee id with whether they hold the domain-wide administrator grant, plus the acting user id. It takesICurrentUseralongside the principal rather than reading the user id from it, because the two interfaces are a deliberate identity/authorization split —ICurrentPrincipalanswers only what the caller may do. The user id is carried because records that freeze attribution at write time (such as project status history) need both: the user-to-employee link is mutable, so an action attributed only by employee cannot later be traced to the account that performed it. - The handler's query must load the ancestor roles —
Portfolio.Roles, andProgram.Roleswhen assigned — and pass them in asProjectAncestryRoles. - The aggregate method checks
CanManageProject/CanManageProgram/CanManagePortfoliobefore any state change.
Every mutating method on those three aggregates takes a PpmActor; there is no ungated public overload, so the compiler will not let a new handler forget. Paths with no human actor (importers, creation) pass PpmActor.System — grep for it to audit every membership bypass in the codebase.
.Include fails silentlyAncestryRoles() reads project.Portfolio?.Roles ?? []. If the handler's query omits the ancestor includes, the navigation is null, ancestry comes back empty, and a legitimately authorized portfolio Owner is denied — with no error to trace. Unit tests using in-memory fakes cannot catch this, because .Include is a no-op there and the test populates the navigation directly.
The read side mirrors the same rule as canManageProject / canManageProgram / canManagePortfolio DTO fields so the UI can hide actions the server would reject. Those projections and the aggregate predicates must agree — if they drift, the UI either hides valid actions or offers ones that fail on submit.
ProjectDetailsDto.BackwardStatusTargets follows the same principle for reverting a project's status: rather than the UI re-deriving which earlier statuses are reachable, the server answers from ProjectStatusLifecycle — the same table and entry requirements Project.RevertStatus enforces. Adding a status requirement in one place without the other is what makes the dropdown offer a transition that then fails.
GetProjectQuery builds its DTO with ProjectToType, so every mapping clause must translate to SQL. BackwardStatusTargets cannot — building it calls a static factory inside a Select over a collection — so the mapping .Ignores it and ResolveBackwardStatusTargets() fills it after materialisation. A clause that fails to translate throws only under a real provider; in-memory fakes run the projection as LINQ-to-Objects and evaluate anything, so changes to this mapping need a test in Wayd.ProjectPortfolioManagement.IntegrationTests.
Sprints are the other membership-gated area. Starting, completing and reopening a sprint needs Permissions.Iterations.Update and membership of the sprint's team or its direct team of teams, which Permissions.Iterations.Administer waives. Membership lives in Organization, so the Work handlers answer it through IsTeamMemberQuery (SprintAuthorization.CanManageTeamSprints) rather than in the aggregate; the aggregate enforces only the lifecycle rules, through TeamSprintTimeline. SprintDetailsDto exposes both — CanManageSprint and CanStart/CanComplete/CanReopen — computed by the same code the commands run.
See Permissions for the user-facing rule.
No Repository Pattern
Application handlers use EF Core DbContext directly for data access. There is no repository abstraction layer.
Vertical Slices
Each service follows consistent Domain/Application layering:
Wayd.Services/Wayd.\{ServiceName\}/
src/
Wayd.\{ServiceName\}.Domain/ # Entities, value objects, interfaces
Wayd.\{ServiceName\}.Application/ # Commands, queries, DTOs, validators
tests/
Wayd.\{ServiceName\}.Domain.Tests/
Wayd.\{ServiceName\}.Application.Tests/
Wayd.\{ServiceName\}.IntegrationTests/ # where the service has Testcontainers tests
Wayd.Links is the one service without the split: it is a single Wayd.Links project.
Feature Folders
Application layer is organized by aggregate root/feature:
Wayd.Work.Application/
WorkItems/
Commands/
CreateWorkItemCommand.cs
CreateWorkItemCommandValidator.cs
Queries/
GetWorkItemQuery.cs
Dtos/
WorkItemDto.cs
Cross-domain replication
Domains do not share entity rows. Each domain keeps its own local projection of concepts owned by another domain, kept aligned two ways:
- Event replication (copy). The owning domain raises a domain event; consuming domains have
public handlers with a
Handle(TEvent, CancellationToken)method that create a local projection carrying the same Id. For example,Organization.Teamis the source of truth;PpmTeam,PlanningTeam, andWorkTeamare read-model copies written by handlers forTeamCreated/DetailsUpdated/Activated/Deactivated. Delivery is durable, so events arrive late, twice and out of order: each copy keeps a watermark per group of fields and skips a change older than one it already holds, and a missing copy is built from the owner's current state. See Consuming an event. - Shared table.
Employeelives inWayd.Common.Domain— one row, referenced by Guid everywhere. No replication needed.
Replication edges
| Source domain | Aggregate | Event(s) | Consumers (projection written) |
|---|---|---|---|
| Organization | Team / TeamOfTeams | TeamCreated/DetailsUpdated/Activated/Deactivated | PPM PpmTeam, Planning PlanningTeam, Work WorkTeam |
| PPM | Project | ProjectCreated/DetailsUpdated/KeyChangedV2/Deleted | Work WorkProject |
| StrategicManagement | StrategicTheme (the source) | StrategicThemeCreated/DetailsUpdated/Activated/Archived/Deleted | PPM PpmStrategicThemes |
| Work | Iteration (sprints) | IterationCreatedV3/DetailsUpdated/DateRangeChangedV2/TeamChanged/Deleted | Planning PlanningSprint |
| Common | Employee | — (shared table) | referenced by Guid everywhere |
StrategicTheme is owned by StrategicManagement; PPM only holds a copy — author themes in
StrategicManagement.
The load-bearing consequence
Because these projections are populated by events, code that creates a replicated concept must go
through the owning domain's command so its SaveChanges dispatches the events — never insert a projection
row directly, and never bulk-insert source rows with a raw AddRange that skips the domain path (the
data generation tool goes through the import endpoints for this reason).
The copies are eventually consistent. These events are delivered durably and asynchronously (see
DurableEventRoutes), after the command that raised them has returned, so a copy can be missing for a
moment after its source was created. Anything that references a copy by foreign key must check it exists
and fail cleanly rather than let the save fault: PlanningInterval.ManageTeams(teamIds) adds
PlanningIntervalTeam { TeamId } with no existence check of its own, so both of its callers —
ManagePlanningIntervalTeamsCommand and PlanningIntervalImportDefinition — verify every requested
PlanningTeam first and report that the team "may still be syncing" instead of an FK violation. A new caller
of ManageTeams, or any new FK to a copy, needs the same guard.
Reading another domain at query time
A copy is worth keeping when a domain writes against the concept or reads it on every request. A report
that only needs another domain's data now and then asks the owner instead, through a request in
Common.Application/Requests that the owning module handles. The team work distribution report
(GetTeamWorkDistributionQuery, Work) does this twice:
GetTeamStructureQuery(Organization) returns a team and every team beneath it over a date range as dated membership edges plus each team's sizing periods, not one resolved tree — a team can change parents inside the window, and work belongs to the parent it had on the day it was done.GetProjectClassificationsQuery(PPM) returns each project's portfolio, program and effective themes (the project's own, or its program's when it has none).
Keep the calculation out of the handler: WorkDistributionCalculator is a pure function over those results and
the queried work items, so its rules are unit tested without a context or a dispatcher.
The sprint burn-down's ideal line does the same for capacity. Work owns the sprint, its working week and its
team days off; Organization owns allocations, holiday calendars and time away. SprintScopeHistoryLoader
sends GetTeamDayAvailabilityQuery and gets back, for each day, how much of the team's allocated delivery
time is there, then applies its own two facts on top. The feature flag is checked once, in the Organization
handler, which answers null when it is off — so with the flag off nothing outside Organization can read an
allocation, and Work draws the line as it did before allocations existed.
A sprint's capacity against its scope works the same way. GetSprintCapacityQuery sends one
GetTeamsCapacityByPeriodQuery for the sprint and the prior sprints its velocity comes from, each period
carrying that sprint's team days off, and gets back the delivery person-days of each. A team day off
(TeamDayOff, in Common) is a date and the percent of the day the team is off; Organization folds it into each
TeamDay as the share worked, so a half day halves everyone's time there exactly as the ideal line halves
the day's weight. Organization answers any
number of teams and periods in a fixed number of reads, and null with the flag off; Work keeps the comparison
with scope (SprintCapacityComparison) on its side, as a pure function.
A planning interval's capacity crosses three modules. Planning owns the interval, its teams and iterations;
GetPlanningIntervalCapacityQuery asks Work for every team's sprint days off in the interval
(GetTeamDaysOffQuery, one read) and Organization for every team over every iteration in one
GetTeamsCapacityByPeriodQuery, passing those days off per team. Neither read grows with the number of teams or
iterations.
Domain events and the activity log
Every domain event drained by SaveChanges is captured in the activity log, and events follow one set of
conventions for naming, payloads, versioning and consumption in every module. See
Domain Events.
Connector framework
Customer-configured integrations (Azure DevOps, Entra, Workday, Azure OpenAI, future Jira / GitHub connectors) plug into a shared framework in the Wayd.AppIntegration service, the Wayd.Integrations.* projects, and a frontend registry pair. The goal is that adding a connector is additive — drop in new files, register them, no edits to the generic plumbing.
Every connector declares its ConnectorCapability values (WorkItems, People, AiProvider) — a multi-surface connector can support several through one connection. The capability — not the specific connector type — determines which background runner picks the connection up and which Sync History view the detail page shows. Each capability carries a display category via Display(GroupName) that groups the connector picker UI.
Backend shape
| Concern | Lives in | Pattern |
|---|---|---|
| Aggregate | Wayd.AppIntegration.Domain/Models/ | Connection<TConfiguration> base; subclass per connector. Any connector that participates in a sync runner (work-sync or people-sync) implements ISyncableConnection to expose SystemId (nullable — return null if not applicable) and CanSync (IsActive && IsValidConfiguration && HasActiveIntegrationObjects). Both runners filter on CanSync. AI-provider connectors don't implement the interface — they aren't sync-runner-driven. |
| Configuration | Wayd.AppIntegration.Domain/Models/.../*ConnectionConfiguration.cs | Plain POCO stored as JSON. Mark credential fields with [Encrypted] (from Wayd.Common.Domain.DataProtection) so they're encrypted at rest by EncryptingJsonValueConverter. |
| Capability declaration | Wayd.Common.Domain.Enums.AppIntegrations/ConnectorExtensions.cs | Add an arm to Connector.GetCapabilities() declaring the capabilities the connector supports (a multi-surface connector declares several). The runners and detail-page actions dispatch by capability; each ConnectorCapability member's Display(GroupName) is the display category that groups the picker UI. |
| Commands / Queries | Wayd.AppIntegration.Application/Connections/Commands/\{Connector\}/, .../Queries/ | One folder per connector. CQRS via Wolverine, validators via FluentValidation. Activate / Deactivate are connector-agnostic (ActivateConnectionCommand / DeactivateConnectionCommand) — don't re-implement them per connector. |
| Polymorphic DTO | Wayd.AppIntegration.Application/Connections/Dtos/ConnectionDetailsDto.cs | Add a [JsonDerivedType] entry; controllers switch on the request/DTO type to dispatch to the right handler. |
| Secret masking | Wayd.AppIntegration.Application/Connections/Dtos/.../*ConfigurationDto.cs | Implement a Mask* method that preserves the first 4 characters and the original length (matching MaskPersonalAccessToken / MaskClientSecret / MaskApiKey). The shared update-command pattern compares first-4-chars + length to detect "user posted back the masked value unchanged" and preserve the stored secret. |
| External client | Wayd.Integrations.\{SystemName\}/ | Only if the connector actually talks to a remote API. Pure AI providers can wrap an existing SDK directly inside the application layer. |
| Persistence | Wayd.Infrastructure/Persistence/Configuration/AppIntegrationConfiguration.cs | TPH discriminator; one entity-config per concrete connection class. Use HasEncryptedJsonConversion() for the configuration column. |
| Connector module | Wayd.Infrastructure/ConnectorModules/\{Connector\}ConnectorModule.cs | One IConnectorModule per connector: declares its capabilities and registers all of its DI wiring (keyed sources, descriptor builders, init probes, HTTP clients) in one place. Discovered by assembly scan (AddConnectorModules()) — no edits to shared registration code. Architecture tests enforce one module per Connector value, capability agreement with GetCapabilities(), and a keyed source per declared sync capability. |
| Sync orchestration — work | Wayd.Integrations.Abstractions/IWorkItemSource.cs + Wayd.AppIntegration.Application/Connections/Managers/\{Connector\}WorkItemSource.cs | Implement IWorkItemSource for the connector and register it (keyed by Connector enum value) in the connector's module. The generic WorkSyncRunner calls every registered source on a single Hangfire schedule — no per-connector job. |
| Sync orchestration — people | Wayd.Integrations.Abstractions/IEmployeeSource.cs + Wayd.AppIntegration.Application/Connections/Managers/\{Connector\}EmployeeSource.cs + a descriptor builder | Implement IEmployeeSource for the connector (an adapter wrapping the low-level per-system service) plus an ISyncableConnectionDescriptorBuilder, and register both in the connector's module. The generic PeopleSyncRunner resolves sources via EmployeeSourceFactory — no runner edits. |
| Init probe (optional) | Wayd.Integrations.Abstractions/IConnectionInitProbe.cs | Connectors whose configuration warrants an upstream validation probe implement IConnectionInitProbe and register it keyed in their module. The connections controller's init endpoint resolves by key — a connector "supports an init probe" exactly when one is registered (Workday today). |
Sync orchestration
Sync-shaped connectors plug into category-specific runners. Both runners follow the same shape: load active connections for their category, loop, write a SyncRun row per connection per run.
Work sync
Work-sync connectors (AzDO today, Jira/GitHub in future) plug into a single generic runner. The pieces:
IWorkItemSource(Wayd.Integrations.Abstractions) — connector-neutral contract:TestConnection,GetSystemId,RefreshOrganizationConfiguration,GetSyncPlan(returns a flat list ofWorkspaceSyncTarget),PrepareWorkspaceForItemSync,SyncIterations,SyncWorkItems,GetWorkItemHistory. Sources hide connector-specific hierarchy internally — AzDO'sWorkProcess → Workspacewalk happens insideGetSyncPlan, the runner only sees workspaces.IWorkItemSourceFactory— resolves the right source per connection via keyed DI. Sources hold credentials internally afterBind(descriptor)so per-call signatures stay credential-free.WorkSyncRunner(Wayd.AppIntegration.Application/Connections/Managers/WorkSyncRunner.cs) — loops all active syncable connections, resolves each source via the factory, walks the flat plan, runsProcessDependenciesCommand, and writes aSyncRunrow per connection per run (AppIntegrations.SyncRuns). Per-connection failures don't kill the run; per-workspace failures don't kill the connection.- Hangfire entry point:
JobManager.RunWorkSync(SyncType, SyncTriggerSource, Guid? connectionId, CancellationToken)— one job for all work-sync connectors.connectionId = nullruns every active syncable connection (manual viaBackgroundJobType.WorkFullSync/WorkDiffSync, or scheduled). Passing aconnectionIdruns that connection only (manual "Sync now" from the connection detail page).[DisableConcurrentExecution]is keyed on the method, so all variants serialize against each other.
Work item history
Sprint metrics ask what was true about a work item at an instant, so Wayd keeps each item's history as effective-dated periods in Work.WorkItemStateHistory: one row per stretch of time during which none of the tracked fields (iteration, status, work type, team, assignee, Story Points, Effort, Size) changed. An item's state at T is the one row where ValidFrom <= T and ValidTo is null or after T.
- The contract.
GetWorkItemHistory(target, watermark)returns aWorkItemHistoryBatch: revisions asIExternalWorkItemRevision(the tracked values after each revision, not what changed), the next watermark as an opaque string, and whether it is the last batch. A null watermark starts from the workspace's first revision.GetAllRevisions(target, workItemIds)returns every revision of the given items wherever they were made. A connector that can't supply history returnsWorkItemHistoryBatch.Empty(watermark)and no revisions. - The runner pulls history after
SyncWorkItems, a batch at a time, from the workspace's stored watermark (Workspace.WorkItemHistoryWatermark). Each batch is applied bySyncExternalWorkItemHistoryCommand, which saves the batch's watermark in the same save. A full sync replays from the start onto the stored history and deletes nothing: revisions already stored are skipped, so it fills gaps and changes nothing it already holds. A history failure leaves the workspace succeeded but degraded, and a batch that is not the last must advance the watermark or the history sync fails rather than ask forever. - Every revision is kept. Each revision read is stored in
Work.WorkItemSourceRevisions, source values only, and periods are built from that log. A workspace's revisions cover only what was made in it, so an item that moved between projects has revisions in several streams, which arrive in no fixed order. A revision after every stored one extends the item's periods; one that lands earlier rebuilds the item's periods from the log, since a period cannot say where inside it a late revision belongs. - Gaps are filled from the item. Sources number an item's revisions without gaps, so once every workspace of the connection has read its stream, the runner asks
GetWorkItemsMissingRevisionsQueryfor items whose stored numbers skip one (revisions made in a project Wayd doesn't sync, or while the item was a work type the stream filters out) and fetches them withGetAllRevisions, without moving the watermark. Waiting for every stream first means a move between two synced projects is read from both streams, and each revision keeps the workspace it was made in. Each item is a call of its own, so a sync fills at most 500 and the next continues.WorkItemRevisionFillsrecords the highest revision an item held when it was last filled, so an item whose gap the source won't close (it denies access, or returns a revision Wayd can't store) isn't fetched again until a newer revision arrives. - Collapsing.
WorkItemStateTimelinemerges revisions that change no tracked field into the open period, and skips a revision at or below the last one applied, so a batch delivered twice changes nothing. - Only source values are stored, by stable id. The status and work type names and the source's iteration id sit beside what Wayd resolved them to, because an old revision can name something Wayd no longer holds. Nothing is resolved through a connection mapping, which isn't effective-dated: Azure DevOps derives a work item's team from its iteration, so team over time is read through the iteration; a source that records team on the item supplies a raw
TeamKey. The assignee is the exception — an identity mapping corrects who a person is rather than reorganizing anything — so it is resolved at ingest and repointed byRepointWorkItemAttributionCommandlike the work item's. - History is deleted with its work item. A revision is matched to its item across every workspace of the source system, so an item that moved keeps the revisions it made before the move; revisions of an item Wayd doesn't hold anywhere are skipped. Revisions and periods are imported facts and raise no domain events.
One-time initialization flows (e.g. AzDO's InitWorkProcessIntegration / InitWorkspaceIntegration) stay connector-specific in the existing init manager — they're user-triggered setup wizards with connector-specific payloads, not part of the generic runner.
People sync
People-sync connectors (Entra and Workday today, BambooHR/Okta in future) follow the same shape as work sync but simpler — there's no per-target sync plan, every active connection just fetches its employees and feeds them into the existing connector-agnostic BulkUpsertEmployeesCommand. The pieces:
IEmployeeSource(Wayd.Integrations.Abstractions) — connector-neutral contract:Bind(descriptor), thenGetEmployees(Instant? since, ct)returning employees plus a neutral exclusion breakdown. Behavior modifiers live on the port:SupportsIncremental(can the source return deltas) andMatchBy(which employee property the upsert prefers to key on, read from the bound configuration — the upsert falls back to the other candidate key on a miss). Implementations are adapters inWayd.AppIntegration.Application/Connections/Managers/(EntraEmployeeSource,WorkdayEmployeeSource) that wrap the low-level per-system services (IEntraEmployeeSource,IWorkdayEmployeeSource) — the same layering asAzureDevOpsWorkItemSourcewrappingIAzureDevOpsService. Sources build their own per-connection client at call time — there is no shared app-wide client, so multiple connections of the same type (e.g. two Entra tenants) work without conflict.IEmployeeSourceFactory— resolves the right source per connection via keyed DI and callsBind, mirroringIWorkItemSourceFactory. Descriptor builders (ISyncableConnectionDescriptorBuilder, shared with work sync) load the typed entity and box its configuration.PeopleSyncRunner(Wayd.AppIntegration.Application/Connections/Managers/PeopleSyncRunner.cs) — loops all active connections with the People capability, resolves each source via the factory (the runner never names a concrete connector), sendsBulkUpsertEmployeesCommand, then callsIUserService.UpdateMissingEmployeeIdsandSyncUsersFromEmployeeRecords. Writes aSyncRunrow per connection per run; per-connection details (employeesFetched,employeesUpserted,employeesExcluded,errors[]) are serialized toSyncRun.DetailsJson.- Hangfire entry point:
JobManager.RunPeopleSync(SyncType, SyncTriggerSource, Guid? connectionId, CancellationToken).connectionId = nullruns every active people-sync connection (manual viaBackgroundJobType.PeopleFullSync/PeopleDiffSyncor scheduled). Passing aconnectionIdruns that connection only (manual "Sync Now" from the connection detail page).
Differential people sync is source-dependent: when the source declares SupportsIncremental (Workday today), a differential run passes the last successful run's timestamp as the watermark and skips the deactivation pass (missing from a delta means "no changes", not "no longer exists"). Sources without incremental support (Entra) transparently degrade every run to a full snapshot, and BulkUpsertEmployeesCommand is idempotent.
Why no shared IConnectorSource<TItem>
Work-sync and people-sync runners look superficially similar but their per-target shapes diverge: work sync walks a workspace plan with per-workspace metrics; people sync is a single fetch-then-upsert per connection. Forcing both behind one generic interface would create a TConnectionState / TItem / TSyncTarget mess that hurts readability without removing duplication (the duplication is ~30 lines of SyncRun bookkeeping, which is cheap). Keep the two runners separate; share the SyncRun entity and the category-routed controller actions.
Frontend shape
The connector UI is registry-driven on both the create and detail surfaces:
| Concern | File | What to add |
|---|---|---|
| Create form section | Wayd.Web/src/wayd.web.reactclient/src/app/settings/connections/_components/connector-registry.ts | Map the ConnectorType enum value to your configuration form component. |
| Connector type metadata | Wayd.Web/src/wayd.web.reactclient/src/types/connectors.ts | Add to the ConnectorType enum + CONNECTOR_NAMES / CONNECTOR_DESCRIPTIONS maps. |
| Detail page | Wayd.Web/src/wayd.web.reactclient/src/app/settings/connections/[id]/_components/detail-registry.tsx | Register a DetailEntry for the new connector. Provide a Details component (or use GenericConnectionDetails as a fallback). Optional: extraTabs, ExtraActions (connector-specific menu items), Wrapper (if your detail UI needs a context provider), EditForm (if the shared edit form doesn't fit). |
| Per-connector subfolder | Wayd.Web/src/wayd.web.reactclient/src/app/settings/connections/[id]/\{connector\}/ | All connector-specific components live here. Export the registry entry from index.tsx. |
The generic edit / delete / list flows handle every connector automatically. Only build connector-specific UI when the connector genuinely needs it (e.g. Azure DevOps has its workspace/team mapping flow; AI providers don't).
Why a registry instead of a plugin system
There are few connectors (four today — Azure DevOps, Azure OpenAI, Entra and Workday — plausibly 10-20 long-term) and the team owns them all. A static registry catches misregistration at compile time, keeps the bundle predictable, and is trivial to refactor. If Wayd ever opens connectors to third-party authors, revisit then.