Skip to main content

SECS Roslyn Fork — Implementation Plan

Repository Setup

Fork Structure

secs-workspace/
├── src/ existing — engine runtime
│ ├── SECS.Core/ typed IDs, hashing, shared primitives
│ ├── SECS.Schema/ WorldSchema and schema row types
│ ├── SECS.Kernel/ deterministic semantic kernel
│ ├── SECS.Runtime/ runtime coordinator
│ └── SECS.Compiler.Contracts/ compiler-emitted contract types
├── secs-roslyn/ forked Roslyn submodule
│ └── (forked from dotnet/roslyn, latest)
├── examples/
│ ├── valenar-v2/ reference colony-builder example
│ └── character-trainer-v2/ smaller training-loop example
└── docs/

The VS Code extension and LSP server live inside the secs-roslyn fork itself — Roslyn already contains the Workspaces and LSP infrastructure, so the extension is built from the same codebase rather than being a separate repo.

Git Setup

Fork: Create a private fork of dotnet/roslyn on GitHub. Fork from latest. Create a secs branch — this is the working branch where all SECS modifications go.

Submodule: secs-roslyn/ is the Roslyn submodule in this workspace. Configure it to track the secs branch. Full clone — no shallow, we want the complete history for tracing how upstream implemented features like record.

Branch strategy:

  • secs — working branch with all SECS modifications
  • upstream/main — tracks dotnet/roslyn for periodic merges
  • Upstream merges happen deliberately (quarterly or when needed), not continuously

Target Framework

  • Roslyn fork toolchain: follows secs-roslyn/global.json (currently .NET 10.0.108 after the 2026-05-31 upstream sync)
  • secsc distribution target: RID-scoped tool bundle. The bundle ships secsc, a matched Roslyn compiler driver, target reference assemblies, SECS reference assemblies, and secsc.bundle.json; repository-local secsc.dll invocations must pass --toolchain-root explicitly.
  • Engine libraries: v2 core/runtime/schema/kernel/compiler-contract assemblies target netstandard2.1 where they must remain Unity-compatible; adapters and examples target net8.0.
  • Compiler output: configurable target — the game developer chooses their target framework. The compiler emits code against the v2 compiler/runtime contracts, so output is compatible with any .NET runtime that supports the selected target.

What We're Adding to Roslyn

Current Lowering Baseline

The compiler target is the current v2 design/runtime plus the compiler-emitted Generated/ output under examples/valenar-v2/Generated/obj/**/SecsGenerated (emitted by secsc from Content/; the only retained checked-in .cs under Generated/ is the 21-line ValenarHostIds.cs host failure-reason surface). Valenar is the reference example, not the generic language vocabulary. The generic compiler must bind whatever scopes, contracts, methods, events, activities, policies, and fields the game declares.

Post-Wave-7 behavior concepts have been carried into the v2 design/runtime contract. The current compiler-oracle shape is the v2 WorldSchema emitted under examples/valenar-v2/Generated/obj/**/SecsGenerated.

The most important correction to this plan is that template is not a standalone first feature. Even an empty template<C> T { } needs the structural world already bound: scope declarations, contract declarations, owner-and-signature method ids, FNV-1a-64 hashes, and module registration. Roslyn work must therefore start with a structural declaration prepass before or alongside template-shell lowering.

Second correction: the callable/type foundation is no longer SecsValueKind plus scalar-only template fields. The compiler target is C# source type binding plus generated SecsTypeRef metadata as described in docs/design/07-structured-template-data-and-callables.md: structs, records, enums, arrays, chosen C# collection-shaped values, structured template fields, typed query returns, typed template commands, and generated scope command/query facades. Runtime metadata and template query/command dispatch now use typed registrations; SecsValue remains only for command payload storage and explicitly erased dynamic/tooling adapters, not normal template or scope callable ABI.

"Generated C#" in this plan means the ordinary C# syntax tree that SECS lowers to, not a requirement that the compiler writes .cs files during a normal build. The primary pipeline is .secs syntax trees -> merged SECS syntax tree -> lowered C# SyntaxTrees -> Roslyn emit -> DLL. A deterministic --emit-generated-cs diagnostic mode should write those lowered trees under an obj/ artifact directory for review and comparison with today's compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated output, but physical files are not the architectural boundary.

Implementation status (2026-07-04): The compiler path now covers the structural shell, Phase 1.5 packaging/cache/runtime invocation, the data-declaration batch, the executable behavior batch, the first event/activity/policy orchestration slice, and the Phase-3a source-set merge. .secs discovery keeps SECS files out of the normal C# parser, lowers backed declarations to generated syntax trees, supports secsc check and secsc build over ordered --base / --expansion / --mod content packages (with --order, --strict-mod-ops, --out, and --emit-generated-cs), and is pinned by tests/SECS.Compiler.Smoke; the live Valenar build compiles the full examples/valenar-v2/Content tree (113 .secs files) through this path. The current backed language surface includes scopes, root-scope contracts, templates, scalar template values, top-level field declarations, scalar scope fields, walks_to, typed scoped collections with generated lifecycle hooks, simple channels, formula-backed channels, channel modifiers, generated formula delegates, lifecycle contract methods, template command bodies, systems, on_actions, pulse/on_action events, event options, activities, the backed FromActor policy subset, and the six mod operations through the compile-time source-set merge (SecsDeclarationMerger runs between parsing and binding: inject and its try / _or_create variants patch scalar template-field value slots, replace and its variants substitute whole declarations of any backed kind, and --strict-mod-ops promotes unmatched try operations to the SECS0620 warning). conflict-report.json emission, broad expression lowering, structured fields, string field values, template-intrinsic channel sources, non-FromActor policy selectors, unsupported policy decisions, and Endgate full Generated/ replacement remain future work.

Source keyword whitelist and grammar status

The compiler must keep source syntax, diagnostics, generated stand-ins, and runtime ABI in lockstep. This table is the whitelist for committed source grammar categories; anything outside it is either ordinary C# collected for metadata, a member/body form valid only in a containing SECS declaration, or a runtime/generated concept that is not a source keyword.

CategoryCommitted source formsStatus
SECS declarationsscope, contract, template, channel, field, modifier, system, event, on_action, activity, policyTop-level SECS declaration forms. field is valid as top-level template-field metadata and as a template member assignment form.
Member forms inside declarationsroot_scope, activation, deactivation, context, query, method, phase =, frequency =, tags =, track_prev =, reads, propagates_to, provides, mode, need, selector, ruleValid only in the declaration/member contexts that own them; they are not free-standing top-level keywords.
Mod operationsinject, replace, try inject, try replace, inject_or_create, replace_or_createPrefix a normal SECS declaration and lower through the slot-merge pipeline. The old single override keyword is not committed source syntax.
Ordinary C# collected for metadatastruct, record, enum, static readonly TagId, PhaseDeclaration.Create(...), TickRate.*(...)Parsed as C#; the SECS binder harvests type/tag/phase/cadence metadata from these shapes. Top-level tag, phase, and tick_rate declarations are not SECS keywords.
Body expression/statement formsresolve, template_field, increment, add_modifier, fire on_action, save_scope_as, foreach, any, every, count, random, first, has_tag, has_template, has_contract, registry[...]SECS-aware forms inside method/formula bodies. Arbitrary direct event firing is not committed; only fire on_action ... is in the current source contract.
Formula receiver keywordsself.Field (own-target host-field read, ADR-0007), owner.resolve(Channel) / owner.Field (binding-owner reads, ADR-0008)Valid only inside formula expressions. self reads context.Target directly (no walk); owner reads context.Owner. Both lower to existing IFormulaReadContext reads; owner reads record the OwnerChannel / OwnerHostField read-set kinds.
Runtime/generated concepts, not source keywordsformula delegates, triggers, CandidateBuilderId, SelectorSource.FromCollection, SecsTypeRef, ActivityRequestOrigin, ContractDeclaration.IsRegistryOnly, bridge prev-tick readsThese are generated/runtime ABI concepts. A formula top-level keyword is not committed; formulas are lifted from dynamic channel sources and dynamic modifier effects.

New Contextual Keywords and Source Forms

SurfaceRoleNotes
struct, record, enumDeclares C# value types that participate in SECS metadataOrdinary C# declarations collected by the SECS binder. Lowers to generated C# value types plus SecsTypeDeclaration[]; referenced by generated SecsTypeRef metadata.
scopeDeclares host-owned entity shapes, fields, collections, walks, and host-exposed methodsLowers to ScopeDeclaration[], ScopeFieldDeclaration[], and ScopeMethodDeclaration[].
contractDeclares a template API and root scopeLowers to ContractDeclaration[], ContractMethodDeclaration[], and optional ContextProfileDeclaration[].
templateDeclares a template bound to a contractLowers to a static class plus TemplateEntry.
fieldDeclares template-definition dataTop-level field metadata lowers to TemplateFieldDeclaration[] with SecsTypeRef; template assignments lower to typed scalar or structured accessors.
channelDeclares channel metadata or an own-root template intrinsic sourceTop-level channels lower to ChannelDeclaration[]; template-body channels lower to activation-time channel-source registration.
modifierDeclares reusable channel/template-field effectsLowers to ModifierDeclaration[] plus formula/trigger helpers when dynamic.
systemDeclares a scheduled tick procedureLowers to a class implementing ITickSystem and a SystemRegistration.
eventDeclares a SECS eventAt declaration level, lowers to a SecsEvent subclass plus EventEntry. C# event remains legal inside ordinary C# class bodies.
on_actionDeclares an extension pointMetadata-only in committed source syntax (OnActionId, ScopeId, ProvidedScopes, SelectionMode); effects live in subscriber events (early priority guaranteed base behavior) or at the fire on_action site. No effect body, chain, or fallback syntax in committed source.
activityDeclares a class-style actor-driven activity (Wave 1 replacement for the removed behavior keyword)Lowers to a SecsActivity subclass with typed IActivityArgs<TSelf> args (Wave 3) and a declarative Preview returning EffectPlan (Wave 2). Old allow / effect block syntax is prototype-only and is not the compiler target.
policyDeclares a utility-AI policy with needs, selectors, and rules (Wave 4)Lowers to a SecsPolicy subclass plus per-rule body delegates and NeedTargetExpr discriminated-union targets (Wave 5).
injectPartial slot patch — replaces listed semantic slots, retains the restSECS mod operation; not C# member override.
replaceFull declaration replacement — discards the old declaration bodySECS mod operation.
try injectPatch if target exists; no-op if missingCompatibility patches across DLC/loadout combinations.
try replaceFull replacement if target exists; no-op if missingSame use-case as try inject but broader replacement.
inject_or_createInject if target exists; otherwise create as a new declarationUseful for mod-additive modifiers and templates.
replace_or_createReplace if target exists; otherwise createTotal-conversion and broad compatibility layers.
query / methodDeclares contract callable signaturesquery is read-only and may return any bound source type that lowers to SecsTypeRef; method void is command-producing and must return void.
@<declared-scope-name>, @root, @this, etc.Declared scope sigils plus built-in frame references@Location / @Settlement are Valenar examples; the compiler binds whatever scope names the game declares. These are expression forms, not a single @scope keyword.
resolve, template_field, increment, add_modifier, fire on_action, save_scope_as, foreachLowered expression forms inside bodiesParsed as SECS-aware expressions/statements over normal C#-style bodies. Arbitrary direct event firing remains future work.

New Syntax Nodes

