Skip to main content

01 — World Shape

A SECS world is a graph of entities bound to contracts that live on scopes with fields, channels, and typed value declarations. Before any template, modifier, system, or method body can be parsed, that skeleton has to exist — the compiler has to know what a settlement is, what fields a territory carries, what a Building contract means, which identifiers refer to channel values, and which identifiers name value types such as FeaturePlacementProfile. This document covers the structural declarations that form that skeleton: scopes, scope fields, contracts, top-level fields, type declarations, and top-level channel declarations. Everything downstream (structured template data, per-instance template channels, modifier effect bindings, scope.field reads inside methods) resolves against identifiers introduced here.

These declarations lower to schema rows assembled by Valenar.V2.Generated.SecsModule.BuildSchema() (Valenar's generated namespace shown as the running example; another game would emit <GameName>.V2.Generated.SecsModule.BuildSchema() from the same SECS source). There is no monolithic Declarations class — the rows are produced by flat per-table builder classes (Scopes.Build(), Contracts.Build(), Channels.Build(), …) and concatenated into a single WorldSchema. Nothing here executes at tick time; SecsRuntimeBuilder.Build() validates the schema once at host startup. Identifier values are FNV-1a-64 hashes of canonical id strings — see the H.* section below.

Prerequisites

  • Reading 00-overview.md is assumed (the SECS = Scripting Engine C Sharp spelling, the Content/ → compiler-owned output lowering story, the host / engine / registry split).

Type declarations

07-structured-template-data-and-callables.md is the authoritative type-model doc. This document owns only the structural prepass consequence: type declarations are collected before contracts, templates, fields, and method bodies are bound, and their metadata is emitted as type-catalog rows.

SecsScalarType remains the channel-pipeline scalar enum (src/SECS.Schema/Enums/SecsScalarType.cs). It is still used by ChannelRow.ScalarType because channels are arithmetic channels. It is no longer the generic answer for template fields or callable signatures. Top-level field declarations and scope/contract method signatures use ordinary C# source type syntax; the compiler lowers those source types to SecsTypeRef metadata that can name declared structs, records, enums, arrays, C# collection-shaped values, scope entity references, and template references.

Compiler prepass order: parse type declarations first, then scopes and scope fields, then contracts and callable signatures, then template fields/channels/method bodies. A contract signature such as query FeaturePlacementResult EvaluatePlacement(Territory candidate, FeaturePlacementInput input); is illegal until both FeaturePlacementResult and FeaturePlacementInput bind to declared C# source types and therefore to generated SecsTypeRef metadata.

Built-in strong types

The following strong types are SECS built-ins, recognised by the compiler without any struct / record declaration in .secs files:

TypeUnderlying typePurpose
EntityHandleulongRuntime entity instance id. Identifies a live entity across the host bridge. Never compared against identifier hashes (H.*).
TemplateIdulong (record-struct wrapper)Compile-time–checked template identity. Used as the key type in ScopedDictionary<TemplateId, T> when a collection is keyed by which template built each entry. Prevents accidental mixing of entity ids, channel ids, and template ids at call sites. Full spec: 08-collections-and-propagation.md § TemplateId.
ChannelIdulong (record-struct wrapper)Compile-time–checked channel identity. Used when designer-authored structured metadata needs to point at channels rather than resolve them, such as IReadOnlyList<ChannelId> FeedsChannels on Valenar skill-sheet profiles. Lowers to SecsTypeRef.ChannelId, not SecsTypeRef.UInt64.

There is no StatId strong type. Numeric gameplay quantities are expressed either as ordinary scalar scope fields (with matching top-level channel declarations) or as field int / field long (or other SecsTypeRef) template definition data.

SecsScalarType and SecsTypeRef are compiler/generated-code types, not SECS source-language built-ins; they appear in generated C# output and in this doc's Compiles to: blocks. EntityHandle, TemplateId, and ChannelId are visible as source types in .secs files.

Scope declarations

A scope is a node type in the world graph. Scopes carry fields (host-owned data), typed scoped collections (ScopedList<T> / ScopedDictionary<TKey, T>), and parent links via walks_to. Entities are allocated against a scope; contracts bind to a scope as their root_scope; method bodies walk scopes with name.field expressions. The full typed-collection spec lives in 08-collections-and-propagation.md.

scope with fields

What: A scope declaration introduces a named entity type and its host-owned fields. Field types are int, long, float, double, or bool (compile-time types) and may carry min / max clamps (except bool, which has no clamps — see §"Clamp literal types" below).

SECS (target syntax, Valenar names shown):

scope Settlement
{
walks_to Settlement; // self-walk

// Resources
int Gold;
int Food;
int Wood;
int Metal;
int Stone;
int Production;

// Population
int Population;
int PopulationCap;

// Military
int Garrison;
int Defense;

// Morale
int Morale;

// Stage progression (0 = not founded, 1..5 = active stages)
int Stage;

// The founding Territory entity id (0 if not yet founded)
long HomeTerritoryId;
}

Source: examples/valenar-v2/Content/settlement/scopes.secs.

Compiles to:

public static ScopeRow[] Build() =>
[
// FieldRange* addresses a contiguous slice of WorldSchema.ScopeFields;
// WalksTo* addresses WorldSchema.ScopeWalkPlans; Method* is reserved
// (no ScopeMethods side table exists in WorldSchema yet).
new ScopeRow(H.Settlement, walksToStart: 0, walksToCount: 1, fieldStart: 0, fieldCount: 13, methodStart: 0, methodCount: 0),
// ...
];

public static ScopeFieldRow[] BuildFields() =>
[
// Settlement fields — addressed positionally by ScopeRow.FieldRange*
new ScopeFieldRow(H.Gold, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Food, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Wood, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Metal, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Stone, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Production, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Population, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.PopulationCap, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Garrison, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Defense, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Morale, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Stage, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.HomeTerritoryId, ownerScopeId: H.Settlement, scalarType: SecsScalarType.Long, storageBindingId: 0),
// ...
];

Source: compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/Scopes.g.cs (Scopes.Build() / Scopes.BuildFields()). The target types are ScopeRow (src/SECS.Schema/Rows/ScopeRow.cs — 7 fields: ScopeId Id, int WalksToRangeStart, int WalksToRangeCount, int FieldRangeStart, int FieldRangeCount, int MethodRangeStart, int MethodRangeCount) and ScopeFieldRow (src/SECS.Schema/Rows/ScopeFieldRow.cs — 4 fields: ScopeFieldId Id, ScopeId OwnerScopeId, SecsScalarType ScalarType, int StorageBindingId). The scope-walk edges do not live on ScopeRow; they are emitted as ScopeWalkPlanRow rows (FromScope, ToScope) addressed by ScopeRow.WalksToRange* (see §"walks_to" below). SecsScalarType is defined in src/SECS.Schema/Enums/SecsScalarType.cs and carries nine members: { Int=0, Long=1, Float=2, Double=3, Bool=4, EntityHandle=5, Template=6, Contract=7, Tag=8 } (the first five are the numeric/bool scalars used by scope fields and channel pipelines — see §"Numeric type set" below). Valenar's Settlement.HomeTerritoryId and Settlement.ManagerId already use long (lowering to ScalarType = SecsScalarType.Long); no current scope field uses double or bool, but the shape is identical: double Fertility would lower to ScalarType = SecsScalarType.Double, and bool Discovered to ScalarType = SecsScalarType.Bool.

Why this shape: The scope declaration names a node type; the field declarations enumerate the host-owned slots on that node. The engine never mutates these values — it reads them through IHostFieldBridge and writes queued kernel commands back during SyncDirty. Splitting ScopeRow from ScopeFieldRow (with the field rows addressed by ScopeRow.FieldRange*) keeps the row tables flat (one array per table) so WorldSchema.Validate() can FK-check both sides independently.

Notes:

  • min / max clamps are allowed on scope fields in the .secs grammar but ScopeFieldRow has no clamp surface (its only payload field beyond identity is SecsScalarType ScalarType). Clamp enforcement is currently spec-only; record the clamp on the corresponding channel declaration where one exists (many scope fields double as the source = target of a channel).

walks_to

What: walks_to <OtherScope> declares an edge from this scope to another, making other resolvable in expressions on this scope. A walks_to self edge is the conventional self-walk that lets settlement work when this is already a settlement.

SECS:

scope Territory
{
walks_to Settlement; // ownership edge via OwnerId; null while wild
walks_to Province; // upward — every territory belongs to one province
walks_to Area; // upward (multi-hop via province)
walks_to Region; // upward (multi-hop)
walks_to Territory; // self-walk

// Ownership — 0 means wild (unclaimed), otherwise the owning settlement's id.
int OwnerId;

// Designation type (see comment above for values). Drives building gating.
int DesignationType;
// ... other fields elided
}

Source: examples/valenar-v2/Content/territories/scopes.secs.

Compiles to:

// ScopeRow addresses its walk edges by range; the edges themselves are
// ScopeWalkPlanRow entries (FromScope, ToScope) in WorldSchema.ScopeWalkPlans.
new ScopeRow(H.Territory, walksToStart: 5, walksToCount: 5, fieldStart: 13, fieldCount: 2, methodStart: 0, methodCount: 0),

// ... the five edges this range addresses:
new ScopeWalkPlanRow(fromScope: H.Territory, toScope: H.Settlement),
new ScopeWalkPlanRow(fromScope: H.Territory, toScope: H.Province),
new ScopeWalkPlanRow(fromScope: H.Territory, toScope: H.Area),
new ScopeWalkPlanRow(fromScope: H.Territory, toScope: H.Region),
new ScopeWalkPlanRow(fromScope: H.Territory, toScope: H.Territory),

Source: compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/Scopes.g.cs.

Why this shape: The engine needs to know every legal target for a scope walk from an entity (ScopeWalk relation kind per docs/runtime/rn-kernel-contract.md). The host owns the actual graph (each territory's province membership, owner settlement, etc.); the declaration establishes which edges are legal at compile time so the compiler can reject unresolved walks before generated code exists.

walks_to storage — committed design: Walk edges are stored as a flat ScopeWalkPlanRow table (src/SECS.Schema/Rows/ScopeWalkPlanRow.cs — 2 fields: ScopeId FromScope, ScopeId ToScope; FromScope always equals the owning ScopeRow.Id, and neither may be ScopeId.None). Each ScopeRow addresses its own edges through WalksToRangeStart / WalksToRangeCount into WorldSchema.ScopeWalkPlans. The compiler emits every declared walks_to line as one ScopeWalkPlanRow, including self-walks, multi-hop shortcuts, and game-specific ownership edges. Expansion composability requires this row shape: if official expansion A adds walks_to Settlement on scope Feature and official expansion B adds walks_to realm on the same scope, the merger unions both edges into the scope's walk range instead of dropping one into an obsolete single-parent slot.

Host-capable source-set note (walks_to):

  • Base game content and official expansions/DLC may declare new walks_to edges on an existing scope because they can ship the matching host walk implementation. Syntax is the same as base: scope <Name> { walks_to <Other>; } contributes additional ScopeWalkPlanRow entries to the effective base scope's walk range. No explicit mod-operation keyword is needed because the source set is adding, not replacing.
  • Two host-capable source sets that each add the same edge (walks_to Settlement on scope Feature) produce one row in the merged walk range — duplicates are deduplicated by (FromScope, ToScope). No diagnostic.
  • Host-capable source sets may declare new scopes altogether (scope OrbitalStation { walks_to Province; }). The new scope's walk range is initialised from that declaration as if it were base.
  • Host-capable source sets may not delete or replace edges declared by base or another host-capable source set. There is no unwalks_to keyword and no edge-replacement syntax. Content that needs a reduced edge set declares a new sibling scope with the desired edges.
  • Third-party data-only mods may not add new walks_to edges or new scopes under the committed runtime model. They consume the scope graph exported by the base game plus enabled official expansions. A mod body that needs a missing edge must target a game/expansion API that already declares it, not declare host topology itself.
  • Walk-use validation (reserved — no shipped owner; see §"Channel-declaration diagnostics") checks the effective host-capable scope graph. A walk is legal iff base or an enabled official expansion declared the edge before third-party mod content is merged.

ScopedList<T> and ScopedDictionary<TKey, T> (typed scoped collections)

Replaces: The bare collection <Name>; keyword is removed. Any .secs file that previously contained collection Buildings; or collection Provinces; must migrate to one of the two typed forms below.

What: Typed scoped collections are declared inside scope blocks and introduce scope-bound entity collections with explicit element types, an engine-managed lifecycle, modifier surface, and aggregate channel support. The full specification lives in 08-collections-and-propagation.md. This section is a summary with migration guidance.

SECS (new syntax):

scope Settlement
{
walks_to Settlement;
// ... fields elided
long HomeTerritoryId;

// Typed scoped collection (replaces the bare `collection <Name>;` keyword).
// Settlement keeps its resource bag; the Buildings / Provinces collections
// moved off Settlement when the model was reworked — Buildings now hang off
// Territory, and provinces are tracked through reverse OwnerId lookup.
ScopedDictionary<TemplateId, Resource> Resources;

// Methods
query int BuildingCount(TemplateId templateId);
}

Compiles to: Each ScopedList<T> / ScopedDictionary<TKey, T> declaration produces a ScopedCollectionDeclaration row. See 08-collections-and-propagation.md § "Lowering" for the generated struct shape.

Migration: Replace every bare collection <Name>; in .secs source with the typed form. Choose ScopedList<T> for ordered, duplicate-allowing collections. Choose ScopedDictionary<TemplateId, T> (or another key type) for keyed, unique-per-key collections. The prior open questions about typed element inference and bidirectional consistency checking are both resolved by the explicit generic parameter — see 08-collections-and-propagation.md § "Why the bare collection keyword is replaced".

Full spec: 08-collections-and-propagation.md

Methods declared on scopes

What: A scope can declare host-exposed method signatures with typed parameters. Non-void methods are read-only queries implemented by the host read bridge; void methods are command-producing calls implemented by the host command bridge and are only callable where a command context exists.

SECS:

scope Territory
{
// ... fields and ScopedList<T> / ScopedDictionary<TKey, T> declarations elided
query int BuildingCount(TemplateId templateId);
method void Reveal();
}

scope Character
{
// ... fields elided
method void DrainStamina(int amount);
method void GainXp(int amount);
method void AdvanceLead(Territory target, int amount);
}

Source: examples/valenar-v2/Content/characters/scopes.secs.

Compiles to (design target — no v2 schema backing yet):

// DESIGN TARGET. ScopeRow.MethodRange* addresses a ScopeMethods side table
// that does NOT exist in WorldSchema today (see §"Side-table status" below);
// these rows are the compiler-visible signature shape the side table will carry.
public static ScopeMethodRow[] BuildMethods() =>
[
new ScopeMethodRow(
ownerScopeId: H.Territory,
id: H.Scope_Territory_BuildingCount_TemplateId_Int,
isQuery: true, // non-void query
returnTypeRef: SecsTypeRef.Primitive(SecsScalarType.Int)),
new ScopeMethodRow(
ownerScopeId: H.Territory,
id: H.Scope_Territory_Reveal_NoArgs_Void,
isQuery: false, // void command-producing method
returnTypeRef: default), // no Void scalar; IsQuery=false carries "void"
new ScopeMethodRow(
ownerScopeId: H.Character,
id: H.Scope_Character_AdvanceLead_Territory_Int_Void,
isQuery: false,
returnTypeRef: default),
];

SecsTypeRef (src/SECS.Schema/Shared/SecsTypeRef.cs) carries two fields — SecsScalarType Scalar, ulong ReferenceId — with factories Primitive(SecsScalarType), Template(TemplateId), Contract(ContractId), and Tag(TagId). There is no Void scalar (a void method is represented by IsQuery = false, not by a return type) and no scope-entity-reference factory yet; entity-typed parameters such as Territory target are a design target the parameter-row side table will need to express. Read-only BuildingCount call sites lower to typed host calls through IHostCallableBridge; the row table is the compiler-visible shape that tells generated code which concrete return type to use and makes both read-only and command-producing scope methods type-checkable. IsQuery must agree with the source keyword: query rows must be non-void, and method rows are void (carried by IsQuery = false).

Side-table status: ScopeRow.MethodRangeStart / MethodRangeCount (src/SECS.Schema/Rows/ScopeRow.cs) reference a ScopeMethods side table that is absent from WorldSchema today; WorldSchema.Validate() performs no range/FK check against it because there is no member to check. Scope-method declarations are therefore a contract-only design surface in v2 until that side table is added.

Why this shape: Some queries (counting buildings at a territory by template id, for instance) are cheap for the host to answer directly from its indexed data structures and expensive for the engine to reimplement by walking a collection. Some game commands need host-owned side effects that are not channel-pipeline operations, such as spending character stamina, advancing a lead, awarding XP, or revealing a territory. Declaring both surfaces on the scope makes the capability discoverable at compile time while keeping the implementation in Host/Bridge/.

Read/write split: A non-void scope method is a pure read for SECS purposes. It can be called from queries, formulas, conditions, CanStart, IsVisible, and other read-only bodies, and it lowers through the read-side host bridges (IHostFieldBridge for field reads, IHostCallableBridge.Invoke for declared query methods). A void scope method is command-producing. It can be called only from bodies that have a command context: template lifecycle methods, systems/events/activities with ctx.Commands, or another command-producing surface. It lowers through the command path — kernel command-journal appends whose application is observed by IHostCommandSink — not through the read bridges, so read-only contexts cannot accidentally mutate host state.

Typed parameter rule: Scope-method parameters are value/helper arguments, not a way to smuggle ambient world context into a template or contract function. query int BuildingCount(TemplateId templateId) is valid because templateId is data for the query. method void AdvanceLead(Territory target, int amount) is valid because the target territory and amount are explicit command inputs. A signature like query bool CanBuild(Territory territory, Settlement settlement, Owner owner) is not a SECS contract shape: the caller supplies a scope frame, and the method body reaches world data through root plus declared walks_to sigils such as territory / settlement.

Runtime value carrier: Known generated call sites should use concrete C# parameter and return types. Dynamic host/tooling paths may still pass erased value spans, but those spans are validated against SecsTypeRef, not against a free-form runtime value tag. Scope-typed parameters such as Territory target are a design target: v2 SecsTypeRef carries Scalar plus a ReferenceId for Template / Contract / Tag references and has no scope-entity-reference kind yet, so the parameter-row side table that backs scope-method parameters will need to add one. The compiler type-checks the source receiver/argument while the runtime bridge receives a compact EntityHandle. Non-void scope methods return the declared type; a dynamic adapter may box that value only at an erased boundary.

Method identity rule: Source calls use normal C# overload resolution. Generated ids are owner-and-signature hashes, not name-only hashes. The canonical hash text is scope:<scope-name>.<Method>(<param-types>):<return-type> for scope methods and contract:<contract-name>.<Method>(<param-types>):<return-type> for contract queries/methods (the compiler emits the canonical text scope:<name>.<Method>(...):<ret> / contract:<name>...; runtime stores only the resulting FNV-1a-64 ids in H.* constants and declaration tables). Examples: scope:Character.AdvanceLead(Territory,int):void, scope:Territory.BuildingCount(TemplateId):int, and contract:Building.CanBuild():bool.

Host-capable extension of scopes

What: The base game and official expansions/DLC may extend an existing scope by adding new fields, new ScopedList<T> / ScopedDictionary<TKey, T> declarations, new scope-method signatures, or new walks_to edges. They may declare entirely new scopes. This is allowed only for host-capable source sets because every such declaration requires matching host storage, child enumeration, walk resolution, or method implementation.

Third-party mod boundary: Third-party mods are data-only under the committed runtime model. They may reference and patch content that uses the exported base/expansion scope graph, but they may not add new scopes, scope fields, ScopedList<T> / ScopedDictionary<TKey, T> declarations, walks_to edges, or scope methods. There is no per-mod host bridge DLL loading. A future engine-owned mod-state system could reopen this, but it must be designed explicitly.

Host-capable additions, no mod operation: A host-capable scope <ExistingName> { ... } block is additive — every field, ScopedList<T> / ScopedDictionary<TKey, T> declaration, method signature, or walks_to line inside it is appended to the effective base scope's declaration. Official expansions do not write replace scope <Name> { ... } for additions; the merger detects the in-scope additive contribution and merges it before third-party mods are compiled.

Field additions:

  • A host-capable expansion may add new fields to an existing scope: scope Settlement { int Piety; } extends settlement with one new field. The merger appends a new ScopeFieldRow with OwnerScopeId = H.Settlement, Id = H.Piety, extends the settlement ScopeRow.FieldRange* to cover it, and the expansion's host code must allocate storage for that field.
  • Third-party mods may not add scope fields. A data-only mod that declares scope Settlement { int Piety; } against an existing exported scope is rejected because the host has no storage surface for H.Piety.
  • A source set may not change the type of an existing field. A declaration such as scope Settlement { long Population; } where base declared int Population is SECS0602; field declarations are not overridable. The host-capable source set must declare a sibling field (long PopulationL) instead.

Collection additions: A host-capable expansion may add new typed scoped collections (e.g., scope Settlement { ScopedList<Shrine> Shrines; }) and must supply the corresponding host-bridge integration for the new collection. Third-party mods may iterate existing collections but may not declare new ScopedList<T> / ScopedDictionary<TKey, T> fields. Full extension rules: 08-collections-and-propagation.md § "Host-capable extension".

walks_to additions: Already covered in §"walks_to" above. Host-capable source sets may add new edges to existing scopes; duplicates dedupe; no source set may remove edges. Third-party mods may only use edges already exported by the effective base scope graph.

Method additions: A host-capable expansion may declare a new method signature on an existing scope (scope Territory { int WorkshopCount(TemplateId templateId); }) and ship the host implementation as part of that expansion's code. Third-party mods may call existing exported scope methods but may not add or replace scope methods.

Two-source-set field-name clash: When host-capable source set A declares scope Settlement { int Piety; } and host-capable source set B declares the same field on the same scope, both fail SECS0602 unless the game has defined an official expansion ordering/ownership rule outside SECS. The conflict report lists the slot as scope_field keyed by (H.Settlement, H.Piety).

Multi-hop walks on a leaf scope

What: Leaf scopes in a hierarchy (territory, feature) declare the full chain of upward walks so expressions can reach any ancestor directly without stepping through intermediates. Today the compiled output keeps only the immediate parent.

SECS:

scope Feature
{
walks_to Territory; // upward — every feature sits inside one territory
walks_to Province; // upward (multi-hop via territory)
walks_to Area; // upward (multi-hop)
walks_to Region; // upward (multi-hop)
walks_to Feature; // self-walk

// Visibility: 0 = hidden (fog), 1 = revealed when player explores nearby.
int Discovered;

// Completion: 0 = active, 1 = finished (for one-time features like dungeons
// or ruins that can be explored and exhausted).
int Cleared;

// Threat flag: 0 = peaceful, 1 = actively threatens nearby tiles.
// Used by host systems to apply DungeonAuraOfDread to adjacent territories.
int Hostile;
}

Source: examples/valenar-v2/Content/territories/features/scopes.secs.

Compiles to:

// The ScopeRow addresses its five walk edges by range; the edges are
// ScopeWalkPlanRow entries in WorldSchema.ScopeWalkPlans.
new ScopeRow(H.Feature, walksToStart: 10, walksToCount: 5, fieldStart: 15, fieldCount: 3, methodStart: 0, methodCount: 0),

new ScopeWalkPlanRow(fromScope: H.Feature, toScope: H.Territory),
new ScopeWalkPlanRow(fromScope: H.Feature, toScope: H.Province),
new ScopeWalkPlanRow(fromScope: H.Feature, toScope: H.Area),
new ScopeWalkPlanRow(fromScope: H.Feature, toScope: H.Region),
new ScopeWalkPlanRow(fromScope: H.Feature, toScope: H.Feature),

// ...

// Feature scope fields (addressed by the ScopeRow.FieldRange* above)
new ScopeFieldRow(H.Discovered, ownerScopeId: H.Feature, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Cleared, ownerScopeId: H.Feature, scalarType: SecsScalarType.Int, storageBindingId: 0),
new ScopeFieldRow(H.Hostile, ownerScopeId: H.Feature, scalarType: SecsScalarType.Int, storageBindingId: 0),

Source: compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/Scopes.g.cs.

Why this shape: The feature declaration explicitly lists every ancestor it needs to reach (province, area, region) so .secs code is self-documenting about which walks are legal. The lowering preserves all declared edges as ScopeWalkPlanRow entries in the scope's walk range; the host bridge still owns the actual entity lookup for each edge.

Notes:

  • The type set for scope fields is { int, long, float, double, bool } — the five scalar members of SecsScalarType at src/SECS.Schema/Enums/SecsScalarType.cs that apply to scope fields (the enum also carries EntityHandle, Template, Contract, and Tag for SecsTypeRef-typed surfaces; see §"Numeric type set" below for the full rationale). None of the Valenar scopes declare a long / double / bool field — every binary-valued field (Discovered, Cleared, Hostile, Explored) uses int with 0/1 semantics. Whether the compiler should allow both and choose, or require bool for binary semantics, is an open authoring-convention call; the type system permits either. For now, assume int everywhere and document the convention.

Scope-walk correctness (committed 2026-04-24)

What: Every WalkScope(from, H.Target) call the compiler lowers — whether from a target.field expression in a method body, a template Activate body, a formula context walk, or a host-bridge CanBuild helper — must correspond to an edge declared via walks_to <Target> in the source scope's declaration. Undeclared walks are a compile error under the walk-use validation rule (reserved — no shipped owner; the shipped SECS0104 UnknownWalkTarget covers unknown walks_to declaration targets). The self-walk (walks_to Territory; on scope territory) counts as a declared edge for WalkScope(loc, H.Territory).

SECS: The rule applies to every walk site. Valenar's building templates are rooted at Territory, and they need to read settlement-owned resources during CanBuild. That is legal because scope Territory declares the ownership edge explicitly:

scope Territory
{
walks_to Settlement; // ownership edge via OwnerId; null while wild
walks_to Province; // upward
walks_to Area; // upward (multi-hop via province)
walks_to Region; // upward (multi-hop)
walks_to Territory; // self-walk
// ...
}

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

Compiles to:

public static bool CanBuild(ScopeFrame scope, IHostFieldBridge host, IKernelWorld kernel)
{
// Scope walks resolve through the kernel ScopeWalk relation, not a host method.
var settlement = kernel.Relations.ScanTargets(scope.Root, RelationKind.ScopeWalk, H.Settlement);
if (settlement.IsNull)
{
return false;
}

return
host.ReadInt(settlement, H.Settlement, H.Stage) >= MinStage
&& BuildCostRules.CanPay(Id, scope, host);
}

The kernel resolves this edge through the ScopeWalk relation (which the host populated from Territory.OwnerId), but that is an implementation detail. The declaration is the contract that lets the compiler accept @Settlement inside a territory-rooted template.

Diagnostic — walk-use validation (reserved — no shipped owner):

scope walk from '<FromScope>' to '<ToScope>' is not declared.
Traversable via walks_to from '<FromScope>': { <list of declared targets> }.
If '<ToScope>' should be reachable, add walks_to <ToScope>; to the
scope declaration.

If the walks_to Settlement; line were missing, the Farm case would produce:

scope walk from 'territory' to 'settlement' is not declared.
Traversable via walks_to from 'territory': { province, area, region, territory }.
If 'settlement' should be reachable, add walks_to Settlement; to the
scope declaration.

Why this shape: Walk-graph correctness is declaration-driven, not host-implementation-driven. A host bridge that happens to support territory → settlement today may not tomorrow (or in a mod / alternate host); the declaration is the contract. Two consequences follow:

  • The compiler can type-check scope.X expressions without consulting the host — settlement.X inside a territory-rooted method is either backed by walks_to Settlement in scope Territory (legal) or rejected at compile time (illegal).
  • The registry-time walk-resolution path becomes a table lookup against the declaration arrays rather than a runtime dispatch to host-bridge methods that may or may not succeed.

The alternative — discovering walk edges dynamically by trying host.WalkScope(...) and treating null as "probably fine" — is rejected. Walk-use validation replaces permissive fallbacks with a compile-time guarantee. A declared walk can still return EntityHandle.Null at runtime when the relationship is absent for this specific entity (for example, a wild unowned territory has no settlement); that is normal game state and the generated query or method decides whether null means false, no-op, or something else.

Notes: None at the correctness level. Whether the compiler should accept multi-hop declarations implicitly (adding walks_to Area on scope Territory auto-enables walks_to Region because region is area's declared parent) or require every reachable target to be listed explicitly is a lowering ergonomics question (see §"walks_to" open questions — design (1) vs (2)). Walk-use validation fires in either design: in design (1), the transitive closure of declared walks defines the legal set; in design (2), the literal set of walks_to lines defines it.

Contract declarations

A contract is the shape of a template API — it pins a template to a root scope, declares the typed query methods and void methods that templates may implement, and records lifecycle bindings that tell the runtime which declared void methods to call automatically. Templates do not declare their own scope; they bind to a contract. Multiple templates share one contract (every Building template roots at Territory, exposes the CanBuild query, and implements void methods such as OnBuilt / OnDestroyed / OnDayTick).

contract { root_scope / queries / methods / lifecycle bindings }

What: A contract declaration names a root scope, a query set, a void-method set, and optional lifecycle bindings. Queries are read-only callable surfaces with explicit return types; they cannot enqueue commands or mutate host fields. Methods are void command-producing surfaces; they receive ITemplateCommandContext, enqueue effects through context.Commands, and flush with context.FlushCommands() after command-producing source statements. Lifecycle bindings such as activation OnBuilt; select one of the declared void methods for an automatic host/runtime call. CanBuild is one ordinary query name, not a compiler keyword and not a hard-coded Building special case.

Scope-frame rule: Every contract-declared query or method executes inside a scope frame derived from the contract's root_scope. Source-level contract functions do not accept arbitrary game-world parameters such as Territory, Settlement, or Owner to describe where they run. The caller supplies the root-scope entity and, for existing-instance calls, the owner entity; the compiler binds those as root and this / owner. All other world data must be reached through declared scope walks (@Settlement, @Province, @Territory, etc.) or through value/helper parameters on scope methods. Creation-time queries run before a template instance exists, so their frame has root but no this or owner. Existing-instance queries and void methods add this / owner for that instance while keeping root as the root-scope target.

Callable API rule: Every contract-declared query and every contract-declared void method is callable from both host C# and SECS source through the same generic contract-call API. The host supplies template id, query/method id, a ScopeFrame, and either an IHostFieldBridge read boundary for queries or a command-journal context for void methods (whose application the host observes through IHostCommandSink). The contract-callable surface itself is IHostCallableBridge.Invoke. SECS source calls the same surface by naming the template, the query/method, and an explicit context(...) frame. Lifecycle bindings are just automatic callers of this same method surface; they do not create a private hook path.

Context-profile rule: A contract may name reusable ordered context chains for effective template-value reads. A profile is owned by the contract because its expressions are interpreted relative to that contract's root_scope and declared walks. The profile is not a guard, payer, validation hook, or mini query language; it expands to an ordered list of context entities for template_field(Template, Field, ProfileName). Null handling, affordability checks, resource payer selection, and any "can this happen?" decisions remain ordinary code in contract query bodies such as CanBuild().

Lifecycle vocabulary rule: The engine owns the lifecycle slot identifiers; the game owns the method names. activation means "this template instance became active" in every game. OnBuilt only means "built" because Valenar's Building contract chose that name. A different game can bind the same engine slot to OnEquipped, OnCrafted, OnUnlocked, or any other declared void method:

contract Equipment
{
root_scope Character;
activation OnEquipped;
deactivation OnUnequipped;

query bool CanEquip();
method void OnEquipped();
method void OnUnequipped();
}

The runtime still sees only ContractRow.ActivationBindingId = H.Contract_Equipment_OnEquipped_NoArgs_Void and ContractRow.DeactivationBindingId = H.Contract_Equipment_OnUnequipped_NoArgs_Void (two ContractMethodId fields directly on the row). No engine branch knows what "equipped" means. This is what keeps SECS a generic scripting engine rather than a colony-builder-specific engine.

SECS:

contract Building
{
root_scope Territory;
activation OnBuilt;
deactivation OnDestroyed;

query bool CanBuild();
method void OnBuilt();
method void OnDestroyed();
method void OnDayTick();

context BuildCostContext
{
@Settlement;
@Territory;
}
}

contract Settlement
{
root_scope Settlement;
activation OnCreated;

method void OnCreated();
method void OnDayTick();
method void OnSeasonTick();
}

// Territories are claimed and designated at runtime; OnClaimed/OnDesignated fire
// from host code when the player (or auto-claim system) acts on a territory.
contract Territory
{
root_scope Territory;
activation OnLoaded;

method void OnLoaded();
method void OnClaimed();
method void OnDesignated();
}

contract Feature
{
root_scope Feature;
activation OnSpawned;

query FeaturePlacementResult EvaluatePlacement(
Territory candidate,
FeaturePlacementInput input);

method void OnSpawned();
method void OnDiscovered();
}

Source: domain-owned contract files such as examples/valenar-v2/Content/territories/features/contracts.secs.

Compiles to:

// ContractRow addresses its query/method rows by range into WorldSchema.ContractMethods;
// the query-vs-method split is the IsQuery flag on each ContractMethodRow (no separate
// id lists on the row). Lifecycle is the two binding-id fields directly on the row.
public static ContractRow[] Build() =>
[
new ContractRow(
H.BuildingContract, rootScopeId: H.Territory,
methodRangeStart: 0, methodRangeCount: 4, // CanBuild + OnBuilt + OnDestroyed + OnDayTick
activationBindingId: H.Contract_Building_OnBuilt_NoArgs_Void,
deactivationBindingId: H.Contract_Building_OnDestroyed_NoArgs_Void,
isRegistryOnly: false),
new ContractRow(
H.SettlementContract, rootScopeId: H.Settlement,
methodRangeStart: 4, methodRangeCount: 3,
activationBindingId: H.Contract_Settlement_OnCreated_NoArgs_Void,
deactivationBindingId: ContractMethodId.None,
isRegistryOnly: false),
new ContractRow(
H.BuildOrderContract, rootScopeId: H.Territory,
methodRangeStart: 7, methodRangeCount: 0,
activationBindingId: ContractMethodId.None,
deactivationBindingId: ContractMethodId.None,
isRegistryOnly: true), // registry-only: no instantiable template body

// Map hierarchy contracts — instantiated by Host/Map/MapGenerator
new ContractRow(H.RegionContract, rootScopeId: H.Region, methodRangeStart: 7, methodRangeCount: 1, activationBindingId: H.Contract_Region_OnLoaded_NoArgs_Void, deactivationBindingId: ContractMethodId.None, isRegistryOnly: false),
new ContractRow(H.AreaContract, rootScopeId: H.Area, methodRangeStart: 8, methodRangeCount: 1, activationBindingId: H.Contract_Area_OnLoaded_NoArgs_Void, deactivationBindingId: ContractMethodId.None, isRegistryOnly: false),
new ContractRow(H.ProvinceContract, rootScopeId: H.Province, methodRangeStart: 9, methodRangeCount: 1, activationBindingId: H.Contract_Province_OnLoaded_NoArgs_Void, deactivationBindingId: ContractMethodId.None, isRegistryOnly: false),
new ContractRow(H.TerritoryContract, rootScopeId: H.Territory, methodRangeStart: 10, methodRangeCount: 3, activationBindingId: H.Contract_Territory_OnLoaded_NoArgs_Void, deactivationBindingId: ContractMethodId.None, isRegistryOnly: false),

// Feature contract — created at generation time, discovered lazily by the player
new ContractRow(H.FeatureContract, rootScopeId: H.Feature, methodRangeStart: 13, methodRangeCount: 4, activationBindingId: H.Contract_Feature_OnSpawned_NoArgs_Void, deactivationBindingId: ContractMethodId.None, isRegistryOnly: false),

// Character contract — created once by the host runtime after map init
new ContractRow(H.CharacterContract, rootScopeId: H.Character, methodRangeStart: 17, methodRangeCount: 1, activationBindingId: H.Contract_Character_OnSpawned_NoArgs_Void, deactivationBindingId: ContractMethodId.None, isRegistryOnly: false),
];

public static ContractMethodRow[] BuildMethods() =>
[
new ContractMethodRow(
H.Contract_Building_CanBuild_NoArgs_Bool, ownerContractId: H.BuildingContract,
signatureHashLow: 0xUL /* FNV-1a-64 low bits of "contract:Building.CanBuild():bool" */,
isQuery: true,
returnTypeRef: SecsTypeRef.Primitive(SecsScalarType.Bool),
paramCount: 0, paramRangeStart: 0),
new ContractMethodRow(
H.Contract_Building_OnBuilt_NoArgs_Void, ownerContractId: H.BuildingContract,
signatureHashLow: 0xUL,
isQuery: false, // void method; no return-type ref
returnTypeRef: default,
paramCount: 0, paramRangeStart: 0),
new ContractMethodRow(
H.Contract_Building_OnDestroyed_NoArgs_Void, ownerContractId: H.BuildingContract,
signatureHashLow: 0xUL,
isQuery: false,
returnTypeRef: default,
paramCount: 0, paramRangeStart: 0),
// EvaluatePlacement is the doc 07 design target: a query returning a declared record
// and taking a scope-entity-typed parameter. v2 SecsTypeRef can carry the record
// return as a Template/Contract reference, but a scope-entity parameter kind and the
// parameter-row side table (addressed by ParamRangeStart/Count) do not exist yet.
new ContractMethodRow(
H.Contract_Feature_EvaluatePlacement_Territory_FeaturePlacementInput_FeaturePlacementResult,
ownerContractId: H.FeatureContract,
signatureHashLow: 0xUL,
isQuery: true,
returnTypeRef: /* design target: record ref to FeaturePlacementResult */ default,
paramCount: 2, paramRangeStart: 0),
// ... one row per declared query/method on every contract.
];

Source: compiler-emitted contract rows under examples/valenar-v2/Generated/obj/**/SecsGenerated (Contracts.g.cs, Contracts.Build() / Contracts.BuildMethods()) for the implemented scalar examples; the EvaluatePlacement row is the doc 07 design target. Target rows: ContractRow (src/SECS.Schema/Rows/ContractRow.cs — 7 fields: ContractId Id, ScopeId RootScopeId, int MethodRangeStart, int MethodRangeCount, ContractMethodId ActivationBindingId, ContractMethodId DeactivationBindingId, bool IsRegistryOnly) and ContractMethodRow (src/SECS.Schema/Rows/ContractMethodRow.cs — 7 fields: ContractMethodId Id, ContractId OwnerContractId, ulong SignatureHashLow, bool IsQuery, SecsTypeRef ReturnTypeRef, int ParamCount, int ParamRangeStart). Both lifecycle bindings live directly on ContractRow as ActivationBindingId / DeactivationBindingId (each may be ContractMethodId.None); v2 has no separate lifecycle-binding array and no lifecycle-slot enum — the two binding-id fields on the row are the whole surface. The query-vs-void split is the per-row IsQuery flag, not two id lists. Context profiles do not lower to a WorldSchema row in v2 — there is no ContextProfile/ContextEntry row type in the schema (BuildCostContext and its kind are a contract-owned, source-level expansion that the read-site lowering inlines; the schema has no member backing them yet). Method parameters live in a parameter-row side table addressed by ContractMethodRow.ParamRangeStart/ParamCount that is absent from WorldSchema today (Validate() performs no FK check against it).

Why this shape: The contract is the pin that lets the engine route automatic lifecycle calls and expose template-specific callable surfaces without special cases. Listing queries explicitly makes read-only availability checks part of the contract API: host UI can ask CanBuild, SECS code can ask the same query inside systems/events, and mods can replace a query body without the compiler treating CanBuild as magic. Listing void methods explicitly (rather than discovering them from template bodies) lets the compiler diff template implementations against the contract — templates that implement functions not on the contract are rejected at compile time; contracts with unimplemented methods fall through to a default no-op.

ContractRow is the fast runtime dispatch/lifecycle table. ContractMethodRow is the compiler-facing callable signature table. WorldSchema.Validate() FK-checks that every method row's OwnerContractId references an existing contract and that each contract's MethodRange* addresses a contiguous slice of WorldSchema.ContractMethods; the query-vs-command split is the per-row IsQuery flag, not two id lists. The metadata table does not grant dispatch by itself. This gives Roslyn enough information to bind explicit query / method template members and calls by owner + SecsTypeRef signature while keeping runtime invocation keyed by the ContractMethodId ids. Queries may return declared structs/records/enums/tags/container values; methods remain command-producing and must return void (carried by IsQuery = false).

The lifecycle table is intentionally one level of indirection: engine slot id -> developer method id. Adding a new generic engine moment later (for example a transition-enter slot) adds one lifecycle id and one binding row; it does not reserve method names like OnBuilt or OnEquipped globally. Games can keep their own vocabulary, and SECS stays responsible only for when the automatic call happens.

Lifecycle bindings are optional. A contract with no activation binding compiles and receives no automatic activation method call.

Context profiles are similarly optional and source-level. The compiler treats each named profile as contract-owned metadata and expands it at each read site: template_field(Farm, GoldCost, BuildCostContext) is equivalent to template_field(Farm, GoldCost, context(@Settlement, @Territory)) inside a Building template. The ordered broad-to-narrow context-chain semantics live in 05-expressions.md § "Template value read".

Notes:

  • Whether the source spelling should remain activation OnBuilt; or switch to on_activate OnBuilt;. Both are lifecycle-binding spellings; neither changes the rule that the target must be a declared void method.
  • Contract inheritance / mixins (spec-level extends) are not present in either .secs sources or compiler-owned output. If introduced, they would need to flatten at lowering time — contracts would remain a flat array with no hierarchy.

Mod scope note (contracts are not extensible by mods): Contracts are base-only. Mods may declare new contracts but may not extend existing contracts — a mod may not add new query methods to a base-declared contract's query list, may not add new void methods to its method list, may not change root_scope, and may not change lifecycle bindings such as activation. To introduce a new lifecycle hook or query surface (e.g., OnRaided / CanRaid on Building-shaped templates), a mod declares a new contract (contract Raidable { root_scope Territory; query bool CanRaid(); method void OnRaided(); }) and a new template hierarchy that binds to it. The merger emits SECS0602 if a mod declares a top-level contract block that names an already-declared contract identifier; there is no per-query or per-method additive merge for existing contracts. Cross-reference 06-overrides-and-modding.md § "What's NOT overridable".

Top-level Channel declarations

A channel is a named, typed, optionally-clamped value the engine resolves through the 6-phase target pipeline. Top-level channel declarations introduce the channel's identity and metadata — its name, its type (int / long / float / double / bool), its ChannelKind, whether it binds to a host-owned scope field via source =, and its clamps. Template bodies declare per-instance channels — see 02-templates.md. Modifier effects (Gold += 10;, FoodOutput *= 50%;, FoodCapacity = 10;) are introduced in 03-channels-and-modifiers.md. This document only covers the declaration.

Numeric type set (committed 2026-04-24)

What: A channel's compile-time arithmetic type is one of five values: int (32-bit signed), long (64-bit signed), float (IEEE 754 single-precision), double (IEEE 754 double-precision), bool (boolean). The same five apply to scope fields (§"scope with fields" above). These are the five numeric/bool scalar members of the SecsScalarType enum; the enum itself carries four further members (EntityHandle, Template, Contract, Tag) used by the general SecsTypeRef reference model, not by channel arithmetic. No further numeric types are added without an explicit design change — decimal, uint, byte, and BigInt are out of scope (rationale below).

SECS:

channel int Population { kind = Base; source = Settlement.Population; min = 0; }
channel long TotalXp { kind = Base; source = Character.TotalXp; min = 0L; }
channel float Fertility { kind = Base; source = Territory.Fertility; min = 0.0; max = 1.0; }
channel double WorldTime { kind = Base; source = World.WorldTime; }
channel bool IsFounded { kind = Contributed; source = Settlement.IsFounded; }

Compiles to: The target enum is SecsScalarType at src/SECS.Schema/Enums/SecsScalarType.cs:

public enum SecsScalarType : byte
{
Int = 0,
Long = 1,
Float = 2,
Double = 3,
Bool = 4,
// The four members below are reference kinds for SecsTypeRef, not channel
// arithmetic types — a channel's ScalarType is always one of the five above.
EntityHandle = 5,
Template = 6,
Contract = 7,
Tag = 8,
}

Each top-level channel lowers to a ChannelRow whose ScalarType is one of the five numeric/bool values. Each scope field lowers to a ScopeFieldRow whose ScalarType is one of the same five.

Why this shape (per CLAUDE.md tenets): Five numeric/bool types from day one is the complete correct algorithm, not a minimal-and-extend approximation. The argument for minimalism ("start with int / float, add long / double when a user asks") is a shortcut the tenets reject: retrofitting 64-bit scalars after int is the sole integer type means breaking every channel-resolver path, every IHostFieldBridge.ReadInt signature, every typed payload slot, and every compiler codegen template. All five types land together in the original design.

Explicitly rejected:

  • uint / ulong — unsigned integers overlap with clamped signed types (channel int X { min = 0; } covers the natural-number use case). Adding a second unsigned path doubles the resolver surface for no gain.
  • byte / short — narrow-width integers are a premature optimisation. A ChannelRow is a fixed-width readonly record struct; shrinking the scalar payload to 1 or 2 bytes saves nothing at the scale SECS operates at.
  • decimal — niche (accounting-grade fixed-point). No current or anticipated Valenar channel requires 28-digit decimal precision; when a use case emerges, decimal is a single-enum-entry addition without restructuring.
  • BigInt — arbitrary-precision integers are a separate multi-month project (arithmetic is O(n), not O(1); the fast-path union model breaks; the tooltip / modifier / formula layers all need parallel codepaths). Out of scope for Phase 1 and Phase 2; revisit only if a shipped game needs it.

Mod scope note (fixed surface): SecsScalarType is an engine-owned enum. Mods cannot extend it. A mod's channel <unlisted-type> Foo { ... } (e.g., channel decimal Treasury { ... } or channel uint Count { ... }) emits a binder diagnostic at the mod's compile time — the shipped SECS0301 (UnsupportedChannelType). The same rule applies to scope-field type declarations via the shipped SECS0102 (UnsupportedScopeFieldType). New numeric types require an engine + compiler release, not a mod release. Cross-reference 06-overrides-and-modding.md § "What's NOT overridable".

bool channels are valid at all three ChannelKind values (Contributed, Base, Accumulative). There is no diagnostic that restricts bool to one kind — the type system permits any kind for any type, and the semantics are well-defined in every combination. A bool channel's resolution pipeline follows the same target phase order as numeric channels, with two specialisations: (1) modifier effect modes on bool channels are restricted to HardOverride (see 03-channels-and-modifiers.mdAdditive / Multiplicative are meaningless on booleans), and (2) bool channels do not accept min / max clamps (see §"Clamp literal types" below). HardOverride is the boolean's only meaningful modifier mode: a single modifier sets the value to true or false and wins. See 03-channels-and-modifiers.md §"HardOverride mode" for the full semantics.

Engine implementation note: The 5-numeric-type commitment is implemented across every engine-side surface that touches channel values. Phase 1 of the compiler emits for all five types from the first day:

  • SecsScalarType carries the five numeric/bool members (Int/Long/Float/Double/Bool) plus four reference members (src/SECS.Schema/Enums/SecsScalarType.cs).
  • ChannelResolverV2.Resolve(EntityHandle, ChannelId) returns double; the six-phase value pipeline is shared across types, and typed reads are the caller's responsibility (src/SECS.Runtime/Channels/ChannelResolverV2.cs).
  • IHostFieldBridge carries six scalar Read* / six scalar Write* methods so the host bridge can expose typed field I/O without boxing (src/SECS.Runtime/HostBridges/IHostFieldBridge.cs).
  • ChannelRow carries a single double ClampMin / double ClampMax pair gated by ClampMinFlag / ClampMaxFlag (not one pair per scalar). ModifierEffectRow carries one double StaticDelta static-value slot (not a per-type union); see 03-channels-and-modifiers.md.

The enum name is deliberately neutral: SecsScalarType is shared by channels, template fields (via SecsTypeRef), scope fields, host-bridge payloads, formula delegates, and modifier effects. The declaration family (channel, field, or scope field) decides semantics; the scalar enum only decides storage and arithmetic.

Array ordering: the Channels.Build() row array is sorted ascending by ChannelId.Value before assembly into WorldSchema (the schema builder requires every top-level row array sorted by typed-id value for deterministic kernel initialization). Within a source file, the compiler collects declarations in source order; within a single source set, files are processed in alphabetical path order under the set root; cross-source-set merging then applies the layered-mod rule: legal replace operations on replaceable declaration families keep the existing H.X slot, while net-new declarations are merged and the combined array re-sorted by id. Top-level channel declarations themselves are create-only, so a second channel declaration with the same identity is redundant or invalid rather than a legal replacement. The same id-sorted assembly governs ScopeFields, Scopes, Contracts, and every other schema row table.

Model: intrinsic sources are own-root, modifiers cross scopes

Rule (committed 2026-04-24, clarified 2026-05-01): A top-level channel declaration defines a resolver channel. ChannelKind.Contributed starts at zero, may receive compiler-emitted template intrinsic channel sources on the template activation root, and then receives modifier effects. There is no language-level "contribution" primitive that pushes anonymous values into arbitrary scopes — cross-scope effects (a building affecting its settlement's Morale) are expressed as named modifier attachments. This is the canonical statement of the model; other docs (02, 03) reference this subsection.

The three shapes of "channel effect":

ShapeSECS surfaceEngine mechanismExample
Host-owned basechannel int Population { kind = Base; source = Settlement.Population; }ChannelKind.Base — host field is read at resolution phase 1; modifiers apply on topPopulation counter mutated by simulation events
Modifiermodifier HouseMoralePresence { stacking = stackable; Morale += 5; }ModifierBindingStore — entries aggregate at resolution phase 2/3Buildings contributing to settlement Morale
Template intrinsic channel sourcetemplate Farm { channel int FoodOutput = 5; }A channel-source binding emitted only by template activation, targeting the activation root (static value, or a FormulaId for dynamic sources resolved through the runtime formula registry)A Farm template providing its territory-root FoodOutput

Concrete example: a House affecting settlement.Morale. The correct shape is a modifier attachment, not a template-body contribution. The declarations are:

// channels.secs (this doc's subject)
channel int Morale { kind = Contributed; source = Settlement.Morale; min = 0; max = 100; }

// modifiers.secs (declared once, attached many times)
modifier HouseMoralePresence {
stacking = stackable;
Morale += 5;
}

// House template body (forward syntax, not yet in source file)
template<Building> House {
OnBuilt() { @Settlement.add_modifier HouseMoralePresence; }
}

Ten Houses attach ten stackable HouseMoralePresence modifiers to the settlement entity. Each binding is owned by the House that created it and targeted at the settlement. The settlement's Morale resolves from the Contributed zero base plus the ten stackable +5 modifiers and any other active modifiers, then clamps to [0, 100]. Tooltip attribution comes directly from the named modifier; destroying one House removes that House's owned binding through the owner/target lifecycle cleanup.

Why this shape:

  • One cross-scope mechanism. A prior draft had templates "contributing" a value to a higher-scope channel and modifiers applying on top. Two mechanisms for "source affects another scope" was a language smell; cross-scope effects now go through modifiers.
  • Targeted mod attribution requires named effects. Modifiers have stable names; an anonymous "contribution" from a template body does not. Mods that disable a specific buff need something to target.
  • Tooltip attribution works naturally. "Morale: 65 = 50 base + 15 Celebration + 10 Temple" requires each source to have a display name. Modifiers provide this; raw template aggregation does not.
  • The Paradox idiom. Stackable modifiers are the well-trodden path for building → province / county → realm aggregation in CK3 / EU4 / Victoria 3. SECS follows that pattern by design.

Runtime primitive note: Template-owned intrinsic channel sources lower to channel-source bindings on the activation root (static value, or a dynamic source routed through a registered FormulaId). They are emitted by compiler-generated activation code, must target the template activation root, and are not surfaced in .secs as a generic contribute verb. A cross-scope effect must lower as a named modifier, not as a raw channel source.

Forward pointers:

  • Template-body per-instance channel semantics: 02-templates.md.
  • Modifier declarations and stacking policies: 03-channels-and-modifiers.md.

field declarations

What: A top-level field declaration defines a template-field identifier, source C# type, generated SecsTypeRef, display metadata, and optional clamps when the type is numeric scalar. It is the global metadata home for values assigned inside template bodies with field int GoldCost = 10; or field FeaturePlacementProfile Placement = new(...);. A field is not a channel: its base value is readonly template-definition data, read through typed template field accessors / registry APIs, and is not accepted by resolve(...).

SECS:

field int GoldCost {
name = "Gold Cost";
description = "Gold required to construct this template.";
min = 0;
}

field int WoodCost {
name = "Wood Cost";
description = "Wood required to construct this template.";
min = 0;
}

field FeaturePlacementProfile Placement {
name = "Placement";
description = "Feature placement scoring data.";
}

The declaration identifier (GoldCost) is the hash/localization-key seed. name and description are player-facing fallback text. If localization provides field_goldcost or field_goldcost_desc, those localized strings override the inline fallback; if the keys are absent or no localization provider is installed, the inline values are used.

Compiles to:

// Each authored field VALUE on a template lowers to one TemplateFieldRow whose
// payload is serialized into a side blob addressed by SerializedValueOffset/Length.
// The row carries no display metadata — ValueTypeRef is its only typed payload.
public static TemplateFieldRow[] Build() =>
[
new TemplateFieldRow(H.GoldCost, ownerTemplateId: H.Farm, valueTypeRef: SecsTypeRef.Primitive(SecsScalarType.Int), serializedValueOffset: 0, serializedValueLength: 4),
new TemplateFieldRow(H.WoodCost, ownerTemplateId: H.Farm, valueTypeRef: SecsTypeRef.Primitive(SecsScalarType.Int), serializedValueOffset: 4, serializedValueLength: 4),
// Structured field (FeaturePlacementProfile) is the doc 07 design target — its
// ValueTypeRef would carry a record/template reference; the serialized blob holds
// the encoded struct value.
new TemplateFieldRow(H.Placement, ownerTemplateId: H.Dungeon, valueTypeRef: /* design target: record ref */ default, serializedValueOffset: 8, serializedValueLength: /* blob len */ 0),
];

TemplateFieldRow (src/SECS.Schema/Rows/TemplateFieldRow.cs — 5 fields: TemplateFieldId Id, TemplateId OwnerTemplateId, SecsTypeRef ValueTypeRef, int SerializedValueOffset, int SerializedValueLength) holds the per-template field value, not a display-metadata catalog. ValueTypeRef is the general SecsTypeRef from doc 07. The author-facing display metadata — name, description, min / max clamps, localization keys — has no backing WorldSchema row in v2; it is a contract-only design surface the schema does not yet carry. A game with no template fields emits an empty TemplateFields array so schema assembly stays uniform.

Base vs effective template value: Field accessors return the authored base value, and source template_field(Farm, GoldCost) / template_field(Dungeon, Placement) means base-only. A template-value resolver applies active modifier bindings to that base template value in a caller-supplied ordered context chain, e.g. template_field(Farm, GoldCost, context(@Settlement, @Territory)) for "Farm's GoldCost in this settlement and territory after contextual discounts." Additive/multiplicative runtime field effects are numeric-scalar only; whole-value HardOverride on structured fields is exact-SecsTypeRef only. A named contract profile such as BuildCostContext expands to the same ordered chain. That resolver reuses the modifier binding/effect machinery but is not the channel resolver and does not mutate the template's base field. Note: v2 ModifierEffectRow targets a ChannelId only, so template-field modifier effects are a design target not yet backed by the v2 schema. See 03-channels-and-modifiers.md § "Template-field modifier effects", 05-expressions.md § "Template value read", and 07-structured-template-data-and-callables.md.

Why this shape: Template fields are template-definition data, read before an instance exists, not channel-resolved values. GoldCost is the canonical example: it is a property of the Farm template, not an entity channel on a created Farm. The host can ask "what does this template cost?" before an instance exists; the channel resolver cannot, because resolution requires an entity/scope target and a channel pipeline. (The author-facing tooling surface — names, descriptions, clamps, localization keys — is a design target that has no v2 schema-row backing yet, as noted above.)

Field identifiers and channel identifiers share the global hash namespace for now. A source set that declares both field int GoldCost and channel int GoldCost is rejected to avoid ambiguous H.GoldCost, tooltip, override, and localization meanings. If the language later namespaces hashes by family, this rule can be relaxed deliberately; until then, field-vs-channel is a semantic split, not a spelling collision.

Naming decision: SecsScalarType is the channel-pipeline scalar enum. SecsTypeRef (src/SECS.Schema/Shared/SecsTypeRef.cs) is the general type model for fields and callables — two fields, SecsScalarType Scalar plus a ulong ReferenceId for Template / Contract / Tag references. Numeric scalar fields use the scalar members; structured fields point at declared types through the reference id (the structured-type catalog itself is a doc-07 design target not yet a WorldSchema row).

channel with source (Contributed, Base, Accumulative)

Per design discussion 2026-04-24, clarified 2026-05-01: kind = is explicit, Contributed has no host base, template intrinsic sources are restricted to the activation root, and cross-scope aggregation uses named modifiers. See §"Model: intrinsic sources are own-root, modifiers cross scopes" above for the canonical statement; this subsection covers the declaration-surface mechanics.

What: A channel declaration takes a C# type (one of int, long, float, double, bool — see §"Numeric type set" above), an identifier, a required kind = {Contributed | Base | Accumulative} clause, optional name / description display metadata, and optional clamps. The presence of source = <scope>.<field> binds the channel to a host-owned scope field so the engine can write back / read through. kind = is required as the first sub-option; the compiler rejects a channel declaration without it. This is the committed syntax per design discussion 2026-04-24 — the "infer kind from declaration shape" approach was rejected; kind is part of the declaration, not something to infer.

SECS:

// Settlement channels — aggregated from owned territories and buildings
channel int Gold {
kind = Accumulative;
name = "Gold";
description = "Coin and precious metal stockpiled by the settlement.";
source = Settlement.Gold;
min = 0;
}

channel int Morale {
kind = Contributed;
name = "Morale";
description = "The spirit and resolve of the people.";
source = Settlement.Morale;
min = 0;
max = 100;
}

channel int Population {
kind = Base;
name = "Population";
description = "The number of people sheltered by the settlement.";
source = Settlement.Population;
min = 0;
}

channel int FoodOutput { kind = Contributed; source = Territory.FoodOutput; }

Source: examples/valenar-v2/Content/settlement/channels.secs (abbreviated).

The declaration identifier (Gold) is the hash/localization-key seed. name and description are player-facing fallback text. If localization provides channel_gold or channel_gold_desc, those localized strings override the inline fallback; if the keys are absent or no localization provider is installed, the inline values are used.

Compiles to:

// ChannelRow positional fields:
// (Id, Kind, ScalarType, ClampMinFlag, ClampMaxFlag, ClampMin, ClampMax,
// SourceFieldId, TrackPrev, ExecutionLane, SourceFormulaId)
// SourceFieldId is a ChannelSourceFieldId (None = no host base); clamps are a
// single double pair gated by flags; SourceFormulaId is None for host-field/static channels.
public static ChannelRow[] Build() =>
[
// --- Accumulative: pure stockpiles, host is SOT, no modifiers ---
new ChannelRow(H.ChannelFood, ChannelKind.Accumulative, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Food, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelWood, ChannelKind.Accumulative, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Wood, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelMetal, ChannelKind.Accumulative, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Metal, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelStone, ChannelKind.Accumulative, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Stone, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelGold, ChannelKind.Accumulative, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Gold, true, ExecutionLane.NativeEligible, FormulaId.None),

// --- Base: host is SOT, modifiers CAN apply ---
new ChannelRow(H.ChannelPopulation, ChannelKind.Base, SecsScalarType.Int, true, false, 0, 0, H.Src_Settlement_Population, true, ExecutionLane.NativeEligible, FormulaId.None),

// --- Contributed: no host base (starts at 0), modifiers aggregate, SyncDirty writes ---
new ChannelRow(H.ChannelProduction, ChannelKind.Contributed, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Production, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelMorale, ChannelKind.Contributed, SecsScalarType.Int, true, true, 0, 100, H.Src_Settlement_Morale, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelPopulationCap, ChannelKind.Contributed, SecsScalarType.Int, true, false, 0, 0, H.Src_Settlement_PopulationCap, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelGarrison, ChannelKind.Contributed, SecsScalarType.Int, true, false, 0, 0, H.Src_Settlement_Garrison, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelDefense, ChannelKind.Contributed, SecsScalarType.Int, true, false, 0, 0, H.Src_Settlement_Defense, true, ExecutionLane.NativeEligible, FormulaId.None),
];

Source: compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/Channels.g.cs (Channels.Build()). Target row: ChannelRow (src/SECS.Schema/Rows/ChannelRow.cs — 11 positional fields: ChannelId Id, ChannelKind Kind, SecsScalarType ScalarType, bool ClampMinFlag, bool ClampMaxFlag, double ClampMin, double ClampMax, ChannelSourceFieldId SourceFieldId, bool TrackPrev, ExecutionLane ExecutionLane, FormulaId SourceFormulaId). The row carries no display metadata (Identifier / Name / Description are not on the row; localization keys derive from the canonical id). Clamps are a single double ClampMin / ClampMax pair gated by ClampMinFlag / ClampMaxFlag — not one pair per scalar. The host-field binding is the typed ChannelSourceFieldId SourceFieldId (None ⇒ no host base); SourceFormulaId and SourceFieldId are mutually exclusive (a non-None SourceFormulaId routes the base through the runtime formula registry). See §"Numeric type set" and §"Clamp literal types".

Why this shape: The engine can't resolve a channel until it knows (a) its type, for arithmetic; (b) where the phase-1 value comes from — the SourceFieldId host field, a SourceFormulaId delegate, or zero plus intrinsic sources; (c) whether modifiers apply, for phases 2-4; (d) whether to write back, for SyncDirty. ChannelKind encodes those behavioural bits with a single enum. Clamps at declaration time apply after HardOverride and are enforced per channel regardless of whether the phase-1 value comes from a host field, template intrinsic channel sources, or zero. Under the committed model (see §"Model: intrinsic sources are own-root, modifiers cross scopes" above), there is no generic summed-contribution language surface — cross-scope aggregation happens through modifiers.

Notes:

  • Whether kind = should also accept lowercase (kind = base) to reduce visual noise, or stay capitalised to mirror the ChannelKind.Base C# enum one-for-one. Current commitment: capitalised, for cross-language identity.

The "kind inference" line of questioning (source-less defaults, shape-based defaults, sum-of-contribution semantics) is closed as of 2026-04-24: the committed model makes kind part of the declaration, not something to infer, and removes contribution-summing as a language mechanism.

ChannelKind.Contributed

What: The channel has no host field base — the phase-1 value starts at 0, then adds any compiler-emitted intrinsic channel sources registered on that target by template activation. Active modifier effects apply after that and the declaration clamps the result. SyncDirty writes the resolved value back to the bound scope field so the host (UI, other systems) can read it. The source = ... binding is a write-back cache target, not a base-value source. Under the committed model (see §"Model: intrinsic sources are own-root, modifiers cross scopes"), this is the shape for channels whose value is engine-resolved rather than host-owned.

SECS (declaration only):

channel int Morale { kind = Contributed; source = Settlement.Morale; min = 0; max = 100; }
channel int PopulationCap { kind = Contributed; source = Settlement.PopulationCap; min = 0; }
channel int Defense { kind = Contributed; source = Settlement.Defense; min = 0; }

Source: examples/valenar-v2/Content/settlement/channels.secs (abbreviated).

Compiles to:

// (Id, Kind, ScalarType, ClampMinFlag, ClampMaxFlag, ClampMin, ClampMax, SourceFieldId, TrackPrev, ExecutionLane, SourceFormulaId)
new ChannelRow(H.Morale, ChannelKind.Contributed, SecsScalarType.Int, true, true, 0, 100, H.Src_Settlement_Morale, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.PopulationCap, ChannelKind.Contributed, SecsScalarType.Int, true, false, 0, 0, H.Src_Settlement_PopulationCap, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.Defense, ChannelKind.Contributed, SecsScalarType.Int, true, false, 0, 0, H.Src_Settlement_Defense, true, ExecutionLane.NativeEligible, FormulaId.None),

Source: compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/Channels.g.cs.

Why this shape: Contributed exists because some channels have no natural host base. Take Territory.FoodOutput — a territory does not own a standalone FoodOutput value in its own right; the value is provided by the active building template's intrinsic channel source and any modifiers. Similarly, settlement.Morale starts at zero and is built up from Houses, Temples, celebration modifiers, etc. through named modifiers. The source = binding gives the host a readable field (populated by SyncDirty) for UI and other systems, but no host code ever writes into it — the engine is authoritative.

Notes: None — resolved by the committed explicit kind = syntax and the own-root channel-source contract. The historical ambiguity (inferring Contributed vs Base from declaration shape, or "sum of template contributions" as a generic language mechanism) is closed; the author states the distinction directly, template-body channel sources are intrinsic to their activation root, and cross-scope aggregation is handled by modifiers.

ChannelKind.Base

What: The channel's base value is the host's scope field; modifiers apply on top at resolution time; SyncDirty does not write back (to avoid compounding the modifier effect every tick). Use for channels where the host is authoritative and modifiers are additive/multiplicative decorations on the host value.

SECS (declaration only):

channel int Population { kind = Base; source = Settlement.Population; min = 0; }

Source: examples/valenar-v2/Content/settlement/channels.secs.

Compiles to:

// --- Base: host is SOT, modifiers CAN apply ---
// (Id, Kind, ScalarType, ClampMinFlag, ClampMaxFlag, ClampMin, ClampMax, SourceFieldId, TrackPrev, ExecutionLane, SourceFormulaId)
new ChannelRow(H.ChannelPopulation, ChannelKind.Base, SecsScalarType.Int, true, false, 0, 0, H.Src_Settlement_Population, true, ExecutionLane.NativeEligible, FormulaId.None),

Source: compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/Channels.g.cs.

Why this shape: Population is a game-logic counter the host mutates through simulation events (births, deaths, immigration) — it has a natural host base that the engine does not own. But modifiers like a plague or a population-boost event should still register against it at resolution time. Base allows that without the write-back feedback loop.

Notes: None — resolved by the committed explicit kind = syntax. The "infer from whether any modifier targets the channel" alternative was rejected because it makes a channel's semantics depend on unrelated files, and removing the last modifier targeting it would silently flip the kind.

ChannelKind.Accumulative

What: The channel is a pure host-owned stockpile. Modifiers do not apply (modifiers should target rates, not reservoirs). SyncDirty does not write back. Use for resource stockpiles — gold, food, wood — where a ProductionRate * delta accumulates into the stockpile each tick and modifiers belong on the rate channels, not the pool.

SECS (declaration only):

channel int Gold { kind = Accumulative; source = Settlement.Gold; min = 0; }
channel int Food { kind = Accumulative; source = Settlement.Food; }
channel int Wood { kind = Accumulative; source = Settlement.Wood; }
channel int Metal { kind = Accumulative; source = Settlement.Metal; }
channel int Stone { kind = Accumulative; source = Settlement.Stone; }

Source: examples/valenar-v2/Content/settlement/channels.secs (abbreviated).

Compiles to:

// --- Accumulative: pure stockpiles, host is SOT, no modifiers ---
// (Id, Kind, ScalarType, ClampMinFlag, ClampMaxFlag, ClampMin, ClampMax, SourceFieldId, TrackPrev, ExecutionLane, SourceFormulaId)
new ChannelRow(H.ChannelFood, ChannelKind.Accumulative, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Food, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelWood, ChannelKind.Accumulative, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Wood, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelMetal, ChannelKind.Accumulative, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Metal, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelStone, ChannelKind.Accumulative, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Stone, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelGold, ChannelKind.Accumulative, SecsScalarType.Int, false, false, 0, 0, H.Src_Settlement_Gold, true, ExecutionLane.NativeEligible, FormulaId.None),

Source: compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/Channels.g.cs.

Why this shape: If a "Famine" modifier could halve Food, a player's stockpile would evaporate each tick the modifier is active. The designer intent is to halve the food production rate (a different channel) and let the stockpile drift. Accumulative enforces that distinction at the type level — the engine never applies modifiers even if a declaration mistakenly targets a stockpile.

Notes: None — resolved by the committed explicit kind = syntax. The "naming-convention" and "downstream-usage" alternatives were rejected: both are silent-flip hazards (rename a channel or remove the last modifier targeting it, and the kind changes invisibly).

Resource amount, capacity, and flow

What: A resource system is three different channels, not one overloaded channel:

ChannelSECS shapeWho owns itModifiers?Example
Amount / stockpilekind = Accumulative channel backed by a mutable scope fieldHost/game stateNoFood, Gold, Wood
Flow / ratekind = Contributed or kind = Base channelEngine if aggregate, host if baseYesFoodOutput, FoodConsumption, GoldIncome
Capacity / storage limitSeparate kind = Contributed or kind = Base channelEngine if aggregate, host if baseYesFoodCapacity, GoldCapacity

The amount is the player's current stockpile. It changes because systems/events increment or spend it. The capacity is a resolved limit used by those systems when deciding how much of a write is allowed. A Storehouse does not modify Food; it attaches a named modifier to FoodCapacity. A famine does not halve Food; it modifies FoodOutput or FoodConsumption. This keeps "how much I have" separate from "how fast it changes" and "how much I can hold".

SECS:

scope Settlement
{
int Food; // current amount
int FoodCapacity; // cached resolved capacity for UI/host reads
int FoodOutput; // cached resolved production rate
}

channel int Food {
kind = Accumulative;
name = "Food";
description = "Stores of grain, meat, and foraged provisions.";
source = Settlement.Food;
min = 0;
}

channel int FoodCapacity {
kind = Contributed;
name = "Food Capacity";
description = "The maximum food this settlement can store.";
source = Settlement.FoodCapacity;
min = 0;
}

channel int FoodOutput {
kind = Contributed;
name = "Food Production";
description = "Food produced each day.";
source = Settlement.FoodOutput;
min = 0;
}

modifier StorehouseFoodCapacity
{
translation = "Storehouse";
stacking = stackable;
FoodCapacity += 100;
}

modifier Famine
{
translation = "Famine";
tags = Debuff;
FoodOutput *= 50%;
}

This snippet shows a settlement-level aggregate resource model. A game may keep rates on another scope instead — Valenar currently keeps FoodOutput on territory and rolls it into settlement stockpiles in a system — but the split is the same: amount, rate, and capacity stay separate.

System-side rule: Resource writers clamp against capacity at the point they write the amount. With the command surface that means calculating a safe delta before appending an increment command:

var current = ctx.FieldBridge.ReadInt(settlement, H.Settlement, H.Food);
var capacity = (int)ctx.Channels.Resolve(settlement, H.ChannelFoodCapacity); // ChannelResolverV2.Resolve returns double
var accepted = Math.Max(0, Math.Min(producedFood, capacity - current));

ctx.Commands.IncrementScopeField(settlement, H.Settlement, H.Food, accepted);

Spending uses the same principle in the other direction: reject the spend or clamp the negative delta so the stockpile never drops below the declared minimum. Host code may enforce its own invariants as a final safety net, but game logic should still ask the resolved capacity channel when deciding whether production can be stored.

Why capacity is a channel, not a field: A top-level field is template-definition data such as GoldCost; it can be read before an instance exists. Resource capacity is instance/game-state data: it depends on the settlement, its buildings, laws, ownership, difficulty, temporary effects, and mods. That makes it a runtime channel. If a particular game has a fixed host-authored storage limit, declare capacity as kind = Base; if capacity is the pile of Storehouses, laws, and modifiers, declare it as kind = Contributed.

Notes: None for the generic model. Individual games still decide which resource families need capacity channels, which capacity names they expose, and whether their production system discards overflow, stores overflow elsewhere, or queues it as a warning/event.

Channel with no source

What: A channel declaration without a source = clause is valid only for kind = Contributed (per the kind = Base / kind = Accumulative require-source = rules above). It declares a runtime channel with no host field binding — a value that can still be resolved on an entity and affected by modifiers, but has no scope field for host read/write-back. This is not the declaration primitive for costs, durations, radii, or other template-definition data; those use the top-level field declaration above and template-body field int X = N.

SECS:

// Runtime feature channel with no host-backed storage field.
channel int ThreatLevel {
kind = Contributed;
name = "Threat Level";
description = "How dangerous this feature is when resolved at runtime.";
min = 0;
}

channel int LootValue {
kind = Contributed;
name = "Loot Value";
description = "The reward value available on this feature.";
min = 0;
}

Valenar's current feature content uses this source-less contributed-channel shape for feature-only values such as ThreatLevel, LootValue, ResearchBonus, MoraleBonus, MineralBonus, and DefenseBonus.

Compiles to:

// (Id, Kind, ScalarType, ClampMinFlag, ClampMaxFlag, ClampMin, ClampMax, SourceFieldId, TrackPrev, ExecutionLane, SourceFormulaId)
new ChannelRow(H.ChannelThreatLevel, ChannelKind.Contributed, SecsScalarType.Int, true, false, 0, 0, ChannelSourceFieldId.None, true, ExecutionLane.NativeEligible, FormulaId.None),
new ChannelRow(H.ChannelLootValue, ChannelKind.Contributed, SecsScalarType.Int, true, false, 0, 0, ChannelSourceFieldId.None, true, ExecutionLane.NativeEligible, FormulaId.None),

Note: SourceFieldId is ChannelSourceFieldId.None (no source = clause), so there is no host base; Kind is ChannelKind.Contributed; ClampMinFlag = true with ClampMin = 0 carries the min = 0 clamp. The row stores no display strings — the channel's name and localization keys derive from its canonical id string.

Why this shape: A source-less channel is still a channel because the author intends to call resolve(ThreatLevel), let modifiers affect it, clamp it in the channel pipeline, and show it in channel-aware tooltips. It still lives in the global Channels array so name/hash/type are centralised, but no pipeline write-back happens. Template-definition data does not meet that bar: GoldCost must be available before an entity exists, so it belongs to the field system.

Internal flag (design target, no v2 row backing): A per-channel "internal/synthesized" marker is a design target reserved for compiler-synthesized channels — those the compiler distinguishes from author-facing channels (type-discriminator hashes and other mechanical artefacts that must not appear in tooltips). v2 ChannelRow (src/SECS.Schema/Rows/ChannelRow.cs) carries no Internal field today — the 11 fields are identity, kind, scalar type, the clamp pair + flags, source-field id, TrackPrev, ExecutionLane, and SourceFormulaId. User-declared .secs channels are never internal; internal = true syntax in .secs is deferred until a concrete shipping-game use case emerges. Adding the marker later is a non-breaking row extension.

Mod scope note (Internal is base-only): All user-declared channels — base or mod — are author-facing. The internal/synthesized distinction is reserved for compiler-synthesised channels and is not reachable via .secs surface in either source set; since v2 ChannelRow carries no Internal field yet, there is no row slot a mod could set.

Mod scope note (kind = requirement applies cross-source-set): The required-kind = check (reserved — no shipped owner) fires on any top-level channel declaration without kind =, regardless of which source set authored it (base or any mod). There is no source-set-conditional relaxation. Channel declarations themselves are not replaceable — a mod's attempt to write replace channel int Population { kind = Base; } is SECS0602 (the mod is treating a channel declaration as a slot, but channels are create-only at the language surface). Cross-reference 06-overrides-and-modding.md § "What's NOT overridable". To redirect a base channel's source or clamps, a mod declares its own channel under a fresh name and arranges the host bridge accordingly.

Notes:

  • (Closed 2026-04-24) min / max clamps lower uniformly regardless of whether the channel has a source =. The compiler emits ClampMinFlag = true, ClampMin = N and ClampMaxFlag = true, ClampMax = M for every declared clamp. Clamps are value constraints applied in the clamp phase of the resolution pipeline, independent of source.
  • The "no default kind" question is closed: kind = is required on every top-level channel, source-less or sourced. No silent Contributed fallback.
  • Source-less Contributed channels are scope-agnostic channel identities: they can be resolved on any entity target, have no host cache field (SourceFieldId = ChannelSourceFieldId.None), and still receive modifiers. The shipped SECS0303 (UnknownChannelSourceScope) scope check applies when a channel has source = <scope>.<field>; for source-less channels, template-body intrinsic sources are kept legal by the runtime/compiler rule that intrinsic channel sources target only the activation root.
  • A fourth ChannelKind for compiler-internal channels was considered and rejected. v2 ChannelKind (src/SECS.Schema/Enums/ChannelKind.cs) is the closed three-member set Base / Accumulative / Contributed; the internal/synthesized distinction is a deferred per-channel marker (see the Internal flag paragraph above), not a ChannelKind value.

Clamp literal types (committed 2026-04-24)

What: min = and max = clauses accept a single literal whose compile-time type must exactly match the channel's declared type. A mismatch is a compile error under the clamp literal type check (reserved — no shipped owner; the shipped SECS0306 UnsupportedChannelLiteral covers unsupported literals for channel properties). bool channels may not declare min or max at all — booleans have no ordering, so clamps are meaningless and the compiler rejects them outright.

SECS (each channel shown with its accepted literal forms):

channel int Gold { kind = Accumulative; source = Settlement.Gold; min = 0; max = 1_000_000; }
channel long TotalXp { kind = Base; source = Character.TotalXp; min = 0L; max = 9_000_000_000L; }
channel float Fertility { kind = Base; source = Territory.Fertility; min = 0.0f; max = 1.0f; }
channel double WorldTime { kind = Base; source = World.WorldTime; min = 0.0; max = 1.0e9; }
channel bool IsFounded { kind = Contributed; source = Settlement.IsFounded; } // NO min / max allowed

Compiles to: The literal-type match is a source-level check; the stored clamp is always the single double ClampMin / ClampMax pair gated by ClampMinFlag / ClampMaxFlag (the literal is widened to double for storage, regardless of the channel's ScalarType):

// (Id, Kind, ScalarType, ClampMinFlag, ClampMaxFlag, ClampMin, ClampMax, SourceFieldId, TrackPrev, ExecutionLane, SourceFormulaId)

// int channel — int literals checked at source; stored in the double clamp pair
new ChannelRow(H.ChannelGold, ChannelKind.Accumulative, SecsScalarType.Int, true, true, 0, 1_000_000, H.Src_Settlement_Gold, true, ExecutionLane.NativeEligible, FormulaId.None),

// long channel — long literals checked at source; stored as double
new ChannelRow(H.ChannelTotalXp, ChannelKind.Base, SecsScalarType.Long, true, true, 0, 9_000_000_000d, H.Src_Character_TotalXp, true, ExecutionLane.NativeEligible, FormulaId.None),

// float channel — float literals checked at source; stored as double
new ChannelRow(H.ChannelFertility, ChannelKind.Base, SecsScalarType.Float, true, true, 0.0, 1.0, H.Src_Territory_Fertility, true, ExecutionLane.NativeEligible, FormulaId.None),

// double channel — double literals checked at source; stored as-is
new ChannelRow(H.ChannelWorldTime, ChannelKind.Base, SecsScalarType.Double, true, true, 0.0, 1.0e9, H.Src_World_WorldTime, true, ExecutionLane.NativeEligible, FormulaId.None),

// bool channel — both clamp flags false (booleans reject min/max at source)
new ChannelRow(H.ChannelIsFounded, ChannelKind.Contributed, SecsScalarType.Bool, false, false, 0, 0, H.Src_Settlement_IsFounded, true, ExecutionLane.NativeEligible, FormulaId.None),

Target row ChannelRow at src/SECS.Schema/Rows/ChannelRow.cs carries a single double ClampMin / ClampMax pair gated by ClampMinFlag / ClampMaxFlag — every numeric scalar's clamp widens into that one double pair. bool channels leave both flags false (no clamp).

Diagnostic — clamp literal type check (reserved — no shipped owner):

channel <name>: clamp literal type does not match channel type.
channel <int|long|float|double> expects <int|long|float|double> literals.
bool channels do not support min / max clamps.

Trigger examples:

  • channel int Gold { min = 0.0; } — float literal on int channel → error.
  • channel float Fertility { max = 1; } — int literal on float channel → error (must write 1.0f or 1.0; the compiler reads the literal form, not the value).
  • channel long TotalXp { min = 0; } — int literal on long channel → error (must write 0L).
  • channel bool IsFounded { min = false; } — bool channel with any clamp → error.

Why this shape: Literal-type strictness at declaration forces the designer to state the channel's numeric width explicitly at every clamp site. A permissive regime (int literal on long channel implicitly widens; float literal on double channel implicitly widens) papers over declaration-type drift: if a channel's width changes from int to long, every min = 0; max = 100; clause silently keeps compiling with the int interpretation, and a 64-bit overflow case that the widening was meant to fix stays unfixed. Strict matching surfaces the cross-cutting change as a compile error the author must resolve by updating every clamp.

The bool-clamps-rejected rule is symmetric: booleans are unordered, so min / max have no semantic meaning. A permissive compiler that accepted channel bool X { min = false; } would store a clamp that never fires, which is silent-dead-code the type system should reject.

Notes: None at the type-strictness level. Literal-form conventions (whether 0 is an int literal or an untyped numeric constant that widens to long on demand) follow C#'s literal-suffix rules verbatim: 0 = int, 0L = long, 0f / 0.0f = float, 0d / 0.0 = double.

range = sugar status (deferred, 2026-04-24)

A range = 0..100 clause — sugar over min = 0; max = 100; — was considered and deferred indefinitely. The core min = / max = syntax is the committed complete form; sugar is an optional convenience, not a missing piece of architecture. No range keyword is reserved, no new diagnostic is added. If sugar is introduced later, it desugars to the same min / max pair at parse time — a purely additive, non-breaking change.

This is not a tenet violation: the core surface is the COMPLETE correct form (per CLAUDE.md — "the right amount of complexity is what the task actually requires, no speculative abstractions" is overridden here by the tenets themselves, which call for full algorithms; min / max literal-strict clamps are the full algorithm, range sugar is orthogonal ergonomics).

ChannelKind decision table

The three ChannelKind values differ on three orthogonal axes: where the base value comes from, whether modifiers apply at resolution time, and whether SyncDirty writes back to the host. The enum ChannelKind at src/SECS.Schema/Enums/ChannelKind.cs (Base=0, Accumulative=1, Contributed=2) captures the combinations the engine supports:

ChannelKindBase value sourceModifiers apply?SyncDirty writes back?Canonical use
ContributedNo host field base; starts at 0, value is the modifier pile on topYesYesEngine-authoritative channels built up purely from modifiers (morale, production, garrison, defense, territory output channels). Host reads resolved value via scope field.
BaseHost-owned scope field (host mutates directly)YesNoHost-authoritative channels that accept modifier decorations (population, character level).
AccumulativeHost-owned scope fieldNoNoHost-authoritative stockpiles that modifiers must not touch (gold, food, wood, metal, stone).

.secsChannelKind mapping (committed): the compiler requires explicit kind = {Contributed | Base | Accumulative} on every top-level channel declaration. There is no inference and no default.

Channel-declaration diagnostics:

The shipped SECS01xx band belongs to the fork's scope binder: SECS0101 (DuplicateScopeField), SECS0102 (UnsupportedScopeFieldType), SECS0103 (DuplicateScopeWalk), SECS0104 (UnknownWalkTarget), and SECS0105 (InvalidScopedCollectionType). Related shipped channel-binder codes live in the SECS03xx band (SECS0301-SECS0306: unsupported type/kind, unknown source scope/field, source type mismatch, unsupported literal). The authoritative allocation table is SECS-Compiler-Plan.md § Diagnostic Code Catalog.

The channel-declaration rules below are committed language rules whose compiler diagnostics are reserved with no shipped owner and no allocated code; a code is assigned in the catalog when the owning compiler phase lands.

Rule (reserved — no shipped owner)WhenMessageCross-source-set behaviour
kind = clause requiredkind = clause missing`channel declaration requires kind = {ContributedBase
kind = Base requires source =kind = Base without source =channel kind = Base requires a source = clause. For channels with no host field, use kind = Contributed.Fires per-set.
kind = Accumulative requires source =kind = Accumulative without source =channel kind = Accumulative requires a source = clause. Accumulative channels are host-owned stockpiles; a source is required to identify the storage field.Fires per-set.
Case-insensitive hash-collision checkTwo identifiers hash-collide under case-insensitive loweringidentifier collision: '<A>' and '<B>' produce the same FNV-1a-64 hash. Rename one.Fires across base ∪ mods union (see §"Case-collision rule" mod scope note). The shipped canonical-identity collision check is SECS0601 (HashCollision).
Cross-assembly redeclaration warningCross-assembly redeclaration of an identifier(warning) identifier '<Name>' is already declared in assembly '<Other>'. Use the appropriate layered-mod operation if you meant to patch an existing declaration; remove the redundant declaration otherwise.Fires when a mod redeclares a base name without an explicit mod operation. Two mods redeclaring the same name (no base) escalate to the shipped SECS0602 (DuplicateDeclaration) — see §"Cross-assembly same-name rule" mod scope note.
Clamp literal type checkClamp literal type does not match channel type (or bool channel has min / max)`channel : clamp literal type does not match channel type. channel <intlong
Walk-use validationWalkScope(from, ToScope) / to.X targets a scope not declared in from's walks_to listscope walk from '<FromScope>' to '<ToScope>' is not declared. Traversable via walks_to from '<FromScope>': { <list of declared targets> }. If '<ToScope>' should be reachable, it must be declared by the base game or an enabled official expansion.Fires across the effective host-capable scope graph: third-party mods may consume declared edges, but they do not contribute new host topology. The shipped SECS0104 (UnknownWalkTarget) covers unknown walks_to declaration targets; walk-use validation at call sites remains reserved.

Error-code convention: the digit prefix is a navigation hint, not a home-doc guarantee. The full band map, per-code identifiers, and shipped/reserved status live in SECS-Compiler-Plan.md § Diagnostic Code Catalog; new codes are allocated there, never inline in a design doc.

The naming-convention table below is historical — it describes decisions once encoded in the retired checked-in Valenar channel stand-in; current channel-row authority is compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/Channels.g.cs:

  • Resource stockpiles (Gold / Food / Wood / Metal / Stone) — treated as Accumulative
  • Simulation counter (Population) — treated as Base
  • Modifier-aggregated settlement values (Morale, Production, PopulationCap, Garrison, Defense) — treated as Contributed (no host base; value is the modifier pile)

These are the earlier generated-output intent captured as data. Once the compiler and channels.secs carry the explicit kind = clauses, this table becomes redundant and should be deleted.

Why Contributed exists as a distinct kind: Some channels have no natural host base — the scope does not own a meaningful value for the channel in its own right. Territory.FoodOutput is the archetype: a territory does not hold a standalone FoodOutput number; the number comes from the active territory-root template intrinsic channel source and any modifiers. settlement.Morale is the same zero-base shape, with Houses / Temples / Celebrations attaching named stackable modifiers. Contributed encodes "no host base; value starts at 0, adds own-root intrinsic channel sources and modifier effects, and may write the resolved value back so the host can display it."

Why Accumulative exists as a distinct kind: Resource stockpiles are the one place where a modifier acting on the channel would be catastrophic. A "famine" modifier that multiplies Food by 0.5 would halve the player's stockpile every single tick as long as it's active — the stockpile evaporates exponentially. The correct modelling is "famine multiplies FoodOutput (the rate) by 0.5, the rate-integration keeps feeding the stockpile at half speed, and the stockpile is never directly scaled". Accumulative enforces that at the type level by silently dropping any modifier effect that targets the channel.

Why Base exists as a distinct kind: Some channels must be host-owned (population comes from simulation events, not from aggregated modifier contributions) but still participate in the modifier system (a plague modifier should show up when resolving Population for UI display). Base is the combination "host SOT, modifiers applied on top, no write-back" — the write-back is skipped because re-writing the modifier-adjusted value back to the host field would make the next tick's resolution double-count the modifier.

Notes:

  • (Closed 2026-04-24) Both kind = Base and kind = Accumulative now require source = <scope>.<field>. Compile errors (both rules reserved — no shipped owner; see §"Channel-declaration diagnostics"):
    • channel kind = Base requires a source = clause. For channels with no host field, use kind = Contributed.
    • channel kind = Accumulative requires a source = clause. Accumulative channels are host-owned stockpiles; a source is required to identify the storage field. Source-less declarations are valid only for kind = Contributed.
  • (Closed) The "inference rules are fragile" question is resolved by the committed explicit kind = syntax: misclassification is now a source-file edit, not a silent naming-heuristic flip.

The H.* hash convention

Every named identifier in a SECS world — scope names, field names, channel names, contract names, method names, scoped collection names, modifier names, template names, activities, policies, needs, selectors, rules, slot kinds — is identified at runtime by a 64-bit unsigned integer produced by FNV-1a-64 over a canonical id string. The compiler-emitted Hashes.g.cs table under the generated project obj/**/SecsGenerated output provides the H.* constants for generated and host consumers.

Canonical id string format

The canonical id string is the single source of truth for an identifier's hash. It is what TemplateId.Create, ChannelId.Create, TagId.Create, ActivityId.Create, PolicyId.Create, NeedId.Create, SelectorId.Create, RuleId.Create, and SlotKindId.Create hash; what the SECS compiler emits; what the localization resolver looks up; and what the compiler-emitted H.<Name> constants commit to. The authority for the live format is the compiler-emitted Hashes.g.cs table under the generated project obj/**/SecsGenerated output (for Valenar, examples/valenar-v2/Generated/obj/**/SecsGenerated/Hashes.g.cs). The format the compiler emits is colon-slash, owner-less, and verbatim on the declared name:

<namespace>:<kind>/<Name>
  • <namespace> is the source-set namespace. valenar for engine-shipped Valenar content; mod-owned ids use the mod's source-set namespace (e.g. a rule in a mod whose source set is cautious_survival emits rule:cautious_survival:policy/CharacterSurvival.FleeOnLowHp — the namespace sits inside the kind-prefix sub-id form described below).
  • <kind> is the SECS construct kind: template, contract, scope, channel, template-field, modifier, system, event, on_action, activity, policy, formula, fail-reason, plus the type/... type-id family. The string channel is the canonical-id kind for channel declarations; the C# type carrying the id is ChannelId (src/SECS.Core/Ids/).
  • <Name> is the declaration's name reproduced verbatim, in the PascalCase the source declares (Watchtower, MainCharacter, HP_Current, Mana_Regen). The compiler does NOT lower-case the name into snake_case and does NOT insert an owner/category segment.

The full string is hashed verbatim with FnvHash.Compute. The hash is case-insensitive (FNV-1a-64 lowers ASCII uppercase before mixing); the case-insensitivity is a defensive safety property over the verbatim PascalCase string, not a relied-on normalization to a lower-case form.

Sub-id and prefix forms

Substructure ids that belong to a parent declaration are emitted in one of two committed shapes, both verbatim on names:

  • Dotted-suffix sub-id — the parent canonical id, a ., and a <sub-kind>:<Member> qualifier:
    • scope field: valenar:scope/Building.scope-field:JobsFilled
    • scope collection: valenar:scope/Territory.scope-collection:Features
    • channel-modifier on a template: valenar:template/MainCharacter.channel-modifier:Attack
    • channel-formula on a template: valenar:template/Scouting.channel-formula:XPThreshold
    • type member: type/FeatureUnlock.Kind
  • Kind-prefix sub-id — the sub-kind, a :, then the owning declaration's canonical id and a .<Member> qualifier:
    • contract method: contract-method:valenar:contract/Building.OnBuilt():void
    • activity method: activity-method:valenar:activity/Rest.OnStart()
    • event option: event-option:valenar:event/RoadsideEncounter.OptionInvestigate
    • modifier-effect formula: formula:modifier:valenar:modifier/Fortified.Defense.1
    • policy substructure (need / selector / rule), where the owner segment names the owning policy: need:valenar:policy/CharacterSurvival.StaminaUrgency, selector:valenar:policy/CharacterSurvival.RestSelector, rule:valenar:policy/CharacterSurvival.CheckStamina

Namespace-less type ids

Type ids carry no source-set namespace. They are emitted under a bare type/ kind with the C# type name reproduced verbatim, generic arguments included:

  • type/FeatureDiscoveryState
  • type/IReadOnlyList<FeatureBlocker>
  • type/IReadOnlyDictionary<TemplateId,ResourceIOEntry>

Examples, reproduced verbatim from the emitted Hashes.g.cs authority:

  • valenar:template/Farm
  • valenar:template/BareRegion
  • valenar:template/DriedRations
  • valenar:contract/Building
  • valenar:scope/Settlement
  • valenar:channel/Attack
  • valenar:channel/Food
  • valenar:channel/HP_Current
  • valenar:template-field/GoldCost
  • valenar:modifier/HouseMoralePresence
  • valenar:template/MainCharacter.channel-modifier:Attack
  • valenar:system/BuildingProduction
  • valenar:event/RoadsideEncounter
  • valenar:on_action/endurance_drill_completed
  • valenar:activity/Rest
  • valenar:policy/CharacterSurvival
  • need:valenar:policy/CharacterSurvival.StaminaUrgency
  • selector:valenar:policy/CharacterSurvival.RestSelector
  • rule:valenar:policy/CharacterSurvival.CheckStamina
  • valenar:formula/Formula_DodgeFromClimbing
  • type/FeatureDiscoveryState

The single token the compiler emits in lower snake_case is the on_action name (valenar:on_action/endurance_drill_completed), which is authored that way in source. Every other id reproduces the declared name verbatim.

An owner-segmented snake_case form (valenar:template/building/farm, valenar:channel/settlement/food) was never implemented by the compiler and is not a live identity target. It is recorded as a never-implemented form in ADR-0003; it is not pursued because changing the id string changes the FNV-1a-64 input and would force the explicitly-deferred save/load migration. Bare-name ("Farm", "HP_Regen") and dot-form ("valenar.building.farm") hashes are likewise not live identity targets. Compiler-emitted Hashes.g.cs entries for current Valenar v2 content come from the colon-slash verbatim canonical id strings above; an owner-segmented, snake_case, bare-name, or dot-form identity is a compiler bug or an explicitly documented future-work gap, not a checked-in Generated fallback.

FNV-1a-64, case-insensitive

What: The hash function is FNV-1a-64 (offset basis 14695981039346656037 / 0xCBF29CE484222325, prime 1099511628211 / 0x100000001B3), applied byte-by-byte to each character after lowering ASCII uppercase letters ('A'..'Z' → 'a'..'z'). Non-ASCII code points pass through unchanged. The output is a ulong. The hash is identical to the function secs-roslyn will use, so a canonical id string authored in .secs source resolves to the same ulong here as in compiled code.

SECS: The hash is never written literally in .secs; identifiers are written by name and (when explicit) by canonical id string passed to a typed-id .Create() factory.

Compiles to (DESIGN TARGET — engine-side update required, see below):

public static class FnvHash
{
private const ulong FnvOffsetBasis = 0xCBF29CE484222325UL; // 14695981039346656037
private const ulong FnvPrime = 0x100000001B3UL; // 1099511628211

public static ulong Compute(ReadOnlySpan<char> text)
{
var hash = FnvOffsetBasis;
for (int i = 0; i < text.Length; i++)
{
var c = text[i];
// Lowercase ASCII for case-insensitive hashing
if (c >= 'A' && c <= 'Z')
c = (char)(c + 32);
hash ^= c;
hash *= FnvPrime;
}
return hash;
}

public static ulong Compute(string text) => Compute(text.AsSpan());
}

Implementation status: src/SECS.Core/Hashing/Fnv1a64.cs implements FNV-1a-64 with the constants above and returns ulong. The identifier surfaces that consume hashes — the src/SECS.Core/Ids/ typed-id wrappers (ChannelId, ScopeId, ScopeFieldId, ContractId, TemplateId, FormulaId, …), the compiler-emitted H.* constants in examples/valenar-v2/Generated/obj/**/SecsGenerated/Hashes.g.cs, and the host-bridge signatures in src/SECS.Runtime/HostBridges/ — all carry the ulong hash value.

Why this shape: Mod-scale composition is the design constraint, not the Valenar-only identifier count. A shipped grand-strategy game with Steam Workshop coverage routinely runs base + ~80 mods producing on the order of 3,000 distinct identifiers in the merged registry. Birthday-paradox collision probability on this set is approximately:

identifier countFNV-1a-32 collision PFNV-1a-64 collision P
200 (Valenar-only)≈ 5 × 10⁻⁶≈ 1 × 10⁻¹⁵
3,000 (base + ~80 mods)≈ 5 × 10⁻² (~5%)≈ 5 × 10⁻¹¹
100,000 (extreme mod scale)effectively certain≈ 5 × 10⁻⁶

A 5% collision rate at mod scale is a production-blocking architectural defect — every published modpack would carry a one-in-twenty chance of a hash collision somewhere in the merged registry, and the merger would surface it as the shipped SECS0601 (HashCollision) at build time forcing modpack authors to rename third-party identifiers they don't own. FNV-1a-64 reduces the same probability to ~10⁻¹¹, safe at any plausible mod scale. The earlier 32-bit reasoning ("readable constants, fits in single register") is wrong under mod-scale: 64-bit literals are still readable (0xC0FFEE0123456789UL) and fit in a single 64-bit register on every modern platform (x86_64, ARM64, RISC-V). Case-insensitive lowering matches the localization resolver so settlement.Gold, Settlement.gold, and settlement.gold all produce the same H.Gold — the spec treats identifiers as case-preserving at declaration and case-insensitive at lookup.

Case-collision rule (committed 2026-04-24): Because FNV-1a-64 lowers ASCII uppercase before hashing, two declarations differing only in case produce the same hash ("Gold" and "gold" both hash to H.Gold). The compiler rejects this at declaration time with the case-collision check (reserved — no shipped owner; the shipped canonical-identity collision check is SECS0601 HashCollision):

identifier collision: 'Gold' and 'gold' produce the same FNV-1a-64 hash under case-insensitive lowering. Rename one.

The check is implemented via a Dictionary<ulong, string> the compiler maintains while processing declarations; when a new declaration's hash is already present with a different string, the compiler emits the case-collision diagnostic and cites both source locations.

Mod scope note (case-collision check cross-source-set): The check fires across the union of base and every loaded mod's declarations. Two mods whose declarations collide under case-insensitive lowering both error; the base may not shadow a mod's declaration via case either. The merger holds the union dictionary; both colliding source sets receive the diagnostic and the merger aborts.

Cross-assembly same-name rule (committed 2026-04-24): Two assemblies (base game + mod, or two mods) that declare a channel / modifier / template / etc. with the same name share one FNV-1a-64 hash and therefore one runtime identity. This is intentional — mods reference base-game identifiers naturally without qualification. Redundant redeclaration emits the cross-assembly redeclaration warning (reserved — no shipped owner) rather than an error, because the layered-mod operation system (Phase 3 of SECS-Compiler-Plan.md) is the correct path for intentional patching:

(warning) identifier 'Gold' is already declared in assembly 'Valenar.Content' at channels.secs:3. If you intended to reuse the existing declaration, this statement is redundant and can be removed. If you intended to patch an overridable declaration, use the appropriate explicit mod operation instead of a plain redeclaration.

Mod scope note (cross-assembly redeclaration warning — three cases): The cross-assembly case splits by source-set provenance:

  1. Mod declares a name already in base, no explicit mod operation → the reserved cross-assembly redeclaration warning. The mod's redeclaration is treated as redundant; intentional patching requires an explicit layered-mod operation.
  2. Mod declares a name already in another mod, no explicit mod operation → the shipped SECS0602 (DuplicateDeclaration) — see 06-overrides-and-modding.md § "Conflict detection". The two mods must coordinate or one must use the appropriate explicit mod operation against the earlier-loaded declaration. The redeclaration warning does not fire because there is no clearly-prior base declaration to reuse.
  3. Mod uses an explicit mod operation on a name in another mod (not base) → well-defined; last mod in load order wins by the standard load-order resolution. Conflict-report entry produced for transparency.

A two-mod identical declaration without an explicit mod operation is therefore not the redeclaration warning — it is the shipped SECS0602 (DuplicateDeclaration) because the merger has no canonical "earlier" target to flag as the redundant baseline.

Notes:

  • (Closed 2026-04-24, Finding 12 of mod-coverage audit; implemented 2026-04-25) Hash collisions between semantically distinct names. The 32-bit width's ~5% collision probability at base + ~80-mod scale (3,000 identifiers) was a production-blocking defect; FNV-1a-64 reduces the same probability to ~10⁻¹¹.
  • The H.* class uses ulong constants. FnvHash.Compute returns ulong. All the ChannelId / ScopeId / FieldId fields on the Declaration structs are ulong. Consistent.

H.* table

What: The compiler emits one public const ulong <Name> = 0x<hex>UL; line per identifier it encounters. The file groups entries by subsystem (scopes, scope fields, contracts, method names, modifiers, templates, systems, …) but the grouping is cosmetic — all constants live on one flat static class, Valenar.V2.Generated.Hashes (the running examples in this doc abbreviate it as H.*).

SECS: not written literally.

Compiles to:

public static class H
{
// Entity types / scopes
public const ulong Settlement = 0xFDDEA81D4D0E990AUL;
public const ulong Building = 0xFAE140BA7FDA6475UL;
public const ulong BuildOrder = 0xF06B81541046E5E5UL;

// Map hierarchy scopes (Region > Area > Province > Territory)
public const ulong Region = 0xC755A623F50A24DDUL;
public const ulong Area = 0x89502E843EC2B8C4UL;
public const ulong Province = 0x9DFA458C4A0F0E85UL;
public const ulong Territory = 0xE0ED86B180C0E829UL;

// ...
}

Source: compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/Hashes.g.cs.

Why this shape: Three reasons.

  1. Identity across boundaries. The engine's storage (DirtySet, ChannelCache, ModifierBindingStore, ScopeGraph) keys on ulong. Host bridge signatures are (ulong entityId, ulong channelId). The constants let every layer refer to the same identity without stringly-typed lookups at hot paths.
  2. Compile-time validation. When a modifier declares Gold += 10;, the compiler can verify that Gold is a known channel id. A string literal "Gold" would only fail at runtime.
  3. Deterministic localization. LocalizationResolver reverses hashes to display names by looking up a LocKey (wrapping the same ulong) against a YAML table. Same hashes on both sides of the bridge means zero synchronisation.

Notes:

  • Two identifiers whose names differ only in ASCII case collide to the same constant. The compiler must reject the second declaration (name conflict) rather than emitting two constants with the same value.
  • None. The retired hand-authored Hashes.cs fixtures used FNV-1a-64 values and did not carry hand-picked sentinels.

Where the compiler emits H.*

What: The compiler walks every .secs file in a project, collects every identifier referenced (declaration names, field names, method names, contract names, template names, system names, modifier names, formula names, scoped collection names), and emits one Hashes.g.cs file per output assembly with the sorted, grouped constants.

SECS: not written literally.

Compiles to: examples/valenar-v2/Generated/obj/**/SecsGenerated/Hashes.g.cs — one emitted file, one class, one flat list.

Why this shape: Single file minimises cross-project drift. One class (Hashes, abbreviated H in examples) gives every generated line a short prefix (H.Gold vs Valenar.V2.Generated.Channels.Gold) that keeps declaration bodies readable. Constants rather than a Dictionary<string, ulong> means zero allocation at schema-assembly time and zero runtime hash computation for compiler-known identifiers.

Notes:

  • Cross-project identifier visibility: if two assemblies each define a channel named Gold, they share one hash only when they intentionally emit the same canonical id string. Source-set namespaces (valenar:channel/Gold vs my_mod:channel/Gold) make accidental short-name reuse distinct by construction; deliberate replacement uses the mod-operation merge rules rather than a second bare-name hash.
  • EntityHandle identifiers are also ulong but they are entity instance ids (allocated at runtime), not identifier hashes. The two ulong spaces are disjoint by construction — entity ids never collide with identifier hashes because the engine allocates entity ids in a different space and the registry never compares them.

Collision handling at schema-validation time

What: Schema assembly builds the row tables addressed by id. If two declarations collide on the same id (different names that hash to the same value, or two declarations of the same name), WorldSchema.Validate() rejects the schema at boot before any tick runs.

SECS: not written literally.

Compiles to: Not shown in this doc's reference rows — the integrity check lives in WorldSchema.Validate() (src/SECS.Schema/WorldSchema.cs), called from the SecsRuntimeBuilder constructor (src/SECS.Runtime/Boot/SecsRuntimeBuilder.cs) before any allocation.

Why this shape: Catching the collision at boot — before any tick runs — means a misnamed channel cannot silently corrupt another channel's resolution. Pushing the check to compile time (in the future compiler) catches it even earlier, but the schema-validation check remains the last line of defence for malformed merged declaration sets or host-bug schemas.

Notes:

  • When a layered mod operation intentionally replaces a declaration in a replaceable family, the compile-time merger resolves the replacement (last load-order writer wins) before WorldSchema is built — the runtime never sees two rows for one id. Whether the compiler should emit an explicit replacement marker to make the intent visible is open.

Schema-validation collision policy: All modding goes through the compile-time merger. Runtime DLL sideload (dropping a mod DLL into a running process and expecting the engine to absorb it) is explicitly out of scope: WorldSchema is immutable once SecsRuntimeBuilder.Build() returns at host startup; v2 has no imperative post-boot registration surface — the schema is assembled once and validated once. Mod authors compose with base by feeding their .secs source through the SECS compiler alongside base content; the compiler's merger pass produces one combined SecsModule.BuildSchema() per game install. Cross-reference 06-overrides-and-modding.md § "Compile-time merger model".

Lowering summary — WorldSchema rows

Every structural declaration lands in a WorldSchema row array assembled by Valenar.V2.Generated.SecsModule.BuildSchema(). The mapping is:

.secs constructBuilder classLowered row arrayTarget rowNotes
enum X { ... }, struct X { ... }, record X { ... }(doc 07 design target)type-catalog rows (not yet a WorldSchema member)SecsTypeRef references (src/SECS.Schema/Shared/SecsTypeRef.cs)C# source type catalog. Generated SecsTypeRef values reference these via ReferenceId. The catalog table itself is a doc-07 design target, not yet a schema row. See doc 07.
scope <N> { walks_to <P>; ... }Scopes.Build()WorldSchema.Scopes (+ ScopeWalkPlans)ScopeRow (src/SECS.Schema/Rows/ScopeRow.cs)Walk edges are separate ScopeWalkPlanRow rows addressed by ScopeRow.WalksToRange*. Self-walks, multi-hop walks, and game-specific ownership walks all lower to walk-plan rows.
int Foo; / long Bar; / float Baz; / double Qux; / bool Quux; inside a scope blockScopes.BuildFields()WorldSchema.ScopeFieldsScopeFieldRow (src/SECS.Schema/Rows/ScopeFieldRow.cs)ScalarType uses the five numeric/bool SecsScalarType members (Int / Long / Float / Double / Bool, see §"Numeric type set"). No clamp surface on this row.
contract <N> { root_scope <S>; query <T> Q(); method void M(); activation M; }Contracts.Build()WorldSchema.ContractsContractRow (src/SECS.Schema/Rows/ContractRow.cs)Runtime dispatch/lifecycle shape — Id, RootScopeId, MethodRange*, ActivationBindingId, DeactivationBindingId, IsRegistryOnly. Methods are addressed by range; lifecycle is two binding-id fields. A None activation binding means no automatic activation hook.
query <T> Q(...); / method void M(...); inside a contract blockContracts.BuildMethods()WorldSchema.ContractMethodsContractMethodRow (src/SECS.Schema/Rows/ContractMethodRow.cs)Compiler-facing callable signatures — Id, OwnerContractId, SignatureHashLow, IsQuery, ReturnTypeRef: SecsTypeRef, ParamCount, ParamRangeStart. Parameters live in an absent side table; the query/method split is the IsQuery flag.
field <type> <Name> { name = ...; description = ...; min = ...; max = ...; }TemplateFields.Build()WorldSchema.TemplateFieldsTemplateFieldRow (src/SECS.Schema/Rows/TemplateFieldRow.cs)Per-template field VALUE rows — Id, OwnerTemplateId, ValueTypeRef: SecsTypeRef, serialized value offset/length. The display-metadata catalog (name/description/clamps) has no v2 schema-row backing.
channel <type> <Name> { kind = ...; source = ...; min = ...; max = ...; }Channels.Build()WorldSchema.ChannelsChannelRow (src/SECS.Schema/Rows/ChannelRow.cs)Kind is stated explicitly (`kind = Contributed

Further row arrays — WorldSchema.Modifiers / ModifierEffects (via Modifiers.Build()) and the template rows — are covered in 03-channels-and-modifiers.md and 02-templates.md respectively.

The entry point — SecsModule.BuildSchema(), which calls each builder class and assembles the WorldSchema — lives in compiler-emitted examples/valenar-v2/Generated/obj/**/SecsGenerated/SecsModule.g.cs. At host startup, SecsRuntimeBuilder.Build() validates that WorldSchema once; once validation completes, the schema is immutable for the lifetime of the runtime. v2 has no monolithic declarations file and no imperative registration entry point — schema assembly through BuildSchema() is the only seam.

Invariants the compiler must enforce

The .secs source and compiler-owned generated output are kept in sync by secsc for implemented feature families, while several future invariants remain compiler responsibilities as later phases land. Listing them here so they can be cross-checked during compiler bring-up. Each invariant carries a source-set note describing how it generalises across base, official expansions, and third-party mods. "Merged set" means the effective base/expansion declarations plus the data-only mod declarations permitted by the merger (06-overrides-and-modding.md § "The merge-pass pipeline").

  • Every source scope referenced in a channel declaration must correspond to an existing ScopeRow. A channel like channel int Gold { source = Settlement.Gold; } presupposes that Settlement is a declared scope and Gold is a declared field on it. The compiler must emit both the ScopeFieldRow and the ChannelRow (with its ChannelSourceFieldId) in lockstep, and reject sources that point at non-existent fields.
    • Source-set note: the check runs against the merged set. A third-party mod's host-backed channel source may reference only scopes exported by the base game or enabled official expansions. Data-only mods cannot introduce their own source scopes because that would require host storage.
  • Every source field referenced in a channel declaration must correspond to an existing ScopeFieldRow on the named scope. If channels.secs names Settlement.Gold but scopes.secs does not declare int Gold; on Settlement, the compiler must reject. Today this is true by construction — both sides were hand-authored to match — but future drift is the main risk.
    • Source-set note: runs against the merged set. A third-party mod's channel may reference a field exported by base or an enabled official expansion; it may not depend on another third-party mod adding host-backed fields.
  • Every RootScopeId on a ContractRow must correspond to an existing ScopeRow. A contract like contract Building { root_scope Territory; } must not parse if territory is not declared. (WorldSchema.Validate() FK-checks ContractRow.RootScopeId against WorldSchema.Scopes.)
    • Source-set note: runs against the merged set. A third-party mod-declared contract may reference base/expansion scopes, not scopes invented by data-only mods.
  • Every id value emitted to H.* must be the FNV-1a-64 of the construct's canonical id string, or of an explicitly specified wire id string for the few runtime-owned wire-id families. Tag mirrors are the special case: H.Tag_* must equal the corresponding TagId.Value from TagId.Create("namespace:tag/name"). The compiler must not emit hand-picked sentinel values.
    • Mod scope note: the rule is global — every source set's emitted constants must be FNV-1a-64. Hand-picked sentinels are invalid in mod output for the same reason they are invalid in base output (a mod identifier whose FNV happens to equal a base sentinel would silently collide). Phase 1 of the compiler bring-up rejects sentinels uniformly.
  • No two identifiers may collide on the same FNV output. WorldSchema.Validate() catches duplicate-id collisions at boot; the compiler should catch them at build time so the error includes file and line.
    • Mod scope note: the check runs across the merged set; see §"Case-collision rule" for the cross-source-set behaviour of the case-collision check.
  • Channel names must be unique across the entire Channels array. Scope-scoped field names can repeat (both territory and province have an OwnerId), but top-level channel names cannot — H.Gold is one constant, one row, one resolution path.
    • Mod scope note: uniqueness is enforced across the merged Channels array. Two source sets declaring channel int Foo { ... } collide; channels do not support inject or replace, so the second declaration is redundant or invalid rather than a legal patch (see SECS0602 and the §"Channel declarations are not overridable" note above).
  • Template field names must be unique across the entire TemplateFields array and must not collide with channel names. field int GoldCost and channel int GoldCost cannot coexist under the current shared H.* namespace. The compiler rejects the second declaration instead of guessing whether GoldCost means template metadata or a resolver channel.
    • Mod scope note: uniqueness is enforced across the merged field + channel namespace. A mod may add a new field name, but it may not redeclare a base channel as a field or a base field as a channel.
  • Every source type in a template field, scope method, contract query/method, struct/record field, array element, collection generic argument, template reference, or scope entity reference must resolve to a declared type or a built-in scalar/scope/template type. Unknown structured type names are SECS0701, not late runtime failures.
    • Mod scope note: type lookup runs against the merged base/expansion type catalog plus permitted mod-added type declarations. Mods may add new value types for their own new fields/templates, but they may not mutate an existing type's shape.
  • Callable signatures are type-stable under replacement. A mod may replace a query or method body; it may not change parameter or return SecsTypeRef. A query replacement for Feature.EvaluatePlacement(Territory, FeaturePlacementInput):FeaturePlacementResult must keep that exact signature.
    • Mod scope note: signature drift is SECS0705 on the merged AST.
  • ChannelKind.Accumulative and ChannelKind.Base imply a non-None SourceFieldId (or SourceFormulaId). A stockpile or host-SOT channel without a source makes no sense — the engine needs somewhere to read the host value from.
    • Mod scope note: invariant applies to every declaration regardless of source set.
  • Every lifecycle binding (ContractRow.ActivationBindingId / DeactivationBindingId) must target a ContractMethodRow in the contract's method range. Otherwise an automatic runtime call targets a method the contract does not acknowledge.
    • Mod scope note: invariant applies to every declaration regardless of source set. Mods cannot extend a base contract's method range (contracts are not extensible — see §"contract declarations").
  • Every contract-declared query id must correspond to one implemented query signature, and every contract-declared method id must correspond to one implemented void signature. A template implementation may omit a declared function and receive the default (query default from the caller's policy, method no-op), but it may not implement an undeclared query or method.
    • Mod scope note: invariant applies to every declaration regardless of source set. Mods cannot extend a base contract's method range.
  • Scoped collection field names on a scope must be unique per scope. A scope declaring two ScopedList<Building> Buildings; fields is a grammar error.
    • Source-set note: uniqueness is enforced per scope across the merged host-capable set. Third-party mods cannot add ScopedList<T> / ScopedDictionary<TKey, T> fields; see §"Host-capable extension of scopes".

Cross-references

  • Template bodies as template field assignments (field int GoldCost = 10) — readonly template-definition data lowered to TemplateFieldRow value rows (src/SECS.Schema/Rows/TemplateFieldRow.cs) → 02-templates.md
  • Template bodies as per-instance channel declarations (channel int X = N / channel int X { return ...; }) — intrinsic channel sources on the activation root, not generic cross-scope contributions → 02-templates.md
  • Modifier declarations that affect channels, stacking policies, and cross-scope attachment (the sole language-level mechanism for multi-source aggregation) → 03-channels-and-modifiers.md
  • scope.field reads inside method bodies, foreach over ScopedList<T> / ScopedDictionary<TKey, T> declarations, and the scope-walk resolution rules → 05-expressions.md
  • Typed scoped collections (ScopedList<T>, ScopedDictionary<TKey, T>), the TemplateId built-in strong type, virtual binding propagation, aggregate channels, and prev-tick snapshots — the full replacement for the bare collection keyword → 08-collections-and-propagation.md