These conventions ensure code from parallel agents merges cleanly. All agents (Claude Code, Cursor, GitHub Copilot) follow these rules.
- Architecture index:
docs/architecture/README.md— canonical entry point for platform architecture documents. - Decision records:
docs/decisions/README.md— the "why" behind major design choices. - Namespace root:
Cvoya.Spring.* - Target framework: .NET 10
- Runtime: Dapr sidecar pattern
Required on normal C# source files. EF-generated *.Designer.cs and *ModelSnapshot.cs files keep their generator marker comments and are exempt:
// Copyright CVOYA LLC. Licensed under the Business Source License 1.1.
// See LICENSE.md in the project root for full license terms..editorconfig is the source of truth for repository whitespace and formatting gates across .NET, TypeScript/JavaScript, Python, and config files.
- All committed text files use UTF-8, LF line endings, spaces, trimmed trailing whitespace, and a final newline.
- C# uses 4-space indentation. TypeScript/JavaScript, JSON, XML, and project files use 2-space indentation. Python uses 4-space indentation and a 120-character line length, mirrored in Ruff.
- C# formatter rules enforce file-scoped namespaces, no top-level statements, and
usingdirectives after the namespace declaration. - Python formatting uses Ruff with space indentation and LF line endings. TypeScript/JavaScript formatting is governed by
.editorconfigplus the root ESLint/Next lint configuration.
File-scoped namespaces. The first C# code declaration is always the namespace declaration; using statements follow it. For normal source files, the namespace declaration appears immediately after the copyright header:
// Copyright CVOYA LLC. Licensed under the Business Source License 1.1.
// See LICENSE.md in the project root for full license terms.
namespace Cvoya.Spring.Core.Messaging;
using System;
using Microsoft.Extensions.Logging;- File-scoped namespaces (no braces).
- Namespace immediately after the copyright header.
usingstatements after the namespace declaration.- EF-generated
*.Designer.csand*ModelSnapshot.csfiles may keep their generator marker comments before the namespace and are exempt from the copyright-header rule.
- Namespace matches folder path:
Cvoya.Spring.Core.Messaginglives insrc/Cvoya.Spring.Core/Messaging/. - One public type per file. File name matches type name (
AgentActor.cs,IMessageReceiver.cs). - Internal/private helper types may share a file with the public type they support.
Cvoya.Spring.Corehas zero external NuGet package references — domain abstractions only.
| Element | Convention | Example |
|---|---|---|
| Files | PascalCase, match type name | AgentActor.cs, IMessageReceiver.cs |
| Interfaces | I-prefixed |
IAddressable, IAgentRuntimeLauncher |
| Abstract classes | No prefix | ActorBase, ConnectorBase |
| Records (immutable data) | PascalCase noun | Message, Address, ActivityEvent |
| Enums | PascalCase, singular | MessageType, ExecutionMode |
| Test classes | {Class}Tests |
AgentActorTests, MessageRouterTests |
| Test methods | MethodName_Scenario_ExpectedResult |
ReceiveAsync_CancelMessage_CancelsActiveWork |
| Constants | PascalCase | StateKeys.ActiveConversation |
| Private fields | _camelCase |
_stateManager, _logger |
Every actor (unit, agent, human, connector, tenant) has exactly one stable identifier: a Guid. There is no parallel string identifier (no slug, no scoped handle, no namespaced name). display_name is presentation-only — never unique, never addressable, never a foreign-key target. See docs/architecture/data-and-identity.md and ADR-0036 for the durable decision.
- Type. Repository signatures, DTO ids, route parameters, and method parameters that take an actor identifier are typed
Guid. Neverstring. - Wire form on URLs, address strings, manifest references, CLI output, log lines. 32-char lowercase no-dash hex (
Guid.ToString("N")). One helper:Cvoya.Spring.Core.Identifiers.GuidFormatter.Format. - Wire form in JSON DTO bodies. Standard dashed
8-4-4-4-12. Kiota'sGetGuidValue()and STJ's defaultUtf8JsonReader.GetGuid()accept the dashed form natively; the OSS host registersCvoya.Spring.Host.Api.Serialization.NoDashGuidJsonConverterso the no-dash form deserialises too. - Parse is lenient on every surface.
GuidFormatter.TryParse,Address.TryParse, ASP.NET Core's{id:guid}route binder, theNoDashGuidJsonConverter, and the CLI'sCliResolver.TryParseGuidall accept both no-dash and dashed forms (and any other shapeGuid.TryParserecognises). Emit asymmetry — emit one form per surface, parse many — keeps copy-paste workflows working. Addressshape.Addressis a record withScheme(string) andId(Guid). The canonical render isscheme:<32-hex-no-dash>(e.g.agent:8c5fab2a8e7e4b9c92f1d8a3b4c5d6e7). There is no path form, no@<uuid>form. Use the scheme constants onAddress(AgentScheme,UnitScheme,HumanScheme).display_name. Validated byCvoya.Spring.Core.Validation.DisplayNameValidatoron every write surface; values that round-trip throughGuid.TryParseExactfor any standard form are rejected with structuredcode = display_name_is_guid_shape. CLI verbs acceptdisplay_nameonly as search input (spring agent show <id-or-name>short-circuits to a direct lookup when the argument parses as a Guid; otherwise it runs a name search returning 0/1/n).
public class SpringException : Exception
{
public SpringException(string message) : base(message) { }
public SpringException(string message, Exception inner) : base(message, inner) { }
}
public class EntityNotFoundException : SpringException { ... }
public class PermissionDeniedException : SpringException { ... }
public class InvalidAddressException : SpringException { ... }Result type for expected failures (e.g., message routing):
public readonly record struct Result<TValue, TError>
{
public TValue? Value { get; }
public TError? Error { get; }
public bool IsSuccess { get; }
public static Result<TValue, TError> Success(TValue value) => ...;
public static Result<TValue, TError> Failure(TError error) => ...;
}Rules:
- Actor methods must NEVER let exceptions escape the actor turn. Catch, log, update state, return error response.
- Use
Result<T, TError>for operations that fail in expected ways (routing, resolution). - Use exceptions for unexpected/infrastructure failures (DB down, serialisation error).
- Always log exceptions with structured data before swallowing.
Each project provides an extension method:
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCvoyaSpringCore(this IServiceCollection services) { ... }
public static IServiceCollection AddCvoyaSpringDapr(this IServiceCollection services) { ... }
}Rules:
-
Constructor injection only. No service locator, no
IServiceProviderinjection. -
Prefer primary constructors:
public class MessageRouter( IDirectoryService directory, ILogger<MessageRouter> logger) : IMessageRouter { public async Task RouteAsync(Message message, CancellationToken ct) { logger.LogInformation("Routing message {MessageId}", message.Id); // ... } }
-
Keyed services for strategy patterns:
services.TryAddKeyedSingleton<ICognitionProvider, Tier1CognitionProvider>("tier1"); services.TryAddKeyedSingleton<ICognitionProvider, Tier2CognitionProvider>("tier2");
-
Use
TryAdd*for all service registrations so downstream consumers can override implementations by registering their own before callingAddCvoyaSpring*():public static IServiceCollection AddCvoyaSpringDapr(this IServiceCollection services) { services.TryAddSingleton<IMessageRouter, MessageRouter>(); services.TryAddScoped<IDirectoryService, DaprDirectoryService>(); return services; }
-
Registration in
Program.cs:builder.Services .AddCvoyaSpringCore() .AddCvoyaSpringDapr() .AddCvoyaSpringConnectorGitHub();
State keys — centralised constants prevent typos across parallel work:
public static class StateKeys
{
// AgentActor state (runtime ephemeral only — see ADR-0040)
public const string ActiveConversation = "Agent:ActiveConversation";
public const string PendingConversations = "Agent:PendingConversations";
public const string ObservationChannel = "Agent:ObservationChannel";
public const string InitiativeState = "Agent:InitiativeState";
// UnitActor state (runtime ephemeral only — see ADR-0040)
public const string DirectoryCache = "Unit:DirectoryCache";
public const string UnitStatus = "Unit:Status";
// Configuration / authorization / membership data lives in EF
// (unit_memberships, unit_subunit_memberships, unit_live_config,
// unit_human_permissions, agent_live_config, ...). See ADR-0040 for
// the canonical state-ownership matrix.
}Pub/sub topic naming: {tenant-id}/{owner-id}/{topic}
- Both
{tenant-id}and{owner-id}are 32-char no-dash hex Guids. The owner is the unit (or other addressable) that anchors the topic; the canonical wire form is whatGuidFormatter.Formatemits. - Example:
dd55c4ea8d725e43a9df88d07af02b69/8c5fab2a8e7e4b9c92f1d8a3b4c5d6e7/pr-reviews - System topics use the literal
system/prefix:system/directory-changed,system/activity.
All Dapr interactions go through Cvoya.Spring.Core abstractions, implemented in Cvoya.Spring.Dapr. No direct Dapr SDK calls from actors — actors use injected interfaces.
Stack: xUnit + FluentAssertions + NSubstitute.
public abstract class ActorTestBase<TActor> where TActor : class
{
protected readonly IActorStateManager StateManager = Substitute.For<IActorStateManager>();
protected readonly ILogger<TActor> Logger = Substitute.For<ILogger<TActor>>();
protected Message CreateMessage(
MessageType type = MessageType.Domain,
string? threadId = null,
JsonElement? payload = null)
{
return new Message
{
Id = Guid.NewGuid(),
From = new Address(Address.AgentScheme, Guid.NewGuid()),
To = new Address(Address.AgentScheme, Guid.NewGuid()),
Type = type,
ThreadId = threadId ?? Guid.NewGuid().ToString(),
Payload = payload ?? default,
Timestamp = DateTimeOffset.UtcNow
};
}
}Test naming: MethodName_Scenario_ExpectedResult.
public class AgentActorTests : ActorTestBase<AgentActor>
{
[Fact]
public async Task ReceiveAsync_DomainMessageNewConversation_CreatesConversationChannel() { ... }
[Fact]
public async Task ReceiveAsync_CancelMessage_CancelsActiveWork() { ... }
}Integration tests: Testcontainers for PostgreSQL. Dapr test mode for actor tests.
Rules:
- Every public method has at least one test.
- Test the behaviour, not the implementation.
- Use
ITestOutputHelperfor diagnostic output. - No
Thread.Sleep— useTask.Delayor test synchronisation primitives.
Asyncsuffix on all async methods:ReceiveAsync,ResolveAddressAsync.CancellationTokenas the last parameter on all public async methods.- Never block on async: no
.Result, no.Wait(), no.GetAwaiter().GetResult(). - Use
ValueTaskfor hot paths that often complete synchronously.
System.Text.Json only. No Newtonsoft.Json anywhere.
[JsonSerializable(typeof(Message))]
[JsonSerializable(typeof(Address))]
[JsonSerializable(typeof(ActivityEvent))]
internal partial class SpringCoreJsonContext : JsonSerializerContext { }Rules:
- All serialisable types are records or have parameterless constructors.
- Use
[JsonPropertyName("camelCase")]for external APIs. - Internal serialisation (Dapr state) uses PascalCase (default).
JsonElementfor untyped payloads — notobjectordynamic.- Enums that cross actor-remoting or HTTP MUST serialize by name. Register
JsonStringEnumConverter(allowIntegerValues: false)on anyJsonSerializerOptionsused at those boundaries. Mid-enum insertion is safe once this is enforced — without it, always append. TheallowIntegerValues: falsesetting ensures that a misbehaving caller sending an ordinal receives a deterministic deserialization failure rather than silently landing on an adjacent enum value. SeeActorRemotingJsonOptions(actor-remoting) andProgram.cs(HTTP) for the canonical registrations (#956).
ILogger<T> via constructor injection. Structured logging with event IDs.
Event ID ranges per project:
| Project | Range | Example |
|---|---|---|
| Cvoya.Spring.Core | 1000–1999 | 1001: MessageCreated |
| Cvoya.Spring.Dapr.Actors | 2000–2099 | 2001: ActorActivated |
| Cvoya.Spring.Dapr.Routing | 2100–2199 | 2101: AddressResolved |
| Cvoya.Spring.Dapr.Execution | 2200–2299 | 2201: ExecutionDispatched |
| Cvoya.Spring.Host.Api | 3000–3999 | 3001: RequestReceived |
| Cvoya.Spring.Cli | 4000–4999 | 4001: CommandExecuted |
| Cvoya.Spring.Connector.GitHub | 5000–5999 | 5001: WebhookReceived |
public static partial class LogMessages
{
[LoggerMessage(EventId = 2001, Level = LogLevel.Information, Message = "Actor {ActorType}:{ActorId} activated")]
public static partial void ActorActivated(this ILogger logger, string actorType, string actorId);
}All actors follow the same ReceiveAsync dispatch pattern:
public async Task<Message?> ReceiveAsync(Message message)
{
return message.Type switch
{
MessageType.Cancel => await HandleCancelAsync(message),
MessageType.StatusQuery => HandleStatusQuery(message),
MessageType.HealthCheck => HandleHealthCheck(message),
MessageType.PolicyUpdate => await HandlePolicyUpdateAsync(message),
MessageType.Domain => await HandleDomainMessageAsync(message),
_ => throw new SpringException($"Unknown message type: {message.Type}")
};
}Control messages (Cancel, StatusQuery, HealthCheck, PolicyUpdate) have platform-defined behaviour. Domain messages route to the actor's domain logic (mailbox for agents, strategy for units).
Directory.Build.props (solution-wide):
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>Central package management via Directory.Packages.props — all NuGet versions pinned centrally.
General extensibility rules (TryAdd*, no-seal, visibility, virtual hooks, no tenant assumptions, no statics) live in AGENTS.md § "Open-source platform and extensibility". The conventions below are the tenancy-specific rules that belong with code patterns.
Multi-tenancy (business-data entities): every new business-data entity implements Cvoya.Spring.Core.Tenancy.ITenantScopedEntity with a Guid TenantId column, and its IEntityTypeConfiguration adds the combined tenant + soft-delete query filter — HasQueryFilter(e => e.TenantId == tenantContext.CurrentTenantId && e.DeletedAt == null) — dropping the soft-delete clause only for entities without a DeletedAt column. The DbContext auto-populates TenantId from the injected ITenantContext on insert; write sites do not set it explicitly. The OSS deployment runs functionally single-tenant; every fresh-install row is owned by Cvoya.Spring.Core.Tenancy.OssTenantIds.Default (the deterministic v5 UUID dd55c4ea-8d72-5e43-a9df-88d07af02b69; OssTenantIds.DefaultDashed and OssTenantIds.DefaultNoDash expose the literal string forms for configs, dashboards, and audit-log greps). Never hardcode a string "default" — the tenant id is a Guid. System/ops tables (migrations history, startup config) stay global.
Cross-tenant reads and writes go through ITenantScopeBypass. The EF Core query filter restricts reads and writes to the current tenant. A small set of operations legitimately need to cross that boundary — DatabaseMigrator, platform-wide analytics, system administration. Those call sites wrap the work in ITenantScopeBypass.BeginBypass(reason) so the bypass is auditable (structured log on open and close, with caller context and duration) and so the cloud overlay can swap the default for a permission-checked variant. Never call IgnoreQueryFilters() directly in business code.
Bootstrap seeds via ITenantSeedProvider; implementations must be idempotent and must not overwrite user edits. The default-tenant bootstrap hosted service iterates every DI-registered ITenantSeedProvider in ascending Priority order on host startup, gated by Tenancy:BootstrapDefaultTenant (default true). Implementations upsert by (tenant_id, <natural-key>), log every action at Information, and treat seed values as initial data — operator edits made after the seed always win.
Every user-facing feature ships through BOTH the web portal UI and the spring CLI. Neither surface drifts ahead of the other.
- When planning a feature PR, enumerate the affected surfaces (API endpoints, UI screens, CLI commands). If a surface is missing, either include it in the same PR or file a sibling issue before the PR lands so the gap is tracked.
- "The UI can do X but the CLI can't" (or vice versa) is a real bug, not a speculative nice-to-have.
- Link CLI-side and UI-side issues as siblings when a feature is split across PRs.
- A CLI scenario under
tests/e2e/cli/is a good parity proxy: if the scenario has to fall back tocurlbecause the CLI lacks the command, the CLI is behind.
Exceptions: admin/ops operations that are genuinely dev-only (e.g., dotnet ef migrations add) don't need a UI counterpart. Internal test affordances are also out of scope.
Operator carve-out: operational surfaces (agent-runtime config, connector config, credential health, tenant seeds, skill-bundle bindings) are CLI-only by design. The portal MAY expose read-only views; mutations go through the CLI. See AGENTS.md § "Operator surfaces".
Tenants see only skill bundles bound to them. Discovery stays filesystem-based — FileSystemSkillBundleResolver walks the packages root — but TenantFilteringSkillBundleResolver wraps it and checks the current tenant's ITenantSkillBundleBindingService for an enabled=true row before delegating. Unbound or disabled bundles surface as SkillBundlePackageNotFoundException, indistinguishable from a missing package so callers never leak the existence of bundles they can't use.
- Bootstrap populates default-tenant bindings from the on-disk packages layout (via
FileSystemSkillBundleSeedProvider). The Worker host runs bootstrap at startup; the API host reads the bindings. - A manifest entry like
spring-voyage/software-engineeringlooks up the binding keyed on the package directory namesoftware-engineering. Prefix normalisation lives in both the inner resolver and the decorator — they must not diverge.
Every HttpClient used by an agent runtime or connector that authenticates against a remote service flows through the CredentialHealthWatchdogHandler. Without it, revoked or expired tokens surface only when a unit fails at run-time — the operator sees no accumulating signal.
Wiring pattern (inside a runtime/connector's AddCvoya…() DI extension):
services.AddHttpClient("my-runtime-client")
.AddCredentialHealthWatchdog(
kind: CredentialHealthKind.AgentRuntime,
subjectId: "my-runtime",
secretName: "api-key");subjectIdis the runtimeId(forCredentialHealthKind.AgentRuntime) or connectorSlug(forCredentialHealthKind.Connector).secretNameis the credential key —"api-key"for single-credential subjects; stable per-credential names for multi-part auth.- The handler flips the persistent credential-health row on
401(→Invalid) and403(→Revoked); other status codes pass through unmodified so a flaky upstream does not flap operator-facing status. - Handler writes go through a child DI scope — safe to use from any pipeline, including background hosted services with no ambient request scope.
Every agent runtime (IAgentRuntime) and connector (IConnectorType) is a first-class extension point. The host references the abstraction only; concrete implementations live in their own project and register via DI.
- Agent runtimes live under
src/Cvoya.Spring.AgentRuntimes.<Name>/and referenceCvoya.Spring.Coreonly. Each project ships:- A single
AddCvoyaSpringAgentRuntime<Name>()DI extension, registered withTryAddEnumerable(ServiceDescriptor.Singleton<IAgentRuntime, …>)so a cloud overlay can pre-register a variant without displacing the OSS default. - A
seed.jsonatagent-runtimes/<id>/seed.jsoncarrying the runtime'sDefaultModelscatalogue. - A per-project
README.mddocumenting the runtime's id, tool kind, credential schema, and any host-side baseline tooling.
- A single
- Connectors live under
src/Cvoya.Spring.Connector.<Name>/and referenceCvoya.Spring.Connectors.Abstractions. Each connector exposesAddCvoyaSpringConnector<Name>(IConfiguration configuration)and registers itsIConnectorTypeas a singleton. Connector-specific HTTP routes attach via theMapRoutes(IEndpointRouteBuilder group)contract — the host calls it on a pre-scoped/api/v1/connectors/{slug}group so the connector package stays ignorant of the outer path shape.
Both plugin kinds sit behind a tenant install table (tenant_agent_runtime_installs, tenant_connector_installs) managed by ITenantAgentRuntimeInstallService / ITenantConnectorInstallService. A plugin registered in DI is available to the host; an install row makes it visible to a given tenant. Bootstrap seeds default-tenant installs for every registered plugin; subsequent lifecycle goes through the install service.
Plugins that authenticate via HttpClient MUST wire AddCredentialHealthWatchdog(kind, subjectId, secretName) onto their named client (see § 15). For agent runtimes, credential probing runs inside the unit's chosen container via the UnitValidationWorkflow — runtimes expose GetProbeSteps(config, credential) and the workflow dispatches probes per execution image. For connectors, the accept-time path remains POST /validate-credential → IConnectorType.ValidateCredentialAsync.
- Create
src/Cvoya.Spring.<Kind>.<Name>/with a single DI-extension entry point. ReferenceCvoya.Spring.Core(runtimes) orCvoya.Spring.Connectors.Abstractions(connectors) only. - Implement the contract; wire the credential-health watchdog on any HttpClient that authenticates.
- For runtimes, ship a
seed.json. For connectors, document the typed routes exposed viaMapRoutesin the projectREADME.md. - Register the DI extension from
Program.csin the host. No changes to the dispatcher project are required — the install surface, registry, and bootstrap pick up the new plugin automatically.
docs/concepts/, docs/guide/, docs/architecture/, top-level README.md, and every packages/*/README.md describe the system as it exists in the current codebase. Every "this works" / "this exists" / "this returns" claim corresponds to a verifiable surface — a function, an endpoint, a CLI verb, a YAML key — that a reviewer can grep for.
The existing docs-evergreen-framing CI gate enforces that docs/ never references outdated version labels (V2, V2.1). This convention operates at a higher level: content accuracy, not version tagging.
Planned features, deferred work, and "we will eventually" framing belong in docs/plan/<release>/ (the per-release plan-of-record narrative). When aspirational content must appear in an in-place doc — e.g. a concept doc explaining the long-term shape — it uses a clearly-marked callout:
Planned (v0.2): … or … Not yet implemented: …
The callout names the release or links the tracking issue. Bare "we plan to" prose without the callout is the failure mode this rule catches.
When a PR touches docs/concepts/, docs/guide/, docs/architecture/, top-level README.md, or packages/*/README.md, reviewers must verify:
- Every behavioural claim still matches an identifiable surface in the current codebase.
- Aspirational content uses the Planned callout described above — not bare future-tense prose.