Each SECS construct needs a Roslyn syntax representation that can survive merge, binding, diagnostics, and lowering:

  • ScopeDeclarationSyntax, with field, collection, walks_to, and scope-method members.
  • ContractDeclarationSyntax, with root_scope, lifecycle bindings, query / method signatures, and context profiles.
  • SecsTypeDeclarationSyntax for C# struct, record, and enum declarations, plus binding of C# array and collection type syntax to recursive SecsTypeRef metadata.
  • TemplateDeclarationSyntax, including template fields, own-root intrinsic channels, contract query/method bodies, modifiers, and transition edges.
  • TemplateFieldDeclarationSyntax / TemplateFieldAssignmentSyntax.
  • SecsChannelDeclarationSyntax for top-level channel metadata and template-body channel sources.
  • ModifierDeclarationSyntax and modifier effect syntax.
  • SystemDeclarationSyntax.
  • SecsEventDeclarationSyntax.
  • OnActionDeclarationSyntax.
  • SecsActivityDeclarationSyntax (Wave 1 — renamed from the prior SecsActionDeclarationSyntax).
  • SecsPolicyDeclarationSyntax (Wave 4 — needs/selectors/rules with rule-body delegates and NeedTargetExpr targets).
  • ScopeSigilExpressionSyntax, saved-scope expressions, contract-call expressions, and SECS-aware invocation/iteration forms.
  • ModOperationDeclarationSyntax — wraps any inject, replace, try inject, try replace, inject_or_create, or replace_or_create operation around a standard SECS declaration syntax node. Carries the ModOperationKind enum and the inner declaration's identity/body. Replaces the old OverrideDeclarationSyntax.

Lowering Targets (Matching Current Engine API)

Each SECS node lowers to ordinary C# syntax trees matching the current Generated/ contract. Rows that use Valenar names are examples of the generic lowering shape, not compiler-reserved vocabulary.

SECS ConstructLowers To (matching current engine)
struct / record / enum declarationsGenerated ordinary C# types plus SecsTypeDeclaration[]. Every reference to these source types in fields/callables lowers to SecsTypeRef metadata.
scope <declared-scope> { ... }ScopeDeclaration with ParentScopeIds, ScopeFieldDeclaration[], ScopeMethodDeclaration[], and H.* hashes.
int SomeQuery(ulong templateId); / LocationFacts Facts(); on a scopeScopeMethodDeclaration keyed by scope:<scope-name>.<Method>(<params>):<return>; read-only calls lower to generated typed facades such as LocationScopeQueries.BuildingCount(host, location, templateId) over ISecsHostReads.CallScopeQuery<TArgs,TResult>(...). Erased adapters are only dynamic/tooling boundaries.
void SomeCommand(<declared-scope> target, int amount); on a scopeScopeMethodDeclaration with ReturnType = SecsTypeRef.Void and ParameterTypes = [SecsTypeRef.EntityInScope(...), SecsTypeRef.Int]; call sites lower through generated typed command facades such as CharacterScopeCommands.AdvanceLead(tick, character, target, amount) over the active command context / ISecsHostCommands, then flush after the source statement. Scope-typed parameters use declared scope names.
contract C { root_scope S; query bool CanX(); method void OnY(); }ContractDeclaration with query/method id arrays and lifecycle bindings, plus one ContractMethodDeclaration row per callable signature.
context SomeContext { @SomeScope; @root; }ContextProfileDeclaration owned by the contract; expands to an ordered context chain for template_field(...). SomeScope stands for a source-declared scope name; it is not engine vocabulary.
template<C> T { ... }Static class T with TemplateEntry { TemplateId, Name, ContractId, RootScopeId, Activate, StructuredFields, Queries, Methods, GetField* }, registered from SecsModule.Initialize.
field int SomeField = 10 inside a templateTyped scalar GetFieldInt switch on the template entry, with top-level field metadata using SecsTypeRef.Int.
field FeaturePlacementProfile Placement = new(...) inside a templateStructured immutable template data accessor returning concrete FeaturePlacementProfile, plus top-level field metadata using SecsTypeRef.Record(H.FeaturePlacementProfile).
channel int Food = 5 inside a templatecmds.RegisterChannelSource(scope.Owner, scope.Root, H.ChannelFood, 5) inside Activate; legal only when the channel belongs to the contract root scope.
channel int Food { return ...; } inside a templatecmds.RegisterDynamicChannelSource(scope.Owner, scope.Root, H.ChannelFood, H.Formula_TFood) plus a formula delegate and registry.RegisterFormula(...); cross-scope effects use named modifiers instead.
bool SomeQuery() { ... } on a template whose contract declares itTyped contract query implementation returning concrete bool, registered by contract owner+signature id with metadata ReturnType = SecsTypeRef.Bool. CanBuild is only Valenar vocabulary; there is no special template activation/can-activate hook.
FeaturePlacementResult EvaluatePlacement(Location candidate, FeaturePlacementInput input) { ... }Typed read-only query returning a declared record. Used by feature generation as local scoring only; global budgets/spacing/conflicts are applied by systems after query results are collected.
void SomeMethod() { ... } on a template whose contract declares itGeneric contract method implementation with ITemplateCommandContext and typed argument structs/adapters, registered in TemplateEntry.Methods as TemplateCommand<TArgs> by contract owner+signature id. Generated bodies use context.Scope, context.Host, context.Commands, and context.FlushCommands() after command-producing source statements. Lifecycle bindings call this same generic method surface; there is no raw-entity world-parameter method ABI and no erased argument ABI for normal generated calls.
@SomeScope.SomeFieldDeclared scope walk from the current ScopeFrame, then typed host read such as host.ReadInt(...) selected by the declared scope-field type. Valenar example: @Location.Fertility.
@SomeScope.resolve(SomeChannel)Declared scope walk, then typed channel resolution (host.ResolveChannel* in formulas/triggers or ctx.ChannelResolver.Resolve*Fast in tick bodies). Valenar example: @Settlement.resolve(Morale).
self.SomeField (formula)DIRECT host-field read on the formula's own target with NO scope walk: context.Reads.ReadHostField{Int32|Int64|Double}(context.Target, H.ScopeField...). Distinct from @Scope.Field (which always emits a walk prelude + ScopeWalk read row). FK-anchored against the formula's own root scope. Records a FormulaReadKind.HostField row. See ADR-0007.
owner.resolve(SomeChannel) (formula)Channel read on the binding owner: context.Reads.ReadChannelDouble(context.Owner, H.Channel...). Records a FormulaReadKind.OwnerChannel read row. See ADR-0008.
owner.SomeField (formula)Host-field read on the binding owner: context.Reads.ReadHostField{Int32|Int64|Double}(context.Owner, H.ScopeField...). Records a FormulaReadKind.OwnerHostField read row. See ADR-0008.
template_field(SomeTemplate, SomeField, SomeContext)Base/effective template-field read with the contract-owned context profile expanded to an explicit ordered entity chain. Return type is the field's SecsTypeRef; additive/multiplicative effective reads are numeric scalar only, while structured fields are whole-value exact-type only.
modifier X { SomeChannel += 5; field SomeField *= 80%; }ModifierDeclaration with ModifierEffectTargetKind.Channel or TemplateField, typed effect values, stacking/reapply/decay/tag metadata.
target.add_modifier X while cond duration NCommandBuffer.AddModifier(owner, target, H.X, H.Trigger_Cond, N, captured0, captured1); stored triggers use TriggerDelegate(target, captured0, captured1, host).
system SomeSystem { ... }Class implementing ITickSystem with Frequency, Phase, Execute(TickContext), a static SystemRegistration, and statement-level ctx.FlushCommands() after command-producing source statements.
event SomeEvent { ... }Sealed class deriving from SecsEvent, with Condition(EventQueryContext) (read-only), Execute(EventContext) (command-producing), optional BuildOptions(EventOptions options, EventQueryContext context) (read-only), option handlers Action<EventContext> (command-producing), and static EventEntry Registration. The read-only vs command-producing split is structurally enforced post-Wave C; see docs/adr/ad-0004-structural-read-only-execution-context-types.md and docs/design/10-host-secs-execution-boundary.md § 13.4.
on_action SomeOnAction { ... }OnActionDeclaration metadata (OnActionId, ScopeId, ProvidedScopes, SelectionMode) only; no source-authored effect delegate.
activity SomeActivity { ... }Sealed class deriving from SecsActivity (Wave 1), using ActivityQueryContext for read-only methods (CanStart, IsTargetValid, Preview) and ActivityRunContext for command-producing lifecycle methods (OnStart, OnUpdate, OnComplete, OnStop, OnCancel, OnFail). The pre-Wave-C ActivityContext type is deleted; no [Obsolete] alias exists. Typed args declared as a record struct implementing IActivityArgs<TSelf> (Wave 3) and serialized through ActivityArgsBlob. Preview returns EffectPlan with PredictedEffect rows (Wave 2).
policy SomePolicy { ... }Sealed class deriving from SecsPolicy (Wave 4), with Needs : IReadOnlyList<NeedDeclaration>, Selectors : IReadOnlyList<SelectorDeclaration>, Rules : IReadOnlyList<RuleDeclaration>, and EvaluateRule(RuleId, PolicyQueryContext) -> RuleDecision dispatching by rule id. The pre-Wave-C PolicyRunContext type is deleted; no [Obsolete] alias exists. Need targets use `NeedTargetExpr.Literal
foreach entity in Contractctx.InstanceStore.AllByContract(H.ContractId) or AllByContractForTick(...) for distributed system frequencies.
fire on_action X target targetSaved-scope validation + ctx.Events.FireOnAction(H.X, target, ctx).
inject template Farm { field GoldCost = 8; }Slot-level partial patch: the template_field:GoldCost slot is replaced; all other slots are retained. Emitted C# reflects the merged winner only.
replace template Farm { ... }Full declaration replacement: the old body is discarded and the new body becomes the declaration.
try inject / try replaceSilent no-op when the target identity is absent; otherwise behaves as inject / replace.
inject_or_create / replace_or_createInject/replace if target exists; create new declaration if absent. Body must satisfy full declaration requirements on the create path.

Formula and trigger delegates remain lower-level callbacks with raw EntityHandle parameters because channel resolution and binding updates run outside a normal contract scope frame. Template activation and queries use ScopeFrame directly; template command methods use ITemplateCommandContext, which carries the same scope frame plus the command surface. No generated template or contract callable may use ad hoc world-parameter signatures.

Generated Command-Flush Contract

Generated template contract methods, system bodies, event bodies, and activity bodies flush after every command-producing source statement:

  • increment, add_modifier, and void scope-method calls lower to the command surface and then ctx.FlushCommands() / context.FlushCommands(). save_scope_as writes the saved-scope frame directly; fire on_action dispatches through EventDispatcher after the required scopes are present.
  • A later statement, later subscriber, later activity lifecycle callback, or later loop iteration sees writes from earlier command-producing statements in the same tick.
  • Runtime dispatchers still flush or discard pending commands at body boundaries as a safety net, but the compiler must not rely on end-of-body batching for ordinary behavior bodies.
  • Template contract method void bodies use ITemplateCommandContext, so they follow the same statement-level flush contract as systems, events, and activities (policy rule bodies are read-only — see docs/design/10-host-secs-execution-boundary.md § 13). Template Activate remains a generated intrinsic-channel-source registration method that receives CommandBuffer; the runtime validates activation channel-source targets and flushes activation at the boundary.

Current Engine Types the Compiler Targets

The current compiler target is the v2 contract surface under src/SECS.Core, src/SECS.Schema, src/SECS.Compiler.Contracts, src/SECS.Kernel, and src/SECS.Runtime, with executable reference shapes in examples/valenar-v2/Generated/. Keep this section aligned with docs/specs/sp-world-schema-contract.md, docs/runtime/rn-kernel-contract.md, docs/runtime/rn-kernel-runtime-layers.md, and docs/design/10-host-secs-execution-boundary.md rather than reconstructing type tables in this roadmap.

Runtime context contract. See docs/adr/ad-0004-structural-read-only-execution-context-types.md and docs/design/10-host-secs-execution-boundary.md § 13.4. The compiler must preserve the structural split between read-only query bodies and command-producing bodies. Generated bodies must access read-only state through read-only context/facade types and must emit command-producing bodies through the runtime command surface with statement-level flushes. SECS0803 (CommandInReadOnlyContext) is the shipped compiler guard that rejects command-producing operations inside read-only source expressions.

Diagnostic Code Catalog

A consolidated table of every SECS diagnostic code. Shipped codes are the DiagnosticDescriptors declared in secs-roslyn/src/Compilers/CSharp/Portable/SECS/Syntax/SecsDiagnosticDescriptors.cs and raised by the fork's parser, binder, source-set merger, or command line; the remaining rows are reservations for later compiler/analyzer phases. The v2 runtime ships no diagnostic-code enums. A reservation must never reuse a shipped number; re-allocate the reservation in this catalog before its phase lands.

CodeOwnerSeverityDiagnosesStatus
SECS0001ParserErrorInvalid SECS syntax (structural parse error; message carries the parser text)Shipped (SyntaxError)
SECS0030ParserErrorRemoved Wave-1 keyword (legacy activity declaration); use activity (see Phase 4 Reserved diagnostic codes below)Reserved (Wave 1)
SECS0031ParserErrorRemoved Wave-1 keyword (legacy policy declaration); use policy (see Phase 4 Reserved diagnostic codes below)Reserved (Wave 1)
SECS0032ParserErrorUncommitted SECS-looking source form or keyword (formula top-level declaration, old override, top-level tag, top-level phase / tick_rate, arbitrary direct fire EventName); ordinary C# metadata collection remains allowedShipped (UnsupportedSourceForm)
SECS0033ParserErrorMod-operation form outside the Phase-3a surface (inject of a non-template kind, inject of a non-scalar template member, a non-template _or_create, or a qualified / string-literal target)Shipped (UnsupportedModOperationForm)
SECS0034Command lineErrorInvalid SECS content package manifest (secs.package.json)Shipped (InvalidContentPackageManifest)
SECS0101BinderErrorDuplicate scope field on a scopeShipped (DuplicateScopeField)
SECS0102BinderErrorUnsupported scope field type (current slice: int, long, float, double, bool)Shipped (UnsupportedScopeFieldType)
SECS0103BinderErrorScope declares a duplicate walks_to targetShipped (DuplicateScopeWalk)
SECS0104BinderErrorUnknown walks_to target scopeShipped (UnknownWalkTarget)
SECS0105BinderErrorInvalid scoped collection type; use ScopedList<TElement> or ScopedDictionary<TKey, TElement>Shipped (InvalidScopedCollectionType)
SECS0201BinderErrorReference to an unknown scope declaration (contract.root_scope and other scope references)Shipped (UnknownScope)
SECS0202BinderErrortemplate<Contract> references an unknown contract declarationShipped (UnknownContract)
SECS0203BinderErrorDuplicate template field on a templateShipped (DuplicateTemplateField)
SECS0205BinderErrorUnsupported field type (current slice: int, long, float, double, bool)Shipped (UnsupportedFieldType)
SECS0206BinderErrorUnsupported template field initializer (simple scalar literals only)Shipped (UnsupportedTemplateFieldInitializer)
SECS0207BinderErrorTemplate field assignment has no matching top-level field declarationShipped (UnknownTemplateField)
SECS0208BinderErrorTemplate field declared type does not match assigned typeShipped (TemplateFieldTypeMismatch)
SECS0211ParserErrorInvalid template body declarationShipped (InvalidTemplateBodyDeclaration)
SECS0212BinderErrorUndeclared tag in tags = ... or structural predicateReserved
SECS0301BinderErrorUnsupported channel type (current slice: int, long, float, double, bool)Shipped (UnsupportedChannelType)
SECS0302BinderErrorUnsupported channel kind (supported: Base, Accumulative, Contributed)Shipped (UnsupportedChannelKind)
SECS0303BinderErrorChannel references an unknown source scopeShipped (UnknownChannelSourceScope)
SECS0304BinderErrorChannel references an unknown source fieldShipped (UnknownChannelSourceField)
SECS0305BinderErrorChannel type does not match its source field typeShipped (ChannelSourceTypeMismatch)
SECS0306BinderErrorUnsupported literal for a channel propertyShipped (UnsupportedChannelLiteral)
SECS0401BinderErrorModifier references an unknown target channelShipped (UnknownModifierTargetChannel)
SECS0402BinderErrorUnsupported modifier expressionShipped (UnsupportedModifierExpression)
SECS0403BinderErrorUnsupported modifier target (current slice supports channel effects only)Shipped (UnsupportedModifierTarget)
SECS0404BinderErrorUnsupported modifier stacking value (valid: single, unique, stackable)Shipped (UnsupportedModifierStacking)
SECS0410-SECS0415Registry static analysisWarningActivity static-analysis band (unknown tag, non-positive duration, unknown actor/target scope, non-positive cost, policy-visible activity missing Preview())Reserved — no shipped owner in the live tree
SECS0420ParserErrorinject activity X sets replace-only architectural slotReserved (per-slot activity/policy inject is future Phase 3 work)
SECS04xx (future)Binder + loweringErrorDuplicate EventOption.Key within one event's final merged option list after mod operationsReserved for stable option key protocol (see docs/design/04-behavior.md, 10-host-secs-execution-boundary.md § 20.3, and FUTURE_WORK § 1.3). Must be enforced before any PendingChoiceSave or ResolveChoiceByKey lowering.
SECS0501BinderErrorContract references an unknown methodShipped (UnknownContractMethod)
SECS0502BinderErrorDuplicate contract method on a contractShipped (DuplicateContractMethod)
SECS0503BinderErrorTemplate implements a method its contract does not declareShipped (UnknownTemplateMethod)
SECS0504ParserErrorUnsupported template method statementShipped (UnsupportedTemplateMethodStatement)
SECS0505BinderErrorTemplate method references an unknown modifierShipped (UnknownTemplateMethodModifier)
SECS0506BinderErrorTemplate method references a scope unreachable from the contract root scopeShipped (UnknownTemplateMethodScope)
SECS0507BinderErrorTemplate callable has no matching contract query/method signatureShipped (TemplateCallableSignatureMismatch)
SECS0601BinderErrorFNV-1a-64 collision between distinct canonical identitiesShipped (HashCollision)
SECS0602BinderErrorDuplicate declaration without inject/replaceShipped (DuplicateDeclaration)
SECS0603MergerErrorinject/replace target missingShipped (ModOperationTargetMissing)
SECS0604MergerErrorOperation kind invalid for declaration kindShipped (ModOperationKindInvalid)
SECS0605MergerErrorUnknown or unsupported slot in a mod operation bodyShipped (UnknownModSlot)
SECS0606MergerErrorAttempt to replace the additive-closed walks_to edge setShipped (ClosedScopeWalkReplacement)
SECS0607MergerErrorwalks_to references undeclared scope after mergeShipped (UndeclaredMergedScopeWalk)
SECS0608MergerErrorHost-capable expansion references a source set outside load-order ancestryShipped (HostExpansionAncestryViolation)
SECS0609MergerErrorThird-party data-only mod adds host-backed structural surfaceShipped (DataOnlyModHostSurface)
SECS0610MergerErrorAmbiguous short identity reference in mod operationShipped (AmbiguousModOperationTarget)
SECS0611MergerErrorreplace declaration missing required slotShipped (ReplaceMissingRequiredSlot)
SECS0612MergerErrorDuplicate slot in same declaration / inject bodyShipped (DuplicateModSlot)
SECS0613MergerErrorphase / frequency refers to value not registered in scheduler / catalogCompiler-planned
SECS0614MergerErrorTargeted child-row operation matched zero targetable rowsShipped (ChildRowSelectorNoMatch)
SECS0615MergerErrorTargeted child-row operation matched multiple targetable rowsShipped (ChildRowSelectorAmbiguous)
SECS0616MergerErrorChild-row selector is not activation-constant or not bindableShipped (ChildRowSelectorNotBindable)
SECS0620MergerWarningtry inject / try replace matched no target under --strict-mod-ops (silent no-op without the flag; never an error, never aborts the merge)Shipped (TryModOperationNoMatch)
SECS0701BinderErrorUnknown type name in a field, callable signature, struct/record field, array element, collection generic argument, or template/scope referenceShipped (UnknownType)
SECS0702BinderErrorValue initializer does not match the declared typeShipped (InitializerTypeMismatch)
SECS0703BinderErrormethod declaration has a non-void return type; use query for read-only typed returnsShipped (NonVoidMethod)
SECS0704BinderErrorQuery body performs a command-producing operationCompiler-planned
SECS0705MergerErrorCallable mod patch changes parameter or return type; body replacements may replace implementation onlyShipped (CallableSignatureChangedByMod)
SECS0706BinderErrorRuntime template-field modifier attempts additive/multiplicative effect on a non-numeric structured fieldCompiler-planned
SECS0800MergerWarningTwo or more mod operations write the same slot; the last write in load order winsShipped (ModSlotConflict)
SECS0801BinderErrorUnsupported behavior expression on an event/activity/policy declarationShipped (UnsupportedBehaviorExpression)
SECS0802BinderErrorUnknown behavior reference (event/activity/policy declaration references an unknown identity)Shipped (UnknownBehaviorReference)
SECS0803BinderErrorCommand-producing operation in a read-only expression (conditions, source_formula, can_start, and command argument expressions may only read state)Shipped (CommandInReadOnlyContext)
SECS0804BinderErrorUnsupported formula expression (allowed: resolve(Channel), self.Field, owner.resolve(Channel), owner.Field, owner.Scope.Field, @Scope.resolve(Channel), @Scope.Field, the numeric builtins pow/trunc/min/max/clamp, numeric/bool literals, the supported operators, and the conditional operator)Shipped (UnsupportedFormulaExpression)
SECS0805BinderErrorFormula references an unknown channelShipped (UnknownFormulaChannel)
SECS0806BinderErrorFormula references an unknown scopeShipped (UnknownFormulaScope)
SECS0807BinderErrorFormula references an unknown fieldShipped (UnknownFormulaField)
SECS0808BinderErrorFormula reads a bool host field (formulas evaluate in the double domain and may only read numeric host fields)Shipped (NonNumericFormulaHostField)
SECS0810-SECS0812Save/load runtimeErrorActivity restore validation band (unknown activity id, args schema mismatch, zero lane id)Reserved — the v2 restore paths throw InvalidOperationException without diagnostic-code identifiers
SECS0820-SECS0821Save/load runtimeErrorSlot restore validation band (unknown slot kind, element type mismatch)Reserved — v2 ScopeSlotStore.RestoreFrom throws InvalidOperationException without diagnostic-code identifiers
SECS0822BinderErrorScopedList<T> indexed access attempt; use ScopedDictionary<TKey, TElement> for keyed accessShipped (ListCollectionIndexerUnsupported)
SECS0830AnalyzerErrorPrevious-tick bridge/API read on channel without track_prev = trueDoc-only analyzer reservation
SECS0840BinderErrorTwo scoped collections on the same scope derive the same lifecycle hook method nameShipped (CollectionHookNameCollision)
SECS0910-SECS0914Registry static analysisWarningPolicy static-analysis band (unknown need channel, unknown selector activity, conflicting need weight signs, unknown selector tag, unknown actor scope)Reserved — no shipped owner in the live tree
SECS1001BinderErrorSystem references an unknown foreach scopeShipped (UnknownSystemScope)
SECS1002ParserErrorUnsupported system statementShipped (UnsupportedSystemStatement)
SECS1003BinderErrorUnsupported system expressionShipped (UnsupportedSystemExpression)
SECS1004BinderErrorSystem references an unknown fieldShipped (UnknownSystemField)
SECS1005BinderErrorSystem references an unknown channelShipped (UnknownSystemChannel)

Diagnostic-code numbering ranges:

  • SECS00xx — parser and command-line errors (structural syntax, source keyword whitelist guard, mod-operation form guard, package manifest validation) plus the Wave 1 removed-keyword reservations (SECS0030 / SECS0031).
  • SECS01xx — scope binder errors (scope fields, walks_to edges, scoped collections).
  • SECS02xx — template/contract structural binder errors (unknown scope/contract, template fields, template body) plus the reserved SECS0212.
  • SECS03xx — channel binder errors (type, kind, source scope/field, literals).
  • SECS04xx — modifier binder errors (SECS0401-SECS0404) plus the reserved activity static-analysis and closed-slot bands (SECS0410-SECS0415, SECS0420).
  • SECS05xx — contract/template callable binder errors.
  • SECS06xx — identity and merge errors: hash collision and duplicate declaration (SECS0601 / SECS0602), the source-set merger band (SECS0603-SECS0616), and the strict-mod-ops warning (SECS0620); SECS0613 remains compiler-planned.
  • SECS07xx — binder/merger errors for the structured-types and callables surface from docs/design/07-structured-template-data-and-callables.md. SECS0701-SECS0703 and SECS0705 are shipped; SECS0704 and SECS0706 remain compiler-planned. SECS0705 straddles the binder/merger boundary because it constrains mod patches of callable signatures.
  • SECS08xx — behavior/formula binder errors (SECS0801-SECS0808), the merge slot-conflict warning (SECS0800), the list-indexer and collection-hook errors (SECS0822, SECS0840), and the reserved save/load and analyzer bands (SECS0810-SECS0812, SECS0820-SECS0821, SECS0830).
  • SECS09xx — reserved policy static-analysis band (SECS0910-SECS0914).
  • SECS10xx — system binder errors.

Mod-operation / slot / source-boundary status crosswalk

This crosswalk is limited to diagnostics that sit on the source/compiler/runtime boundary. "Compiler-shipped" means the fork's parser, merger, or binder raises the code today. "Compiler-planned" means later compiler work must emit the code. "Doc-only reservation" means the number is allocated here so future analyzer work cannot collide with shipped codes; the v2 runtime ships no diagnostic-code enums.

Code(s)Current allocation statusShipped surface todayTest coverage todayBoundary note
SECS0032Compiler-shippedUnsupportedSourceForm (parser)Fork parser tests (SecsParserTests) + SECS.Compiler.SmokeSource keyword whitelist guard; rejects uncommitted SECS source forms while allowing ordinary C# metadata collection.
SECS0033Compiler-shippedUnsupportedModOperationForm (parser)Fork parser/merger tests + smoke ModMerge fixturesParse-time rejection of mod-operation forms outside the Phase-3a surface, so the merger only ever observes supported wrappers.
SECS0034Compiler-shippedInvalidContentPackageManifest (command line)SECS.Compiler.Smoke package/cache testssecs.package.json validation for ordered content packages.
SECS0601Compiler-shippedHashCollision (binder)Fork binder tests (SecsBinderTests)Deterministic canonical-id collision check before binding / lowering.
SECS0602Compiler-shippedDuplicateDeclaration (binder)Fork binder/merger testsDuplicate identity without a mod operation, checked on the merged declaration set.
SECS0603-SECS0612, SECS0614-SECS0616Compiler-shippedSecsDeclarationMerger merge band (see main table for per-code identifiers)SecsDeclarationMergerTests (52 tests) + smoke ModMerge packages and phase3a-* fixturesMerge-time operation, slot, source-boundary, and child-row validation from 06 § 12.
SECS0613Compiler-plannedNoneNo compiler tests yetScheduler/catalog value check from 06 § 12.
SECS0620Compiler-shippedTryModOperationNoMatch (merger, warning)StrictModOpsWarnsForUnmatchedTryInject* smoke testsWarn-only under --strict-mod-ops; silent no-op otherwise; never aborts the merge.
SECS0705Compiler-shippedCallableSignatureChangedByMod (merger)Fork merger tests + phase3a-callable-body-replace fixtureCallable mod patches replace implementation only; signatures are closed.
SECS0800Compiler-shippedModSlotConflict (merger, warning)Fork merger testsWarning conflict report; later load-order write wins.
SECS0810-SECS0812, SECS0820-SECS0821Doc-only reservationNoneNone keyed to these codesSave/load restore validation; the v2 restore paths throw InvalidOperationException without diagnostic-code identifiers.
SECS0822Compiler-shippedListCollectionIndexerUnsupported (binder)Fork binder testsScopedList<T> indexed access binder error.
SECS0830Doc-only analyzer reservationNoneNo analyzer tests yetPrev-tick bridge/API read requires track_prev = true; helper shape remains deliberately narrow.
SECS0840Compiler-shippedCollectionHookNameCollision (binder)Fork binder tests + collection-lifecycle-hooks smoke fixtureColliding generated collection lifecycle hook names, caught at compile time.

The shipped codes are exactly the descriptors in SecsDiagnosticDescriptors.cs; all remaining rows in this crosswalk are compiler/analyzer reservations, and no reservation may reuse a shipped number.

This catalog is the single source of truth for diagnostic-code allocation. The phase-specific Diagnostics subsections in docs/design/00-overview.md § Diagnostic code convention and the per-doc Diagnostics tables (01–09) describe each code's full semantics; this table catalogs which codes exist, where they live in the compiler today, and which phase reserves the unshipped ones.

Signature Id Rules

Every callable user/game method uses ordinary C#-style source syntax, but generated ids are owner-and-signature hashes:

  • Scope method canonical text: scope:<scope-name>.<Method>(<param-type-refs>):<return-type-ref>.
  • Contract query/method canonical text: contract:<contract-name>.<Method>(<param-type-refs>):<return-type-ref>.
  • Examples from Valenar: scope:Location.BuildingCount(ulong):int, scope:Character.AdvanceLead(Location,int):void, contract:Building.CanBuild():bool, contract:Building.OnBuilt():void.
  • The canonical text is built from the declared owner, source method name, source parameter SecsTypeRefs, and source return SecsTypeRef. Zero-argument signatures use empty parentheses in the hash input; NoArgs appears only in generated constant names.
  • Scope-typed entity parameters use the source scope name in the canonical text and SecsTypeRef.EntityInScope(H.ScopeName) in declaration metadata.
  • Structured parameters and returns use canonical type names from the SecsTypeRef, e.g. FeaturePlacementInput and FeaturePlacementResult in contract:Feature.EvaluatePlacement(Location,FeaturePlacementInput):FeaturePlacementResult.
  • Hash constants use the generated naming convention shown in Generated/Hashes.cs; Valenar examples include H.Scope_Location_BuildingCount_TemplateId_Int and H.Contract_Building_OnBuilt_NoArgs_Void.
  • Never hash or emit bare callable names. Every scope method, contract query, and contract method belongs to its scope or contract owner.
  • Source calls still bind by normal name/signature resolution. The hash id is the emitted runtime key.

The Phase 1 compiler MUST normalize to the namespace-qualified form <namespace>:<kind>/<name> for all hash inputs. The first compiler-emitted Hashes.cs baseline is authoritative; the canonical-name field on each declaration is the source of truth, and the hash is a derived value.

What the Compiler Generates (Module Initialization)

The compiler produces a SecsModule class as part of the lowered C# syntax tree, matching the v2 WorldSchema assembly shape in the compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/SecsModule.g.cs. The minimum current shape is broader than the old channels/modifiers/templates list:

public static class SecsModule
{
public static void Initialize(SecsRegistry registry)
{
registry.RegisterTypes(Declarations.Types);
registry.RegisterPhases(Declarations.Phases); // Wave 30+: phase declarations
registry.RegisterTickRates(Declarations.TickRates); // Wave 30+: cadence declarations
registry.RegisterScopes(Declarations.Scopes);
registry.RegisterScopeFields(Declarations.ScopeFields);
registry.RegisterScopeMethods(Declarations.ScopeMethods);
registry.RegisterCollections(Declarations.Collections); // Phase 2+: typed ScopedList/ScopedDictionary fields
registry.RegisterTemplateFields(Declarations.TemplateFields);
registry.RegisterChannels(Declarations.Channels);

registry.RegisterFormula(H.Formula_TSomeChannel, Formula_TSomeChannel.Evaluate, readsChannels: []);
registry.RegisterFormulaContribution(H.Formula_TSomeChannel, H.SomeChannel);

registry.RegisterModifiers(Declarations.Modifiers);
registry.RegisterContracts(Declarations.Contracts);
registry.RegisterContractMethods(Declarations.ContractMethods);
registry.RegisterContextProfiles(Declarations.ContextProfiles);
registry.RegisterOnActions(OnActionDeclarations.All);

// Tags — registered named so structural predicates and tooltips can address them.
registry.RegisterTag(H.Tag_SomeTag, "SomeTag");

// Validate dependency graph — catches formula/channel/scope cycles at registration time.
var errors = registry.ValidateDependencies();
if (errors.Count > 0)
throw new InvalidOperationException(
$"SECS dependency validation failed:\n{string.Join("\n", errors)}");

registry.RegisterTemplate(new TemplateId(T.Id), T.Entry);
// Valenar example; default-template generation is owned by a later phase.
registry.RegisterDefaultTemplate(H.Location, new TemplateId(BareLocation.Id));
registry.RegisterTemplateTransitions(Declarations.TemplateTransitions);

// Runtime collection bridge for selectors backed by definition-reference slots.
registry.RegisterCandidateBuilder(SomeBuilder.Id, SomeBuilder.Build);

registry.RegisterTrigger(H.Trigger_SomeCondition, Trigger_SomeCondition.Evaluate);

registry.RegisterActivities(Declarations.Activities); // Wave 1
registry.RegisterPolicies(Declarations.Policies); // Wave 4

// Mod operations are merged at compile time by the Phase 3 source-set
// merge; the emitted module registers only the merged winners and has
// no runtime mod-registration surface.
}

public static readonly SystemRegistration[] Systems = [ /* ... */ ];
public static readonly EventEntry[] Events = [ /* ... */ ];
public static readonly SecsActivity[] Activities = [ /* ... */ ]; // Wave 1 — replaces the legacy behavior array
public static readonly SecsPolicy[] Policies = [ /* ... */ ]; // Wave 4
}

Phase 0 baseline: the active compiler-oracle module is the compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/SecsModule.g.cs. Phase 1+ implementation should match the v2 WorldSchema assembly shape there for executable v2 output. The example above is the abridged structural skeleton; the emitted file shows the current schema registration ordering and second-pass callback shape.

For Phase 1, the required checkpoint is the structural subset of that shape, not the full Valenar module. A minimal empty-template source set must still lower to:

registry.RegisterTypes(Declarations.Types);
registry.RegisterScopes(Declarations.Scopes);
registry.RegisterScopeFields(Declarations.ScopeFields);
registry.RegisterScopeMethods(Declarations.ScopeMethods);
registry.RegisterTemplateFields(Declarations.TemplateFields);
registry.RegisterChannels(Declarations.Channels);
registry.RegisterContracts(Declarations.Contracts);
registry.RegisterContractMethods(Declarations.ContractMethods);
registry.RegisterContextProfiles(Declarations.ContextProfiles);
registry.RegisterTemplate(T.Id, T.Entry);

Everything else in the full module shape — formulas, formula contributions, modifiers, on-actions, default templates, transitions, triggers, systems, events, activities, policies, and mods — can be empty or deferred until the phase that owns that construct. The important point is that template shell lowering is not valid unless the registry has already seen the type catalog, scope graph, callable signatures, hashes, and contract metadata the shell references.

ContractDeclaration[] alone is never a correct contract lowering target. Each contract query/method id listed in QueryMethodIds or MethodIds must have a matching ContractMethodDeclaration row using SecsTypeRef, and each lifecycle binding must target a declared parameterless void method. Likewise, scope methods are structural declarations even when their implementation lives in the host: ScopeMethodDeclaration[] is what lets the binder type-check calls and what lets generated call sites pick typed read-only host calls vs command-producing method forwarding.


Implementation Phases

Phase 0: Orientation & Build

Goal: Fork builds successfully, can compile a normal .cs file with the forked Roslyn.

Status: Complete for the current workspace baseline: secs-roslyn/ is present as the Roslyn submodule and builds. Phase 1 structural-shell work has landed on top of that baseline, and the compiler now emits the backed language surface for Valenar's Generated/ output (see the implementation-status note in "Current Lowering Baseline"). Broad language expansion and full Generated/ replacement for the unbacked surface remain future work.

  • Fork dotnet/roslyn, create secs branch
  • Build the compiler and verify it works on a trivial C# file
  • Add as submodule in secs-workspace
  • Document build instructions in secs-roslyn/BUILDING-SECS.md

Key areas of the Roslyn codebase:

AreaWhat it doesWhy we touch it
src/Compilers/CSharp/Portable/Syntax/Syntax node definitionsAdding SECS node types
src/Compilers/CSharp/Portable/Parser/Parser (LanguageParser.cs)Adding SECS keyword parsing
src/Compilers/CSharp/Portable/Binder/Semantic analysisTeaching the binder about SECS types
src/Compilers/CSharp/Portable/Lowering/Desugaring transformsLowering SECS nodes to standard C#
src/Compilers/CSharp/Portable/Symbols/Symbol tableSECS identities in the symbol table
src/Workspaces/IDE/LSP supportMaking LSP aware of .secs files

Phase Scope Rule

Each phase below implements only committed current syntax and runtime shapes. Open questions are not parser, binder, lowering, CLI, LSP, or test commitments until the design docs settle them. If an open question is mentioned inside a phase, the phase must either use today's committed spelling/shape or explicitly reject/defer the alternative.

Phase 1: Structural Prepass + Template Shell

Goal: Parse enough SECS structure to lower an empty template for any declared contract, emit hashes/declaration tables/module registration as C# syntax trees, and compile to DLL.

Status: Implemented. The Phase 1 structural slice — scope, contract root_scope, and template<Contract> shells with hashes, declaration tables, and module registration — is complete, and the later batches recorded in the implementation-status note under "Current Lowering Baseline" have landed on top of it: fields, channels, channel modifiers, systems, events, activities, the backed FromActor policy subset, the backed expression slice, and the Phase-3a mod-operation merge all ship today (registry_only is accepted in contract bodies). Structured type metadata and the other items listed as future work in that note remain blocked behind later phases.

Phase 1 is intentionally not "template only". A template shell cannot be correct until the compiler has already bound the contract's root_scope, generated owner-and-signature ids for callable methods, and emitted the structural arrays the registry validates at startup.

  • Add or consume typed ID strong types from the v2 contract assemblies. The full set required by the modding/merge architecture (per docs/design/06-overrides-and-modding.md § 4.1) is: TemplateId, ModifierId, ChannelId, TagId, PhaseId, TickRateId, ScopeId, ContractId, SystemId, EventId, OnActionId, ActivityId, ActivityRunId, ActivityLaneId, PolicyId, DomainId, NeedId, SelectorId, RuleId, SlotKindId. All are readonly record structs wrapping ulong. The compiler and generated code must not allow accidental cross-type mixing. TemplateId is also the canonical key type for ScopedDictionary<TemplateId, T> (Phase 2).
  • Study how record was added — trace the full path: SyntaxKind, parser method, syntax node definition, lowering pass.
  • Add syntax for structural declarations:
    • SecsTypeDeclarationSyntax for C# struct, record, and enum declarations. Phase 1 must bind C# source type syntax recursively into SecsTypeRef metadata for scalar, scope entity, template reference, enum, struct, record, array, and supported collection forms; do not lower callable signatures through SecsValueKind.
    • ScopeDeclarationSyntax for scope, walks_to, fields, collection names, and scope-method signatures. Collections in Phase 1 are name-only compile-time members used for hashes and foreach binding only; typed ScopedList<T> / ScopedDictionary<TKey, T> declarations and CollectionDeclaration[] emission are Phase 2 work (see Step 3 of docs/design/08-collections-and-propagation.md).
    • ContractDeclarationSyntax for root_scope, lifecycle bindings, query / method signatures, and context profiles. The committed lifecycle source spelling for now is activation SomeMethod;; on_activate SomeMethod; is an open spelling alternative, not a Phase 1 parser target.
    • Top-level channel and field declaration syntax, enough to populate declaration metadata and hash ids.
  • Emit deterministic FNV-1a-64 H.* constants for every declared identifier. Hash inputs must be canonical identity strings of the form <namespace>:<kind>/<name> (e.g. valenar:template/farm, valenar:channel/population), not bare display names. This prevents unrelated mods from accidentally colliding on common names. FNV-1a-64 collision between two distinct canonical identities is diagnostic SECS0601. Per docs/design/06-overrides-and-modding.md § 4.2.
  • Run a structural binding pass before template lowering:
    • Every source type reference in fields, callable signatures, struct/record fields, array elements, collection generic arguments, template references, and scope entity references must resolve to a built-in type or SecsTypeDeclaration.
    • Every contract root_scope must resolve to a declared scope.
    • Every query / method signature must lower to a ContractMethodDeclaration row with SecsTypeRef return/parameter metadata and owner-and-signature hash. query may return structured types; method must return void.
    • Every contract method id referenced by ContractDeclaration.QueryMethodIds, MethodIds, or LifecycleBindings must have matching metadata. Lifecycle binding spelling is syntax only; emitted metadata always maps ContractLifecycleIds.Activation to the declared parameterless void method id.
    • Every scope method signature must lower to a ScopeMethodDeclaration row with return and parameter SecsTypeRefs.
    • Every context profile entry must resolve through the contract root scope and declared walks_to graph.
  • Emit and register in the lowered C# tree:
    • SecsTypeDeclaration[]
    • ScopeDeclaration[]
    • ScopeFieldDeclaration[]
    • ScopeMethodDeclaration[]
    • TemplateFieldDeclaration[]
    • ChannelDeclaration[]
    • ContractDeclaration[]
    • ContractMethodDeclaration[]
    • ContextProfileDeclaration[]
  • Add TemplateDeclarationSyntax and lower template<C> T { } for any declared contract C:
    • Reject undeclared contract names; do not assume Building, Location, or any Valenar contract/root names.
    • Static class T
    • TemplateEntry with TemplateId, Name, ContractId, RootScopeId, and an empty Activate(ScopeFrame scope, ISecsHostReads host, CommandBuffer cmds)
    • registry.RegisterTemplate(T.Id, T.Entry) in SecsModule.Initialize
  • Emit SecsModule.Initialize in the Phase 1 structural order listed above: scopes/fields/methods/channels first, then contracts/contract methods/context profiles, then template entries.
  • Verification: compile a small generic .secs source set with declared enum/record/struct types, a declared scope, a scope method signature, a contract with a structured-return query and lifecycle method signature, a context profile, and an empty template; inspect --emit-generated-cs output or the decompiled DLL for SecsTypeDeclaration[], ScopeFrame, TemplateEntry, ScopeMethodDeclaration[], ContractDeclaration[], ContractMethodDeclaration[] with SecsTypeRef, ContextProfileDeclaration[], and registration. Do not start broad golden-test infrastructure in this phase.

Phase 2: Template Members, Scope Frames, and Expressions

Goal: Lower the practical template surface on top of Phase 1 structure: fields, own-root channels, formula registration, contract query/method bodies, and scope-aware read/write expressions.

Status: Partial — the backed slice ships. Scalar template-field assignments with typed scalar accessors, scalar scope fields, walks_to edges, typed ScopedList<T> / ScopedDictionary<TKey, T> collections with generated lifecycle hooks (SECS0105 / SECS0822 / SECS0840 enforced), formula-backed channels with generated formula delegates and self / owner receiver reads, lifecycle contract methods, typed template queries, and template command bodies (backed statement subset) are implemented. Structured fields, string field values, template-intrinsic channel sources, effective template-value reads (template_field(...) context expansion), and broad expression lowering remain future work.

Valenar reference example:

template<Building> Farm
{
channel int FoodOutput
{
return (int)(5 * @Location.Fertility / 33);
}

field int GoldCost = 10;

bool CanBuild()
{
return @Settlement.Stage >= 1
&& @Settlement.Gold >= template_field(Farm, GoldCost, BuildCostContext);
}
}

Valenar's Building, Farm, Location, and Settlement names are examples only. The lowering is generic over whatever contract root scope and walks_to graph the source declares.

  • Add template-field assignments and typed scalar/structured accessors. Scalar fields use GetFieldInt / GetFieldLong / GetFieldFloat / GetFieldDouble / GetFieldBool; structured fields emit concrete immutable value accessors keyed by TemplateFieldDeclaration.ValueType = SecsTypeRef.*.
  • Add template-body channel declarations:
    • Static own-root channels lower to cmds.RegisterChannelSource(scope.Owner, scope.Root, ...) in Activate after compile-time scope validation.
    • Dynamic own-root channels lower to cmds.RegisterDynamicChannelSource(scope.Owner, scope.Root, ...), a formula delegate, registry.RegisterFormula(...), and registry.RegisterFormulaContribution(...).
    • Cross-scope channel effects are rejected as template channels and must be expressed as named modifiers.
  • Add ScopeFrame-based lowering for contract query and method bodies:
    • Queries return their declared concrete type through typed query registrations/adapters; erased dynamic dispatch validates against SecsTypeRef.
    • Void methods use TemplateCommand<TArgs> / TemplateCommandDelegate<TArgs> and ITemplateCommandContext.
    • Method/query ids are looked up through ContractMethodDeclaration[], not hard-coded names.
    • Implementing a query or method that is not declared by the template's contract is a compiler error; omitting an implementation follows the current runtime default/no-op behavior.
  • Add scope sigils and expression lowering:
    • @SomeScope.SomeField -> declared scope walk from the current frame, then a typed host read using the generated scope/field hash constants when a walk is required.
    • @SomeScope.resolve(SomeChannel) -> declared scope walk from the current frame, then typed channel resolution.
    • Read-only scope methods -> generated typed query facades over ISecsHostReads.CallScopeQuery<TArgs,TResult>.
    • template_field(...) -> base/effective template-field reads with explicit context chain or ContextProfileDeclaration expansion.
  • Add command-producing expression lowering in template contract methods (increment, add_modifier, void scope methods) through ITemplateCommandContext.Commands; void scope methods use generated typed command facades over the active command context. Emit context.FlushCommands() after each command-producing source statement.
  • Add the feature-placement typed query pattern as the structured-data smoke test: field FeaturePlacementProfile Placement = new(...) plus query FeaturePlacementResult EvaluatePlacement(Location candidate, FeaturePlacementInput input). The compiler must keep this query read-only; global budgets, spacing, uniqueness, and conflicts are applied by generated/host systems after collecting FeaturePlacementResult values.
  • Bind tag identities from plain C# static readonly TagId values such as public static readonly TagId Food = TagId.Create("valenar:tag/food");, and add tags = T1, T2; template/activity-body syntax plus policy selector from tags = T1, T2 binding. Top-level tag <Name>; is rejected per 06-overrides-and-modding.md § 19.4 Tag identities stay plain C#; there is no TagDeclaration[] array. Generated output must register collected tags with registry.RegisterTag(id, name) so structural predicates, validation, and tooling share one registry-backed tag catalog. Lower tags = ... inside a template body to the Tags initializer on the generated TemplateEntry; lower tags = ... inside an activity body to SecsActivity.Tags; lower consider activities from tags = ... to new SelectorSource.FromTags(new[] { H.Tag_* }). Emit/analyze unknown tag references against the registered catalog. Emit the three structural predicate operators (has_tag, has_template, has_contract) as (FilterKind, hash) pairs usable in propagates_to where clauses (Phase 4) and aggregate channel Children.Where(...) expressions (Phase 4). Current Valenar provenance covers template tags only; executable Valenar provenance for activity tags and policy from tags is deferred to a scoped tag-fixture wave that lands .secs, Generated, and tests together. Live Valenar fixtures for propagation has_tag and aggregate .Where(has_tag ...) also remain deferred until a real gameplay use exists, with generic runtime tests covering the APIs meanwhile. Undeclared tag diagnostics (SECS0212) are part of the same binding pass. Short tag aliases may resolve only when exactly one declared tag in the merged catalog exports that alias; if multiple tags share the alias, the compiler must reject the use with a dedicated future/reserved tag-alias ambiguity diagnostic (diagnostic number intentionally unassigned until alias binding is committed). Do not implement short-alias binding until that ambiguity behavior and diagnostic routing are committed.
  • Add typed collection declarations in scope bodies. Replace the Phase 1 name-only collection placeholder with ScopedList<T> and ScopedDictionary<TKey, T> field syntax, lowering each to a CollectionDeclaration entry (ParentScopeId, FieldHash, Kind, KeyType, ElementScopeId). Add CollectionDeclaration[] registration to SecsModule.Initialize. Emit H.<ParentScope>_<FieldName> hash constants for the anonymous collection scope identity. Add the GetChildByTemplate / WalkChildren bridge contract to ISecsHostReads as the host-side iteration surface. Verify ScopedDictionary<TemplateId, T> key-type checking (TemplateId from Step 1) and reject ScopedList<T> indexed access with SECS0822. Closes Open Question #5 — collection Name of ScopeType is superseded by typed ScopedList<T> / ScopedDictionary<TKey, T>.
  • Auto-generate anonymous-collection lifecycle hook method ids. For each ScopedList<T> / ScopedDictionary<TKey, T> field declared on a scope, emit On{FieldName}Added(T child) and On{FieldName}Removed(T child) as ContractMethodDeclaration entries on the parent scope's contract. If the contract does not declare matching method bodies, the engine silently skips dispatch — no warning. Emit SECS0840 when two collection fields on the same scope produce colliding hook names.

Phase 3: Mod Operation & Merger

Authority: docs/design/06-overrides-and-modding.md is the authoritative design document for everything in this phase. When this plan and that doc conflict, the doc wins.

Goal: Parse the six mod operation keywords, build the slot-based merge pipeline, and emit a single merged AST that the Phase 1/2 binder/lowerer sees as one compiled game.

Status: Phase-3a slice shipped. SecsDeclarationMerger (secs-roslyn/src/Compilers/CSharp/Portable/SECS/Binding/SecsDeclarationMerger.cs) runs the compile-time source-set merge between parsing and binding: it applies mod operations against a declaration catalog in load order across the ordered base / expansion / mod source sets and hands the existing binder a merged declaration array in which no mod-operation wrapper survives. In the shipped slice, inject / try inject / inject_or_create patch scalar template_field value slots on templates, and replace / try replace / replace_or_create substitute whole declarations of any currently-backed kind (scope, contract, template, channel, modifier, system, on_action, event, activity, policy) while validating per-kind required slots (SECS0611). Missing targets raise SECS0603 for inject / replace, no-op for the try forms (warn-only SECS0620 under --strict-mod-ops), and create for the _or_create forms. Forms outside this surface are rejected at parse time with SECS0033. Coverage: SecsDeclarationMergerTests (52 tests) in the fork plus the Content/ModMerge packages and phase3a-* fixtures in tests/SECS.Compiler.Smoke. Remaining Phase 3 work: per-slot inject beyond scalar template fields against the full slot schema in 06 § 8 (06 § 8.10 enumerates 25 activity slots, 06 § 8.11 enumerates 6 policy slots), conflict-report.json emission, and --strict-conflicts.

The old single override Name { ... } keyword is replaced by explicit database operations per 06 § 3.

Operation syntax

Six operations are parsed; create is implicit (a normal declaration):

inject template Farm { field GoldCost = 8; }
replace template Farm { ... }
try inject template Farm { ... }
try replace system OldGrowthSystem { ... }
inject_or_create modifier GrainMarketBonus { ... }
replace_or_create template<Building> Lighthouse { ... }

ModOperationDeclarationSyntax wraps any of these six prefixes around a standard SECS declaration syntax node, carrying ModOperationKind and the inner declaration node.

SlotKey structure

Do not combine declaration and slot hashes with XOR. Use the structured key from 06 § 7.3:

public readonly record struct ModSlotKey(
DeclarationKind DeclarationKind,
ulong DeclarationId,
int SlotKind, // backed by the ActivitySlotKind / PolicySlotKind enums; cast to int for cross-kind storage
ulong ChildId); // 0UL when the slot is whole-declaration; non-zero when keying per-row (NeedId, RuleId, cost id, etc.)

Examples: (Activity, H.ActivityScoutNearbyLead, (int)ActivitySlotKind.Duration, 0UL), (Policy, H.PolicyCharacterSurvival, (int)PolicySlotKind.Needs, H.NeedCharacterSurvivalStayAlive). The compiler emits these constants with the kind as a name prefix and no underscore separator (H.PolicyCharacterSurvival from valenar:policy/CharacterSurvival, H.NeedCharacterSurvivalStayAlive from need:valenar:policy/CharacterSurvival.StayAlive); see docs/decisions/ADR-0003.

Implementation pipeline (Steps 1-7, per 06 § 16)

This is the core differentiator. The merger runs seven steps:

  1. Parse source sets independently. Each source set (base, expansion, each mod in load order) is parsed to its own syntax tree and source-set-local diagnostics. No cross-set dependencies at parse time.
  2. Build declaration catalog. Walk all source trees; record each declaration's kind, name, canonical id (<namespace>:<kind>/<Name>, the declared name reproduced verbatim in source PascalCase — see docs/decisions/ADR-0003), typed id, source set, load order, file path, source span, declaration AST node, and slot schema.
  3. Extract semantic slots. Each declaration kind uses its schema (per 06 § 8). A system yields (system_phase, null), (system_frequency, null), (system_execute_body, null). A template yields one template_field slot per field, one template_dynamic_channel_formula per dynamic channel, one template_query_method per query, etc. The full slot table is in 06 § 8.1-8.8 — reference that doc; do not duplicate it here.
  4. Apply operations in load order. For each source set in resolved load order: create → SECS0602 if identity exists; inject → write listed slots (SECS0603 if target missing); replace → replace full declaration (SECS0603 if target missing); try inject / try replace → silent no-op if target missing; inject_or_create / replace_or_create → inject/replace if exists, create if not. Every slot write is appended to slot history. The current value is the last write.
  5. Produce merged AST / merged IR. The merged result retains source provenance per slot: source-set id, load order, source path, source span, syntax node payload, typed semantic payload. Type errors in mod slot bodies must point to the mod's source line, not generated code. This is the primary benefit of the Roslyn fork.
  6. Bind and validate merged result. Run normal C#/SECS binding after merge. Catches unknown identifiers, type mismatches, invalid channel operations, illegal scope walks, bad contract methods, dependency cycles, read-set errors, and all diagnostics in 06 § 12.
  7. Lower once. The lowerer sees one merged world and emits the .g.cs family that today's secsc already writes under obj/**/SecsGenerated (Hashes.g.cs, Scopes.g.cs, Contracts.g.cs, Templates.g.cs, Channels.g.cs, Modifiers.g.cs, Formulas.g.cs, Systems.g.cs, Events.g.cs, Activities.g.cs, Policies.g.cs, SecsModule.g.cs, and the rest of the family listed in tools/secsc/README.md), plus Game.Merged.dll and conflict-report.json (conflict-report.json emission is future work and is not written today).

Slot schema

Every declaration kind has a schema (per 06 § 8) defining: allowed members, slot identifiers, cardinality, base/inject/replace requirements, merge rule, diagnostics, lowering target, and conflict-report kind. Adding a new language feature should mean adding a new schema entry, not writing one-off merge code.

Conflict detection

A slot is conflicted when 2+ mods write the same SlotKey. One mod writing is clean. Two or more mods on the same slot = load order matters; the shipped merger raises the SECS0800 warning, and conflict-report.json emission remains future work. Conflicts are not compiler errors by default; --strict-conflicts (future) turns them into errors. Strict modes per 06 § 13.

Re-merge on reorder: Syntax trees are already parsed. Re-walk the operation list in new load order. No re-parsing. Fast enough for interactive mod manager UI.

Canonical diagnostics

Diagnostics SECS0601SECS0616 are defined in 06 § 12. The merger must emit these at the source line that caused each diagnostic.

Architectural-slot rejection (replace-only slots)

Four activity slots — activity_actor_scope, activity_target_scope, activity_lane, and activity_args_schema — and two policy slots (policy_actor_scope, policy_domain) are replace-only per 06 § 8.10 / § 8.11. The shipped Phase-3a slice cannot violate this: inject against any non-template declaration kind is rejected at parse time with SECS0033, so no per-slot activity/policy inject exists yet. When per-slot activity/policy inject lands, the parser must reject inject against these slots at parse time so the user sees the error pinned to the offending source line. Reserved diagnostic id for that parser-side rejection:

  • SECS0420 (error) — inject activity X { actor ... | target ... | lane ... | (typed-args header) } cannot set replace-only architectural slots activity_actor_scope / activity_target_scope / activity_lane / activity_args_schema. Use replace activity to change architectural identity. Sub-cluster SECS0420 is reserved here so it does not collide with the reserved activity static-analysis band at SECS0410-0415.

The activity-args schema slot lowers from typed-args activities (activity Build(BuildStructureArgs args) { ... }) to an ExpectedArgsSchemaId override on the generated SecsActivity subclass. Untyped activities omit the override and inherit the runtime default 0UL ("accept any blob"); see Phase 4 below.

Structural merge rules

Host-capable source sets (base game plus official expansions/DLC that ship matching host code) may add new scopes, scope fields, collections, scope methods, and walks_to edges. Third-party mods are data-only by default: they consume the exported host-capable scope graph and cannot add host-backed structural surfaces (SECS0609). Contracts are closed surfaces; existing contract method lists, lifecycle bindings, and root_scope are not mod-extended (06 § 8.7). Types are additive but closed under inject/replace for existing exported types (06 § 8.8).

Signature stability

Scope and contract callable ids are regenerated from canonical owner+signature text after merge. Body replacements keep the same method id; additions of new scope methods or new contracts get new ids. Do not emit name-only method hashes.

Expansion/mod boundary

Official expansions are processed as part of the effective base layer before third-party mods and may update both host code and .secs declarations. Third-party mods do not ship host bridge DLLs under the committed runtime model.

Raw C# boundary

Phase 3 is committed for SECS constructs and mod-added SECS declarations. override class is not a committed Phase 3 feature; per 06 § 10 (open question), raw C# class override semantics are unsettled. Do not implement or depend on it here.

Phase 4: Modifiers, Systems, Events, On-Actions, Activities, and Policies

Goal: Complete the committed SECS syntax surface that executes during ticks or dispatches.

Status: Partial — the backed behavior surface ships. The fork emitter (secs-roslyn/src/Compilers/CSharp/Portable/SECS/Emit/SecsCSharpEmitter.cs) lowers channel modifiers (including propagates_to structural filters), formula delegates, systems, on_actions, pulse and on_action events with event options, activities (including typed activity args), and the backed FromActor policy subset; the live Valenar Generated/ output is emitted through these paths. Non-FromActor policy selectors, unsupported policy decisions, template-field modifier effects, and broad behavior-body expression coverage remain future work.

Contracts are no longer a Phase 4 afterthought; the structural subset lands in Phase 1 because templates depend on it. Phase 4 adds the remaining behavior constructs that consume those declarations. Activities (Wave 1, replacing the prior action keyword) and policies (Wave 4) ship in this phase alongside the older modifier/system/event/on-action surfaces. Their runtime targets — SecsActivity, EffectPlan, IActivityArgs<TSelf>, SecsPolicy, NeedTargetExpr, RuleDecision — are already shipped and exercised by hand-written stand-ins; Phase 4 emits the same shape from .secs source.

  • ModifierDeclarationSyntax -> ModifierDeclaration[] with channel/template-field ModifierEffect[], Stacking, MaxStacks, ReApplyMode, Decay, tags, dynamic formula effects, and triggers. Emit the PropagationClause field when propagates_to children [where ...] is present: PropagationMode, PropagationFilter, FilterTags. The shipped binder rejects non-structural propagates_to where predicates (anything other than has_tag, has_template, has_contract) with SECS0801 (UnsupportedBehaviorExpression). Virtual binding materialization in ModifierBindingStore follows the five propagation rules in docs/design/08-collections-and-propagation.md § Part 2: owner preservation (Rule 1), stacking keyed by (modifier_id, owner_id) on the collection (Rule 2), lifecycle mirroring the source binding (Rule 3), structural-only where predicates (Rule 4), and HardOverride closer-scope-wins priority (Rule 5).
  • Add AggregateChannelSource as a new channel source kind. In top-level channel declarations addressed on Parent.FieldName, lower source = Children.Sum/Min/Max/Average/Count/Where(...) expressions to AggregateChannelSource metadata in ChannelDeclaration. Register DirtySet dependencies on child channel changes and on child join/leave events so that aggregate channel cache entries are invalidated when collection membership changes. Only structural predicates (has_tag, has_template, has_contract) are valid in aggregate .Where(...); channel-based and arbitrary lambda predicates are rejected.
  • Add track_prev = true as a ChannelDeclaration property. When set, the engine snapshots the resolved channel value at end-of-tick into PrevTickSnapshotStore. The committed runtime/generated read shape is the host bridge ReadPrevTick* family; standalone prev_tick(...) syntax and a committed ctx.PrevTick(...) lowering are not part of the source contract. Reserve SECS0830 for the future binder/analyzer check that rejects prev-tick bridge/API reads on channels whose declarations do not have track_prev = true.
  • SystemDeclarationSyntax -> class implementing ITickSystem, static SystemRegistration, slot recognition for phase = X; and frequency = Y; inside the system body, load-distributed AllByContractForTick for distributed frequencies, and statement-level ctx.FlushCommands() after command-producing source statements. Top-level phase and cadence values are plain C# helper classes, e.g. Phases.Growth = PhaseDeclaration.Create(...) and Cadence.Daily = TickRate.Days(1), not SECS declarations. The compiler type-checks phase = ...; as a PhaseDeclaration expression and frequency = ...; as a TickRate expression, then emits the resolved SystemRegistration values.
  • Add the registry[id] ambient accessor. In system Execute bodies and formula bodies, lower registry[expr] to context.Registry.GetTemplate(expr). Return type is TemplateEntry? (nullable). Emit a compile error when registry[id] is used outside a context that has TickContext available (e.g., modifier effect bodies run in the resolution path, not the system path). Contract-typed uses (registry[recipeId] where the declared type is TemplateEntry<Recipe>) allow field access validated against the template's contract.
  • SecsEventDeclarationSyntax -> sealed class deriving from SecsEvent, with Condition(EventQueryContext) (read-only), Execute(EventContext) (command-producing), optional BuildOptions(EventOptions options, EventQueryContext context) (read-only), option handler methods taking EventContext (command-producing), and static EventEntry Registration. The read-only / command-producing context split is structurally enforced post-Wave C; the compiler MUST emit EventQueryContext for Condition and BuildOptions, never EventContext. The pre-Wave-C single-EventContext shape is rejected. Rejected stale shapes: interface-based events and the contextless one-parameter options builder.
  • OnActionDeclarationSyntax -> OnActionDeclaration metadata only: OnActionId, Name, ScopeId, ProvidedScopes, and SelectionMode. Source-authored on-action effect bodies, fallback, and chain syntax are not committed; guaranteed effects lower as ordinary subscriber events with priority. Reserved runtime fallback/chaining fields stay inert metadata and must not become live compiler-emitted dispatch behavior until the syntax and mod semantics are recommitted.
  • SecsActivityDeclarationSyntax (Wave 1, replacing the prior legacy behavior declaration type) -> class deriving from SecsActivity, using ActivityQueryContext for read-only methods (CanStart, IsTargetValid, Preview) and ActivityRunContext for command-producing lifecycle methods (OnStart, OnUpdate, OnComplete, OnStop, OnCancel, OnFail). The pre-Wave-C ActivityContext type is deleted; compiler emission MUST use ActivityQueryContext for the read-only methods (constructed runtime-side via the ActivityExecutor.CreateQueryContext factory) and MUST NOT emit references to the deleted type. Typed activity args declared as record struct implementing IActivityArgs<TSelf> (Wave 3) and serialized via ActivityArgsBlob(SchemaId, Payload) with hex/base64 wire shape. When the source declares typed activity args (e.g. activity Build(BuildStructureArgs args) { ... }), the compiler MUST emit an ExpectedArgsSchemaId override returning default(BuildStructureArgs).SchemaId so the executor's boundary schema check (Wave 2 of the Behavior Refactor) rejects mismatched blobs before any lifecycle hook runs. Untyped activities omit the override and inherit the default 0UL ("accept any blob"). The compiler MUST also emit the activity's LaneOccupiedPolicy override only when the source declares a non-default lane-occupied policy; the runtime default is ActivityLanePolicy.Reject and only Reject is currently implemented — Queue and Preempt throw NotImplementedException at start time per the no-silent-fallback tenet. Preview returns EffectPlan of PredictedEffect rows (Wave 2) carrying optional ModifierId (Wave 7) for PredictionOp.Modify projection. Note: deleted per-activity AI-weight hooks stay removed — utility-AI scoring is handled by PolicyExecutor against EffectPlans. Old allow/effect block syntax is prototype-only and must be rejected unless the design docs recommit it.

Hand-stand-in batch wrappers. The compiler may emit a single wrapper class that delegates to data-driven definition records rather than one SecsActivity subclass per activity for content-heavy families with shared body shape. Both shapes must be supported by the runtime registry — SecsActivity is duck-typed by ActivityId, not by class identity.

  • SecsPolicyDeclarationSyntax (Wave 4) -> class deriving from SecsPolicy with Needs : IReadOnlyList<NeedDeclaration>, Selectors : IReadOnlyList<SelectorDeclaration>, Rules : IReadOnlyList<RuleDeclaration>, and EvaluateRule(RuleId, PolicyQueryContext) -> RuleDecision dispatching by rule id. The pre-Wave-C PolicyRunContext type is deleted; compiler emission MUST use PolicyQueryContext (constructed runtime-side via the PolicyExecutor.CreatePolicyQueryContext factory) and MUST NOT emit references to the deleted type. NeedDeclaration carries (NeedId, Name, ChannelId, NeedTargetExpr Target, NeedCurveKind Curve, Threshold, Weight); NeedTargetExpr is a discriminated union (Literal | ChannelRef | Formula) — Formula evaluates at scoring time via Registry.GetFormulaInt (Wave 7). SelectorSource is a discriminated union (AllRegistered | FromTags | FromCollection(SlotKindId Slot, CandidateBuilderId Builder)); FromCollection is the runtime/lowering form used when a selector is backed by stored definition references in ScopeSlotStore, and the bridge keyed by Builder reads those references and emits typed-args ActivityRequest values. This is compiler/runtime support rather than standalone source vocabulary. RuleDecision is a discriminated union of seven cases: Continue / Complete / Fail / Wait / Call(activityId, target, args) / CallBest(selectorId) / CancelChild.
  • foreach entity in Contract -> ctx.InstanceStore.AllByContract(H.ContractId) or AllByContractForTick(...).
  • foreach child in parent.Collection -> ctx.Host.GetChildren(parent, H.CollectionId) + indexed loop.
  • increment, add_modifier, and void scope-method calls -> command lowering plus statement-level flushes in system/event/activity bodies; template contract methods already use the ITemplateCommandContext rule from Phase 2. save_scope_as and fire on_action -> saved-scope / dispatcher lowering with provided-scope validation.
  • remove_modifier remains a deferred source keyword even though runtime helpers exist; do not make it part of Phase 4 unless docs/design/05-expressions.md is updated first.
  • event disambiguation: SECS event at declaration level, C# event inside ordinary class bodies.
  • Reserved diagnostic codes for removed legacy keywords (Wave 1 of the SECS Behavior Refactor): Parser-level errors. Lex/parse phase. Distinct from the reserved registry static-analysis bands SECS0410-SECS0415 (activity warnings) and SECS0910-SECS0914 (policy warnings). The reservation is locked here so the codes do not collide with future Phase 4 diagnostics.
    • SECS0030 (error) — removed legacy activity keyword. Fires on removed activity-keyword declarations and matching removed slot identifiers in mod operations. Replacement guidance is in docs/design/04-behavior.md § Activities and docs/decisions/ADR-0002-behavior-vocabulary.md.
    • SECS0031 (error) — removed legacy policy keyword. Fires on removed policy-keyword declarations. Replacement guidance is in docs/design/04-behavior.md § Policies and docs/design/09-ai-policies-and-activities.md.

Phase 4 verification should use narrow behavior smoke sources rather than broad golden suites: one pulse event, one on-action subscriber event with a provided saved scope, one metadata-only on-action declaration, one class-style activity that calls a void scope method and flushes, and one minimal policy with a need + selector + rule. Inspect lowered C# or decompiled output for SecsEvent, EventEntry, BuildOptions(EventOptions options, EventQueryContext context), OnActionDeclaration[], SecsActivity, ActivityQueryContext, ActivityRunContext, EffectPlan with PredictedEffect rows, IActivityArgs<TSelf> typed arg structs, SecsPolicy with NeedDeclaration / SelectorDeclaration / RuleDeclaration arrays and an EvaluateRule(RuleId, PolicyQueryContext) dispatch, and explicit FlushCommands() after command-producing source statements. Verify that the deleted pre-Wave-C types ActivityContext and PolicyRunContext do not appear in any lowered output.

Phase 5: secsc CLI Roadmap

Goal: Single binary wrapping the forked compiler with SECS-specific commands.

The implemented command surface is secsc check and secsc build over ordered content packages: exactly one --base <dir>, repeatable --expansion <dir> and --mod <dir>, --order <id,id,...> (re-orders only the mod sets by manifest package id), --strict-mod-ops, --out <dll> (required for build), --emit-generated-cs <dir>, --generated-namespace <ns>, --reference / -r <dll>, --csc <path>, and --toolchain-root <dir>. tools/secsc/README.md documents the full flag semantics. The table below is the later CLI roadmap, not the implemented command surface.

CommandWhat it does
secsc build --base Content/ --out Game.Content.dllParse → merge → lower → compile → Game.Content.dll (implemented)
secsc build --base Content/ --mod A --mod B --order A,B --out Game.Merged.dllParse → merge in order → lower → compile → Game.Merged.dll (implemented)
secsc validate --base Content/ --mod A --mod BParse → merge → binder diagnostics → report (no DLL)
secsc conflicts --base Content/ --mod A --mod B --order A,BParse → merge → conflict report JSON only
secsc calc --template <template> --scope <scope>.<field>=<value> --resolve <channel>Load engine, register template, seed mock scope fields, resolve a channel
secsc new "ModName" --game-path /pathScaffold mod project
secsc pack --mod-path ./ModNamePackage mod into .secsmod archive
secsc export-sdk --game-path /pathExtract ModSDK (base .secs source, definitions.json, tools)
secsc lspStart language server
secsc watch --base Content/File watcher, re-validates on save

Ships as a RID-scoped tool bundle — no .NET SDK required on the target machine. The manifest beside secsc is the authority for the matched Roslyn driver, target reference pack, SECS reference assemblies, target framework, RID, and compiler identity.

Phase 6: LSP & VS Code Extension

Goal: First-class editor experience for .secs files.

Built on Roslyn's existing Workspace and LSP infrastructure inside the fork.

LSP server (secsc lsp):

  • Diagnostics (error squiggles from full compiler pipeline)
  • IntelliSense for channel names, template names, scope paths, modifier names
  • Go-to-definition: jump from an inject / replace operation to the target base game definition
  • Hover info: show the base and winning slot payload when hovering a mod operation
  • Find all references: which mods write the same declaration or slot
  • Semantic highlighting for SECS keywords

VS Code extension:

  • TextMate grammar extending C# grammar with SECS keywords (syntax highlighting without LSP)
  • LSP client connecting to secsc lsp
  • Packaged as .vsix, included in game's mod tools
  • Standard LSP — works with any editor (JetBrains, Vim, Emacs)

Phase 7: Mod Manager UI

Goal: Player-facing conflict resolution tool.

  • Consumes secsc conflicts JSON output
  • Drag-to-reorder mod list → re-runs merge with new order (instant, no re-parsing)
  • Per-slot diff: base value vs each mod's version, winner highlighted
  • "Create Patch" button: scaffolds compatibility mod
  • Technology: Avalonia (cross-platform .NET desktop) or the game developer builds their own UI consuming the JSON
  • Can be embedded in game launcher or run standalone

Phase 8: Runtime Script Console

Goal: Paradox-style runtime console — evaluate SECS expressions against live game state.

// Example session using Valenar names.
> @Location.Fertility
33

> template_field(Farm, GoldCost, BuildCostContext)
10

> foreach entity in Settlement { print entity.resolve(Population) }
Entity#1: 42

> inspect Farm
FoodOutput: 5 (dynamic: 5 * @Location.Fertility / 33)
GoldCost: 10 (field)
...

Uses Roslyn's CSharpScript API. The console parses SECS expressions, lowers them (same lowering pass the compiler uses), and feeds the lowered C# to CSharpScript.EvaluateAsync() with the live game registry and an explicit ScopeFrame / TickContext as globals.

SECS provides the evaluation engine; the game developer provides the UI shell (ImGui overlay, in-game terminal, etc.).


Compilation Flow

Current command contract:

secsc check --base <BaseDir> [--expansion <Dir> ...] [--mod <Dir> ...] [--order <id,id,...>] [--strict-mod-ops] [--toolchain-root <dir>]
secsc build --base <BaseDir> [--expansion <Dir> ...] [--mod <Dir> ...] [--order <id,id,...>] [--strict-mod-ops] [--generated-namespace <Namespace>] --out <path>/Content.dll [--emit-generated-cs <path>/Generated] [--toolchain-root <dir>]

The current package, cache, generated-output, and runtime bundle layout is documented in tools/secsc/README.md. The broader command examples below are target architecture for later compiler/tooling phases.

Game Developer (no mods)

secsc build --base Content/ --out Game.Content.dll

├── Parse all .secs files → AST
├── Structural prepass → scopes, contracts, method signatures, hashes
├── Validate (references, types, scope walks, cycles, callable signatures)
├── Lower SECS nodes → ordinary C# SyntaxTrees (matching current Generated/ pattern)
├── Add SecsModule.Initialize + Systems[] + Events[] + Activities[] + Policies[] to the lowered trees
├── Roslyn binder + emitter → IL
└── Output: Game.Content.dll

Normal builds do not need to materialize .cs files. secsc build ... --emit-generated-cs <dir> is a diagnostic/review mode that writes the lowered C# trees to disk so the output can be inspected against the current generated-output contract.

Player with Mods

Game Launcher

├── 1. Discover mods (read mod.json manifests)
├── 2. Resolve load order (dependencies, before/after constraints)

├── 3. Validate + detect conflicts (fast, no compilation)
│ secsc validate --base Content/ --mod A --mod B --order A,B
│ Parse ALL .secs → structural prepass → merge per-slot → conflict report
│ Player sees conflict grid, adjusts load order

├── 4. Compile merged result
│ secsc build --base Content/ --mod A --mod B --order A,B --out Game.Merged.dll
│ Merge ALL .secs into ONE AST → recompute structural ids → lower to C# SyntaxTrees → compile → Game.Merged.dll

└── 5. Launch game
Game loads Game.Merged.dll
SecsModule.Initialize(registry) — one call, fully merged
Game runs — zero mod awareness at runtime

What Ships with the Game

Game/
├── game.exe
├── Game.Content.dll compiled base content
├── SECS.Runtime.dll runtime coordinator (netstandard2.1)
├── SECS.Core.dll shared types (netstandard2.1)
├── SECS.Schema.dll schema contract (netstandard2.1)

├── Content/
│ └── base/ source .secs files (modder reference)
│ ├── common/ (channels, modifiers, scopes, contracts)
│ ├── templates/
│ ├── systems/
│ ├── events/
│ ├── on_actions/
│ ├── activities/
│ ├── policies/
│ └── mods/

├── Tools/
│ ├── secsc/ compiler tool bundle (self-contained, no SDK needed)
│ │ ├── secsc
│ │ ├── secsc.bundle.json
│ │ ├── compiler/
│ │ └── refs/
│ ├── secs-lang.vsix VS Code extension
│ └── definitions.json machine-readable catalog for tooling

├── ModSDK/
│ ├── mod-template/ scaffold project
│ └── MODDING.md game-specific modding guide

└── Mods/
└── (player-installed mod folders with .secs source)

Namespace Rules (Compiler-Enforced)

SECS Constructs

  • Flat global registry. template<SomeContract> SomeTemplate is SomeTemplate everywhere.
  • Identity is the canonical string <namespace>:<kind>/<name> (e.g. valenar:template/farm), hashed to ulong with FNV-1a-64. This prevents accidental collisions on common names across mods.
  • inject template Farm / replace template Farm match by canonical identity, not by C# namespace. Short names are resolved through the exported SDK/identity table; ambiguous short names are SECS0610.
  • No namespace qualifier is needed or allowed on bare SECS construct declarations.

Raw C# Code in .secs Files

  • Mandatory namespace isolation.
  • Base game: developer declares their own namespace (MyGame.Helpers).
  • Mods: compiler auto-prefixes with mod name if no namespace declared.
    • class Helper in ExampleMod -> ExampleMod.Helper
  • Prevents collisions between mods that happen to use the same class names.

Raw C# Class Replacement

  • Deferred/open. docs/design/06-overrides-and-modding.md explicitly leaves namespace-scoped override class Helper semantics unsettled.
  • Phase 3 is committed only for SECS construct operations (inject, replace, and the four variants). Raw C# helpers are namespace-isolated and compiled after the SECS merge; replacing raw C# class bodies is future design work.
  • Do not use raw C# class override as evidence for the committed SECS mod-operation model or as a Phase 3 deliverable.

Mod Manifest Format

{
"id": "example_mod",
"version": "1.2.0",
"name": "Example Mod",
"author": "ModderName",
"game_version": ">=1.0.0",
"dependencies": [],
"load_after": ["example_dependency"],
"load_before": [],
"tags": ["balance", "buildings"],
"content_roots": ["Content/"]
}

Risk Mitigations

RiskMitigation
Roslyn build is complex / slowDocument build steps in Phase 0. CI caches. Build only the C# compiler project, not full Roslyn solution.
Upstream Roslyn changes break forkMerge upstream quarterly, deliberately. Track release notes.
SECS syntax evolves during developmentEach keyword is isolated — changing channel syntax doesn't affect system parsing.
.secs files with broken C# in method bodiesRoslyn handles natively — diagnostics point at exact line in .secs file.
Large Roslyn submoduleFull clone is ~3GB. One-time cost. Needed for tracing record implementation.
event keyword collision with C#Context-based disambiguation: SECS event at declaration level, C# event inside class bodies. Same pattern as record.

Resolved Decisions

  1. File extension: .secs
  2. Compiler target: the Roslyn fork currently builds with the .NET SDK pinned by secs-roslyn/global.json (10.0.108 after the 2026-05-31 upstream sync). Engine targets netstandard2.1. Compiler output targets whatever the game targets (configurable).
  3. Hot reload: Not needed. Game requires restart after recompilation.
  4. Runtime console: Yes (Phase 8). Uses Roslyn's CSharpScript API with SECS lowering.
  5. Mod-operation merge: Compiler pass (pre-binding). Merge-first, compile-once.
  6. Conflict detection: Byproduct of merge pass. Structured JSON output consumed by mod manager UI.
  7. No runtime parsing: Mods ship .secs source to the compiler/launcher, not independent runtime DLLs. The compiler merges base content plus enabled mods in load order, lowers the merged tree, and emits one Game.Merged.dll that the game loads with no .secs parsing and no per-mod DLL awareness at runtime.
  8. Host extension boundary: base game and official expansions are host-capable source sets; third-party mods are data-only unless a future explicit mod-state/host-extension mechanism is designed.
  9. secsc distribution: RID-scoped tool bundle with secsc.bundle.json, bundled Roslyn driver, target reference assemblies, and SECS reference assemblies. Repository-local secsc.dll invocations pass --toolchain-root explicitly.

Open Questions

Items in this section are explicit non-goals for the phase checklists above until their home design docs settle them.

  1. Namespace isolation for SECS constructs: Currently flat/global (Paradox-style). May revisit if collision becomes a problem at scale.
  2. Unity integration: For Unity consumers, assembly loading and .asmdef considerations exist but are not the primary target.
  3. Lifecycle binding spelling: activation SomeMethod; vs a possible on_activate SomeMethod; is still open in docs/design/01-world-shape.md; Valenar's activation OnBuilt; is only an example. The emitted shape is settled: ContractLifecycleIds.Activation -> H.Contract_<Contract>_<Method>_....
  4. Collection declaration typing: Resolved. docs/design/08-collections-and-propagation.md supersedes the bare collection keyword with typed ScopedList<T> and ScopedDictionary<TKey, T> field syntax in scope bodies, lowering to CollectionDeclaration[]. Implemented in Phase 2.
  5. Bare template defaults: BareLocation's default Slots = 4 lacks a source declaration; see docs/design/02-templates.md. The compiler must not invent hidden Valenar defaults.
  6. Event option descriptions/localization: Generated event choices have descriptions by hand today; docs/design/04-behavior.md leaves the source/localization convention open.
  7. Activity cost metadata: Class-style activities (Wave 1 replacement for the removed behavior keyword) are committed, but high-level inspectable cost metadata is open in docs/design/04-behavior.md.
  8. Contract-call helper names: docs/design/05-expressions.md marks helper names such as contract_query<T> / contract_method as target spellings, not final API names. The ABI shape is committed: template id, callable id, ScopeFrame, args, query read boundary, and template command context.
  9. Raw C# class overrides: docs/design/06-overrides-and-modding.md leaves override class Helper open. Until that design is settled, the compiler plan commits only to namespace isolation for raw C# helpers and the six SECS mod operations (inject, replace, try inject, try replace, inject_or_create, replace_or_create) for SECS constructs.
  10. Engine-owned mod state: There is no committed generic storage system that lets data-only mods add persistent per-entity fields, collections, walks, or host methods without host code. If this is desired later, design it explicitly instead of treating host-backed scope declarations as magically moddable.
  11. remove_modifier source keyword: Runtime helpers exist, but the .secs keyword is not committed; see docs/design/05-expressions.md. Do not include it in compiler phase scope until that doc changes.