10 — Host <-> SECS execution boundary
This doc classifies every concept in the SECS world into one of three ownership buckets — Host-owned, SECS-owned, or Shared/bridged — and records the runtime entry points by which the two sides call into each other. It is a cross-cutting reference that complements rather than replaces docs 01-09:
- 01-world-shape.md defines what scopes, fields, and contracts are.
- 04-behavior.md defines what activities, policies, and events do.
- 05-expressions.md defines what queries, scope methods, and types look like at the call site.
- 06-overrides-and-modding.md defines what mods may and may not add.
- This doc names who owns each piece of state and code, which assembly each type lives in, and which interfaces gate the Host <-> SECS handoff.
Boundary discipline matters because SECS targets netstandard2.1 for
Unity consumers; the engine assembly compiles into a host-game DLL
alongside hand-written Generated/*.cs and the host's own runtime,
and the lines between those layers must stay sharp.
1. Purpose and scope
This doc answers four questions:
- What does the host own? (data, rendering, networking, save-file, platform services)
- What does SECS own? (declared content, generated behavior surfaces, channel resolution, command pipeline, mod merge)
- What is shared at the bridge? (typed ids, contexts, command buffers, the host-bridge interfaces themselves)
- Where do the assembly lines fall today and where do they need to move?
Out of scope for this doc:
- Per-call-site invocation legality (which surfaces may start an activity, fire an event, enqueue a command). That detail lives in the invocation matrix below.
- Read-only vs command-producing hardening beyond the committed boundary in section 13.
- Save payload wire format. Section 20.2 separates today's partial restore primitives from a future unified payload candidate; the host still owns bytes and container format.
2. The execution model in two paragraphs
A SECS host instantiates one SecsRegistry, calls every generated
SecsModule.Initialize(registry) once at boot, calls
registry.FinalizeModRegistration() after all mods are loaded, and
then runs a tick loop. On each tick, the host advances the world
(input, networking, AI scheduling) and at chosen phase boundaries
calls into SECS to resolve channels, run systems, dispatch events,
evaluate policies, and progress activities. SECS commands flow back
to the host through ISecsHostCommands and ISecsHostWrites; SECS
reads of host-owned data flow forward through ISecsHostReads.
In implementation terms: the host references both
SECS.Abstractions (the public bridge contract) and SECS.Engine
(the runtime that actually executes the contract). SECS-authored
content (Content/*.secs) lowers to plain C# in the
Generated/ tree, which compiles into the host DLL. Sub-agents,
modders, and the future SECS compiler must respect this two-layer
split: anything that crosses the line between SECS.Engine and
host code must be expressed as a method on one of the
ISecsHost* interfaces or as a registered runtime callback.
3. Three-bucket classification
Every concept below is assigned exactly one bucket. Items marked DRIFT are committed but the assembly placement does not match the "shared" intent — the table notes whether that placement is intentional.
3.1 Host-owned
| Item | Evidence | Notes |
|---|---|---|
| Entity / world physical storage | examples/valenar/Host/GameWorld.cs | The host owns every Dictionary<long, T> and array of game data; SECS engine never holds entity state physically. |
| Scope field storage | 01-world-shape.md:36, 01-world-shape.md:101, 05-expressions.md:184 | Scope fields are host-owned data. The engine reads them via ISecsHostReads.ReadInt/Long/Float/Double/Bool/String/Enum/... and writes the resolved-channel dirty set back via ISecsHostWrites. |
| Scope graph relationship storage | ISecsHostReads.WalkScope (src/SECS.Abstractions/Interfaces/ISecsHostReads.cs) | The host stores parent / child / sibling relationships; engine asks for them by walking declared walks_to paths. |
| Scoped collection backing containers | 08-collections-and-propagation.md:41 ("host-side iteration") | Aggregate channels and policy selectors enumerate via the bridge; engine never owns the backing list. |
| Host bridge implementation | examples/valenar/Host/Bridge/HostBridge.cs:10 (class HostBridge : ISecsHostReads, ISecsHostWrites, ISecsHostCommands) | The class that implements the interfaces lives in host code. |
| Rendering / UI / input | examples/valenar/Server/, examples/valenar/Client/ | Implicit by structure. No SECS type imports rendering or input. (Not committed as an explicit rule — repo structure alone establishes it.) |
| Networking / server authority | examples/valenar/Server/ (SignalR hub) | Implicit by structure; no SECS type references server APIs. |
| Save-file container | (not committed in design docs) | The host owns the save file format and serialization driver; SECS contributes the current partial runtime payload objects and may later contribute a unified payload. See § 20.2. |
| Platform services | (not committed in design docs) | OS / file / network / clock are host-owned by convention. |
| Host-only systems | 04-behavior.md:66, 04-behavior.md:221 | Systems registered with SystemSource.Host are excluded from the SECS merge pass. The host runs them itself. |
| Host boot / new-game orchestration | examples/valenar/Host/GameRuntime.cs:77 (GameRuntime.Create()) | The host code orchestrates engine boot; the engine exposes SecsRegistry.Register* and FinalizeModRegistration as the entry points. |
3.2 SECS-owned
| Item | Evidence | Notes |
|---|---|---|
| Declared content | examples/valenar/Content/**/*.secs, docs/design/README.md:29 | Designer-facing source files. Today they are authored by hand in plain text mirroring committed SECS keywords; the future SECS compiler will parse them. |
| Generated behavior surfaces | examples/valenar/Generated/**/*.cs, docs/design/README.md:30 | Hand-written stand-ins for compiler output. Either carry // Source: <Content/.secs> provenance or the canonical no-source marker. The generated-code provenance rules are backed by a test guard. |
| Template / channel / modifier / formula declarations | 00-overview.md glossary, SecsRegistry runtime registration | Engine owns the declaration metadata at runtime; host registers it at boot. |
| Scope declarations as schemas | 01-world-shape.md, ScopeDeclaration in SecsRegistry | The schema (field names, walks, methods) is SECS-owned; the field data is host-owned. |
| Contract declarations | 01-world-shape.md:397 (contract Character, contract Settlement, etc.) | Engine owns contract metadata; host implements the contract methods through scope-method bindings. |
| Systems / events / on_actions / activities / policies | 04-behavior.md, 09-ai-policies-and-activities.md | All five are SECS declaration keywords with engine-owned executors / dispatchers. |
| Channel / modifier resolution | src/SECS.Engine/Resolution/ChannelResolution.cs | The 6-phase resolution pipeline is entirely engine-owned. Host never participates in channel math. |
| Event dispatch | src/SECS.Engine/Events/EventDispatcher.cs | Engine owns the dispatcher and the queue. |
| Activity execution | src/SECS.Engine/Activities/ActivityExecutor.cs | Engine owns lane scheduling, lifecycle progression, and ActivityRunStore. |
| Policy evaluation | src/SECS.Engine/Policies/PolicyExecutor.cs | Engine owns rule iteration, candidate building, and decision selection. |
| Command buffer model (semantics) | src/SECS.Abstractions/Commands/CommandBuffer.cs (the type itself is in Abstractions; the model is engine-owned) | The type is bridged; the rules about when commands flush, ordering, idempotence belong to the engine. |
| Compile-time semantic mod merge | Phase 3 compiler-owned; activity/policy runtime finalization exists in ModRegistry | The future compiler performs the full source-set-aware merge. The runtime-owned executable subset is startup finalization for activity and policy mods through SecsRegistry.FinalizeModRegistration(). See docs/adr/ad-0001-runtime-mod-finalization-boundary.md. |
| Declaration metadata | src/SECS.Engine/SecsRegistry.cs | The registry owns every declaration entry at runtime. |
| Preview / effect plans | src/SECS.Engine/Activities/EffectPlan.cs, EffectPlanner.cs | The engine owns the preview/predict surface used by AI and UI. |
ModRegistry runtime accumulator | src/SECS.Engine/Modding/ModRegistry.cs | Engine-owned runtime object that holds and merges activity/policy mod entries before finalize. |
ContractLifecycleIds engine vocabulary | src/SECS.Abstractions/Contracts/ContractLifecycleIds.cs | The engine defines Activation and Deactivation as the canonical lifecycle slot names; host implementations bind by these constants. |
RegistryDiagnostics static-analysis surface | src/SECS.Engine/Diagnostics/RegistryDiagnostics.cs | Warn-only validation surface that runs against the populated registry. |
3.3 Shared / bridged
| Item | Assembly | Evidence | Drift? |
|---|---|---|---|
EntityHandle | SECS.Abstractions | src/SECS.Abstractions/EntityHandle.cs:3 | None |
ScopeFrame | SECS.Abstractions | src/SECS.Abstractions/Scopes/ScopeFrame.cs:6 | None |
TickContext | SECS.Engine | src/SECS.Engine/Pipeline/TickContext.cs:3 | BRIDGED engine context type; physical placement in SECS.Engine is intentional. |
ActivityRunContext | SECS.Engine | src/SECS.Engine/Activities/ActivityRunContext.cs:6 | BRIDGED engine context type; physical placement in SECS.Engine is intentional. |
EventContext | SECS.Engine | inside src/SECS.Engine/Events/SecsEvent.cs:34 | BRIDGED engine context type; physical placement in SECS.Engine is intentional. |
PolicyRunContext | SECS.Engine | src/SECS.Engine/Policies/PolicyRunContext.cs | BRIDGED engine context type; physical placement in SECS.Engine is intentional. |
CommandBuffer / command contexts | SECS.Abstractions | src/SECS.Abstractions/Commands/CommandBuffer.cs | None |
ISecsHostReads | SECS.Abstractions | src/SECS.Abstractions/Interfaces/ISecsHostReads.cs | None |
ISecsHostCommands | SECS.Abstractions | src/SECS.Abstractions/Interfaces/ISecsHostCommands.cs | None |
ISecsHostWrites | SECS.Abstractions | src/SECS.Abstractions/Interfaces/ISecsHostWrites.cs | None |
IEntityIdGenerator | SECS.Abstractions | src/SECS.Abstractions/Interfaces/IEntityIdGenerator.cs | None |
IEntityCreator | SECS.Abstractions | src/SECS.Abstractions/Interfaces/IEntityCreator.cs | None |
| Registry / executor / dispatcher APIs | SECS.Engine | SecsRegistry, ActivityExecutor, EventDispatcher, PolicyExecutor, PolicyDispatcher | SECS public engine API. |
| Save payload contributed by SECS runtime | SECS.Engine (objects); host (container) | ScopeSlotStore.Snapshot/Restore, ActivityRunStore.Restore / ActivityRun.ToState | SECS owns the current partial runtime payload objects; host owns the serialized container. PrevTickSnapshotStore is warmed runtime state, not durable payload. See § 20.2. |
Typed ids and SecsTypeRef metadata | SECS.Abstractions | src/SECS.Abstractions/Templates/TemplateId.cs, SecsTypeMetadata.cs, etc. | None |
ActivityArgsBlob / IActivityArgs<TSelf> | SECS.Engine (runtime) and SECS.Abstractions (interface) | src/SECS.Engine/Activities/ActivityArgsBlob.cs, IActivityArgs.cs | Runtime payload in SECS.Engine, interface contract in SECS.Abstractions; split is intentional. |
ScopeSlotStore | SECS.Engine (the store); host (the seeding code) | src/SECS.Engine/Slots/ScopeSlotStore.cs | Engine-owned store seeded directly by generated or host-side activation code. |
4. Assembly boundary
SECS ships as two assemblies plus generated host code:
SECS.Abstractions(netstandard2.1) — public bridge contract. HoldsEntityHandle,ScopeFrame, allISecsHost*interfaces, typed ids (TemplateId,ChannelId,ActivityId,EventId, etc.),SecsTypeRefdeclaration metadata,CommandBuffer,ContractLifecycleIds. A Unity consumer that wants to declare scopes and run them only needs to reference this assembly plus the engine.SECS.Engine(netstandard2.1) — runtime. HoldsSecsRegistry,ChannelResolution,ActivityExecutor,EventDispatcher,PolicyExecutor,ModRegistry,ScopeSlotStore, all the runtime context types (TickContext,EventContext,ActivityRunContext,PolicyRunContext), andRegistryDiagnostics.- Generated host code — compiles into the host DLL. References both assemblies. Ownership of the generated tree is SECS-owned (it is what the future compiler will emit) but the build artifact lives in the host DLL.
- Host code — the game implementation. References both SECS
assemblies plus its own world-storage code. Implements
ISecsHostReads / Writes / Commandsand registers itself with the engine at boot.
The runtime context types currently in SECS.Engine (TickContext,
EventContext, ActivityRunContext, PolicyRunContext) are
logically bridge types — host code receives them through generated
callbacks — but physically live in the engine assembly. That placement is intentional.
5. Host bridge implementation contract
The host must implement:
ISecsHostReads— every read primitive the engine uses to access scope fields, walk scope graphs, enumerate scoped collections, and call read-only scope methods. Seesrc/SECS.Abstractions/Interfaces/ISecsHostReads.cs.ISecsHostWrites— every write primitive the engine uses to push resolved-channel dirty values back to host storage. Seesrc/SECS.Abstractions/Interfaces/ISecsHostWrites.cs.ISecsHostCommands— every command primitive the engine uses to callvoidscope methods that produce host-side mutation. Seesrc/SECS.Abstractions/Interfaces/ISecsHostCommands.cs.IEntityIdGenerator— the host's id generator for runtime entity creation requests.IEntityCreator— the host's entity-creation entry point that the engine calls when acreate_entitycommand resolves.
The host typically implements all five interfaces on a single
HostBridge class for simplicity; the engine never asks more from
the host than the union of these interfaces, and the host never
calls the engine outside SecsRegistry.Register*,
FinalizeModRegistration, executor entry points (Tick,
StartActivity, Fire, EvaluatePolicy), and reading registry
diagnostic output.
ISecsHostReads is read-only: every method returns a value and is
guaranteed not to mutate host state. ISecsHostCommands and
ISecsHostWrites are command-producing: the engine calls them only
in command-producing contexts (system bodies, event bodies, activity
lifecycle hooks, command-method scope methods, modifier dirty-sync).
Read-only contexts (formulas, trigger conditions, queries, activity
CanStart, Preview, policy rule bodies, UI dry-run)
must not reach ISecsHostCommands / ISecsHostWrites. Remaining
structural hardening is backlog work, not a change to the live rule.
6. Host-capable vs data-only mod boundary
docs/design/01-world-shape.md:253-270 establishes a hard rule:
- Host-capable source sets (the base game, official expansions
shipping with the host build) may add new scopes, scope fields,
walks_todeclarations, scoped collections, and scope methods. These additions require host-side bridge code, so they cannot ship as data-only content. - Third-party data-only mods may NOT add any of those. They may add templates, modifiers, systems, events, activities, policies, and content for existing scopes.
This boundary is enforced at compile time by SECS diagnostics
(SECS0604, SECS0609). There is no runtime SourceSet enum or
HostCapable / DataOnly flag in the engine — the distinction is
purely compiler / doc-level. SECS0609 is compiler-phase-only; no
runtime enum wiring is applicable because runtime registration sees
the already-lowered generated declarations, not source-set rights.
7. Valenar-illustrative labeling convention
This doc set uses Valenar (examples/valenar/) as the primary
example game. Valenar concept names — Settlement, Location,
Character, Farm, KnownSpells, ScoutNearbyLead, Goblin,
Lead, Wood, DrainStamina, AdvanceLead — are illustrative
only. A different game implementing SECS would have entirely
different scopes, contracts, channels, activities, policies, and
host-side scope methods.
When a design doc gives a Valenar example for a generic SECS concept, it should:
- Cite the source
.secsorGenerated/*.csfile once near the first occurrence. - Include a one-line "(Valenar example; another game would have different X)" caveat at the section head OR at the first use of each Valenar-specific name.
- Avoid building section headings or sub-doc structure around Valenar-specific names; use generic SECS vocabulary in headings and demote Valenar names into the example body.
This convention is normative for new docs and recommended for
existing docs. Older docs may still carry unlabeled Valenar examples;
remaining cleanup is tracked in docs/design/FUTURE_WORK.md rather
than in this live boundary doc.
8. Cross-references
- 01-world-shape.md — defines scopes, fields, contracts, scope methods.
- 04-behavior.md — defines activities, policies, events, on_actions, systems.
- 05-expressions.md — defines queries, scope methods at the call site.
- 06-overrides-and-modding.md — defines what mods may add (host-capable vs data-only).
- 09-ai-policies-and-activities.md — defines AI policy lowering and the candidate-builder runtime shape.
- docs/adr/ad-0001-runtime-mod-finalization-boundary.md — records the runtime/compiler boundary for mod finalization.
- SECS-Compiler-Plan.md — defines what the compiler must lower and when.
9. Boundary highlights
- Runtime context placement:
TickContext,EventContext,ActivityRunContext, andPolicyRunContextstay inSECS.Engine. See § 22.1. - Slot seeding ownership:
ScopeSlotStoreis engine-owned and seeded directly by generated or host-side activation code. See § 17.5. - Save payload protocol: SECS provides structured runtime payload objects while the host owns serialization format. See § 20.2.
- Phase and cadence declarations stay plain C# helper values, not SECS keywords. See § 21.4.
SecsRegistryand the executor / dispatcher family areSECS.Enginepublic API, not bridge interfaces. See § 22.2.- Remaining Valenar-label cleanup is backlog only and is tracked in
docs/design/FUTURE_WORK.md.
10. Reading guide
Sections 11-20 classify executable surfaces, read-only versus command-producing behavior, and save/load boundaries. Later sections capture API-shape and assembly-placement decisions that constrain the bridge.
11. Invocation matrix
This section classifies every callable / executable / lifecycle
surface SECS exposes today (or ships a hand-written stand-in for) by
who may call it, what context it requires, what it may do, and how it
fails. Surfaces marked NOT-COMMITTED are listed in section 12; detailed backlog lives in docs/design/FUTURE_WORK.md.
The column model is fixed:
- Caller — host / SECS / both / via-bridge-only.
- Context required —
TickContext/ActivityRunContext/ActivityContext/EventContext/PolicyRunContext/TemplateCommandContext/ScopeFrame + ISecsHostReads/CommandBuffer/ScopeSlotStore/SecsRegistry/ none. - Read-only? — yes / no / depends-on-caller-context.
- Command-producing? — yes / no / via-context-Commands.
- May start activities? — yes / no.
- May fire events / on_actions? — yes / no.
- May create / destroy entities? — yes / no.
- Random? — yes / no / via-context-Random / accessible-but-inappropriate.
- Callable in Preview? — yes / no.
- Callable in save / load? — yes / no / N/A.
- Result type — typed / void /
ActivityRequest[]/RuleDecision/EffectPlan/ etc. - Failure mode — throws / fails-validation / silent / undefined.
- Saved? — yes-what / no.
- Status — COMMITTED / PARTIAL / NOT-COMMITTED / DEPRECATED.
Surfaces in this section are ordered by group (A through G), and within each group by the runtime flow used in this doc. Each surface is a paragraph block — a flat-table form would wrap badly in this column model.
11.1 Group A — System / event / on_action / template
A1 — system Execute — COMMITTED
(src/SECS.Engine/Pipeline/ITickSystem.cs:49).
Caller: SECS-registered; dispatched by SystemStep.Execute(TickContext)
inside the tick pipeline; host triggers the pipeline. Context:
TickContext. Read-only? No — has full Commands and
FlushCommands(). Command-producing? Yes — SystemStep.cs:47 flushes
after the body. May start activities? Yes (via ctx and the
executor). May fire events / on_actions? Yes (ctx.Events.FireOnAction).
May create / destroy entities? Yes (create via ctx.CreateEntity;
destroy via host-exposed TemplateActivator.DestroyWithChildren).
Random? Yes (ctx.Random). Callable in Preview? No (no preview gating
for ITickSystem). Callable in save / load? No (pipeline not driven
during restore). Result: void. Failure mode: exception propagates;
SystemStep.cs:50 discards the buffered commands on throw and
re-throws. Saved? No — system has no saved state; activity runs
started from within are saved through ActivityRunStore.
A2 — pulse event Condition — COMMITTED
(src/SECS.Engine/Events/SecsEvent.cs:16).
Caller: EventDispatcher.EvaluateCondition per entity per pulse cycle.
Context: EventContext (wraps TickContext). Read-only? Yes by
enforcement — EventDispatcher.cs:350 calls ctx.DiscardCommands() in
finally. Command-producing? No (any commands enqueued are discarded).
May start activities? No. May fire events / on_actions? No (no flush).
May create / destroy entities? No. Random? Accessible via
context.Tick.Random but semantically inappropriate for a pure
predicate. Callable in Preview? No (no defined preview mode for pulse
events). Callable in save / load? No. Result: bool. Failure mode:
exception propagates after discard; the event is skipped for that
entity. Saved? No.
A3 — pulse event Execute — COMMITTED
(src/SECS.Engine/Events/SecsEvent.cs:19).
Caller: EventDispatcher.InvokeEventBody from ExecuteOrQueueChoice
when no BuildOptions is present. Context: EventContext. Read-only?
No. Command-producing? Yes — EventDispatcher.cs:373 flushes after
the body. May start activities? Yes. May fire events / on_actions?
Yes (recursive FireOnAction allowed). May create / destroy entities?
Yes. Random? Yes. Callable in Preview? No. Callable in save / load?
No. Result: void. Failure mode: exception propagates; commands
discarded (EventDispatcher.cs:376). Saved? No — deferred choice
queue is in-memory; not persisted.
A4 — event BuildOptions — COMMITTED
(src/SECS.Engine/Events/SecsEvent.cs:26).
Caller: EventDispatcher.InvokeBuildOptions from
ExecuteOrQueueChoice. Context: EventContext plus the EventOptions
builder. Read-only? Yes by enforcement — EventDispatcher.cs:362
calls ctx.DiscardCommands() in finally. Command-producing? No.
May start activities? No. May fire events / on_actions? No. May create
/ destroy entities? No. Random? Accessible but semantically
inappropriate. Callable in Preview? No. Callable in save / load? No.
Result: void (mutates the EventOptions builder). Failure mode:
exception propagates; commands discarded. Saved? No.
A5 — event option handler — COMMITTED
(src/SECS.Engine/Events/SecsEvent.cs:97 —
EventOption.Execute).
Caller: EventDispatcher.ResolveChoice invoked by host when the player
picks an option. Context: EventContext (restored from
PendingChoice.SavedScopes). Read-only? No. Command-producing? Yes —
InvokeEventBody wraps the option handler (EventDispatcher.cs:322).
May start activities? Yes. May fire events / on_actions? Yes. May
create / destroy entities? Yes. Random? Yes. Callable in Preview? No.
Callable in save / load? No. Result: void. Failure mode: exception
propagates; commands discarded. Saved? PendingChoice queue is
in-memory and not serialized; pending choices are lost across save /
load.
A6 — on_action declaration — COMMITTED
(src/SECS.Engine/Events/OnActionDeclaration.cs, registered via
SecsRegistry.RegisterOnAction).
Caller: registration-time only; host registers at boot via
SecsModule.Initialize(registry). Context: SecsRegistry (boot-time).
Read-only? N/A — metadata object. Command-producing? N/A. May start
activities? N/A. May fire events / on_actions? N/A. May create /
destroy entities? N/A. Random? N/A. Callable in Preview? N/A.
Callable in save / load? N/A. Result: void. Failure mode:
InvalidOperationException on duplicate id or missing registry
attachment. Saved? No — declaration is static.
A7 — on_action fire — COMMITTED
(src/SECS.Engine/Events/EventDispatcher.cs:166 — FireOnAction).
Caller: SECS or host; ctx.Events.FireOnAction(onActionId, target, ctx) is callable from any context that holds a TickContext.
Context: TickContext (implicit on ctx.Events). Read-only? No.
Command-producing? Yes — FireOnAction flushes the buffer before
dispatch (EventDispatcher.cs:168). May start activities? Yes (via
subscribed events). May fire events / on_actions? Yes (subscriber code
or host code may call nested dispatch explicitly; reserved fallback /
chaining metadata is ignored). May create / destroy entities? Yes.
Random? Yes.
Callable in Preview? No. Callable in save / load? No. Result: void.
Failure mode: InvalidOperationException on missing required saved
scope (ValidateProvidedScopes). Saved? No.
A8 — template activation — COMMITTED
(src/SECS.Engine/TemplateActivator.cs:40 — Activate).
Caller: host-initiated at boot or runtime; Activate(templateId, owner, scopeTarget). Context: TemplateCommandContext for lifecycle
methods; ScopeFrame + CommandBuffer for the activation delegate
itself. Read-only? No — runs RegisterChannelSource / AddModifier
commands and invokes the contract's activation lifecycle (e.g.
OnBuilt). Command-producing? Yes (own internal CommandBuffer).
May start activities? No during activation proper; lifecycle method
may. May fire events / on_actions? No (no TickContext available
during activation; TemplateCommandContext lacks event dispatch).
May create / destroy entities? Yes — TemplateCommandContext.CreateEntity
delegates to TemplateCommandEntityCreator. Random? No (no Random
on TemplateCommandContext). Callable in Preview? No. Callable in
save / load? Not as a durable restore primitive — Activate / Create
fire activation and lifecycle behavior and would treat an already-saved
entity as newly created. Current partial restore uses raw
ActivityRunStore.Restore + ScopeSlotStore.Restore for the stores
that support it; a no-lifecycle template rehydrate path remains future
work. Result: ActivationSummary. Failure mode:
InvalidOperationException on missing lifecycle method. Saved? Channel
sources and modifier bindings registered during activation are not
persisted as an "activation record" today; future save/load must either
persist the resulting runtime records or add a restore-only rehydrate
path that rebuilds template runtime state without normal lifecycle side
effects.
A9 — template deactivation — COMMITTED
(src/SECS.Engine/TemplateActivator.cs:401 — Destroy,
DestroyWithChildren).
Caller: host — Activator.Destroy(templateId, owner, scopeTarget)
or Activator.DestroyWithChildren(owner). Context:
TemplateCommandContext for the deactivation lifecycle; no
TickContext. Read-only? No — removes channel sources and bindings;
calls the deactivation lifecycle. Command-producing? Yes (internal
buffer; RemoveAllChannelSources, RemoveAllBindings). May start
activities? No. May fire events / on_actions? No. May create / destroy
entities? Yes (recursive DestroyWithChildren). Random? No. Callable
in Preview? No. Callable in save / load? No. Result: void. Failure
mode: InvalidOperationException on missing instance. Saved? No.
A10 — template query — COMMITTED
(src/SECS.Abstractions/Delegates.cs:58 — TemplateQueryDelegate,
called via ISecsHostReads.CallScopeQuery).
Caller: host or SECS — host.CallScopeQuery<TArgs, TResult>(target, scopeId, methodId, args) (ISecsHostReads.cs:34); formula bodies and
event conditions can call through context.Tick.Host.CallScopeQuery.
Context: ScopeFrame + ISecsHostReads (no full TickContext).
Read-only? Yes — ISecsHostReads guarantees no host mutation per § 5.
Command-producing? No. May start activities? No. May fire events /
on_actions? No. May create / destroy entities? No. Random? No (no
Random in the delegate signature). Callable in Preview? Yes — safe
to call from Preview, Condition, CanStart, etc. Callable in
save / load? No (no active host state to query). Result: generic
TResult. Failure mode: InvalidOperationException if the method is
not found or the argument type does not match
(SecsRegistry.cs:2449). Saved? No.
A11 — template command method — COMMITTED
(src/SECS.Abstractions/Delegates.cs:44 — TemplateCommandDelegate,
invoked via ITemplateCommandContext.CallScopeCommand or
TickContext.CallScopeCommand).
Caller: SECS or host — invoked from system / event / activity bodies
via ctx.CallScopeCommand(target, scopeId, methodId, args).
Context: ITemplateCommandContext (its own CommandBuffer).
Read-only? No. Command-producing? Yes — writes to the CommandBuffer cmds passed in via ISecsHostCommands. May start activities? Yes
(host bridge may start activities inside). May fire events /
on_actions? Yes (host bridge may fire events inside). May create /
destroy entities? Yes. Random? Depends on the host implementation.
Callable in Preview? No — TickContext.CallScopeCommand throws if
HostCommands is null (TickContext.cs:216). Callable in save /
load? No. Result: void. Failure mode: InvalidOperationException
if HostCommands is null; the host bridge throws on unknown methods.
Saved? No (effects flow through the CommandBuffer to the host).
11.2 Group B — Activity
B1 — activity IsVisible — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:80).
Caller: ActivityExecutor.IsVisible(activityId, context)
(ActivityExecutor.cs:88). Context: ActivityContext. Read-only? Yes
by convention — no command flush in the executor's IsVisible path.
Command-producing? No. May start activities? No. May fire events /
on_actions? No. May create / destroy entities? No. Random? Accessible
but inappropriate. Callable in Preview? Yes — safe outside tick
execution. Callable in save / load? No. Result: bool. Failure mode:
exception propagates; the executor returns false for an unknown
activityId. Saved? No.
B2 — activity IsTargetValid — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:83).
Same shape as IsVisible. Caller:
ActivityExecutor.IsTargetValid(activityId, context)
(ActivityExecutor.cs:97). Context: ActivityContext. Read-only? Yes
(same enforcement pattern as IsVisible — no flush in path).
Command-producing? No. May start activities? No. May fire events /
on_actions? No. May create / destroy entities? No. Random? Accessible
but inappropriate. Callable in Preview? Yes. Callable in save / load?
No. Result: bool. Failure mode: returns false for unknown
activityId. Saved? No.
B3 — activity CanStart — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:86).
Caller: inner check inside ActivityExecutor.CanStartAt
(ActivityExecutor.cs:129); also reachable through the host-facing
CanStart API. Context: ActivityContext. Read-only? Yes — no flush
in the CanStart path; § 5 names it as a read-only context.
Command-producing? No. May start activities? No. May fire events /
on_actions? No. May create / destroy entities? No. Random? Accessible
but inappropriate. Callable in Preview? Yes — CanStart is part of
the non-starting preview path. Callable in save / load? No. Result:
bool. Failure mode: exception propagates; the activity is not
started. Saved? No.
B4 — activity GetDurationTicks — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:89).
Caller: ActivityExecutor.CanStartAt (ActivityExecutor.cs:157) and
StartAt (ActivityExecutor.cs:222). Context: ActivityContext.
Read-only? Yes — pure computation; no command flush in the path.
Command-producing? No. May start activities? No. May fire events /
on_actions? No. May create / destroy entities? No. Random? Accessible
but inappropriate. Callable in Preview? Yes. Callable in save / load?
No. Result: int. Failure mode: InvalidOperationException on a
negative return (ActivityExecutor.cs:226). Saved? No on its own; the
duration is stored on ActivityRun and persisted via
ActivityRunState.
B5 — activity Preview — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:92).
Caller: ActivityExecutor.Preview(...) (ActivityExecutor.cs:353);
also called at start time by PreviewDriftRecorder. Context:
ActivityContext. Read-only? Yes by convention and § 5; no command
flush in the executor's preview path. Command-producing? No (commands
must not be flushed). May start activities? No. May fire events /
on_actions? No. May create / destroy entities? No. Random? Accessible
via context.Tick.Random — acceptable for probabilistic previews,
but should be deterministic. Callable in Preview? Yes — this IS the
preview surface. Callable in save / load? No. Result: EffectPlan.
Failure mode: InvalidOperationException on unknown activityId;
exception in body propagates. Saved? No.
B6 — activity OnStart — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:95).
Caller: ActivityExecutor.InvokeLifecycle(runContext, activity.OnStart)
inside StartAt (ActivityExecutor.cs:259). Context:
ActivityRunContext. Read-only? No. Command-producing? Yes —
InvokeLifecycle flushes after the body
(ActivityExecutor.cs:587). May start activities? Yes. May fire events
/ on_actions? Yes. May create / destroy entities? Yes (full
TickContext available through context.Tick). Random? Yes. Callable
in Preview? No. Callable in save / load? No. Result: void. Failure
mode: exception propagates; commands discarded; an OnStart failure
leaves the run in the store and the caller checks the terminal state.
Saved? No on its own — the run is saved, not the OnStart invocation.
B7 — activity OnUpdate — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:99).
Same shape as OnStart. Caller: ActivityExecutor.TickAt →
InvokeLifecycle(context, run.Activity.OnUpdate)
(ActivityExecutor.cs:325). Context: ActivityRunContext. Read-only?
No. Command-producing? Yes. May start activities / fire events /
create / destroy entities? Yes. Random? Yes. Callable in Preview? No.
Callable in save / load? No. Result: void. Failure mode: exception
propagates; commands discarded. Saved? No.
B8 — activity OnComplete — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:103).
Same shape as OnStart. Caller: InvokeTerminalLifecycle case
Completed (ActivityExecutor.cs:562). Context: ActivityRunContext.
Result: void.
B9 — activity OnStop — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:107).
Same shape as OnComplete. Caller: InvokeTerminalLifecycle case
Stopped.
B10 — activity OnCancel — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:111).
Same shape as OnComplete. Caller: InvokeTerminalLifecycle case
Canceled.
B11 — activity OnFail — COMMITTED
(src/SECS.Engine/Activities/SecsActivity.cs:116).
Same shape as OnComplete. Caller: InvokeTerminalLifecycle case
Failed.
B12 — activity start (host-initiated) — COMMITTED
(src/SECS.Engine/Activities/ActivityExecutor.cs:161 —
Start(ActivityId, ActivityContext)).
Caller: host or SECS via ActivityExecutor.Start(request, tick) or
Start(activityId, context). Context: ActivityContext externally;
ActivityRunContext for lifecycle invocations. Read-only? No —
deducts costs, applies cooldown, fires OnStart. Command-producing?
Yes (cost deduction calls FlushCommands; OnStart fires commands).
May start activities? Yes (recursive starts on different lanes). May
fire events / on_actions? Yes (through the OnStart body). May
create / destroy entities? Yes (through the OnStart body). Random?
Yes. Callable in Preview? No. Callable in save / load? No. Result:
ActivityRun? (null when CanStart fails). Failure mode: returns
null on validation failure; throws InvalidOperationException on args
schema mismatch or negative duration. Saved? ActivityRun.ToState()
→ ActivityRunState, including request origin; serialized by host to
persist across saves; ActivityExecutor.SeedNextRunId restores the id
counter.
B13 — activity preview (host-initiated) — COMMITTED
(src/SECS.Engine/Activities/ActivityExecutor.cs:353 — Preview(...)).
Caller: host or SECS (e.g. policy scoring). Context: TickContext plus
ActivityId plus EntityHandle actor / target. Read-only? Yes.
Command-producing? No. May start activities? No. May fire events /
on_actions? No. May create / destroy entities? No. Random? Accessible.
Callable in Preview? Yes — this is the preview entry point. Callable
in save / load? No. Result: EffectPlan. Failure mode:
InvalidOperationException for unknown activity. Saved? No.
B14 — activity cancel (host-initiated) — COMMITTED
(src/SECS.Engine/Activities/ActivityExecutor.cs:342 —
Cancel(ActivityRun, TickContext)).
Caller: host or PolicyDispatcher.ProcessCancelChild
(PolicyDispatcher.cs:168). Context: TickContext. Read-only? No —
transitions the run, fires OnCancel. Command-producing? Yes
(through OnCancel). May start activities / fire events / create /
destroy entities? Yes (through OnCancel). Random? Yes. Callable in
Preview? No. Callable in save / load? No. Result: bool (false if
the run is not in a cancelable state). Failure mode: returns false
silently. Saved? After cancel, the run is removed from the store and
not persisted.
B15 — activity lifecycle transition — COMMITTED
(src/SECS.Engine/Activities/ActivityRun.cs — TryTransition).
Caller: internal to the executor and via
ActivityRunContext.Complete/Stop/Cancel/Fail
(ActivityRunContext.cs:58-75). Context: ActivityRunContext.
Read-only? No — mutates run status. Command-producing? No (the
transition itself); the terminal lifecycle hook is command-producing.
May start activities? No. May fire events / on_actions? No. May
create / destroy entities? No. Random? No. Callable in Preview? No.
Callable in save / load? No. Result: void (via the context methods).
Failure mode: silent if the run is already terminal — TryTransition
returns false and the call is ignored. Saved? The new status is
captured in ActivityRunState.
11.3 Group C — Policy
C1 — policy rule Decision — COMMITTED
(src/SECS.Engine/Policies/SecsPolicy.cs:26 —
EvaluateRule(RuleId, PolicyRunContext)).
Caller: PolicyExecutor.EvaluateAllRules →
policy.EvaluateRule(rule.Id, context) (PolicyExecutor.cs:111).
Context: PolicyRunContext (Actor, Domain, Tick). Read-only?
AMBIGUOUS — see C13-18 below. Command-producing? AMBIGUOUS
(C13-18). May start activities? No (rule bodies return
RuleDecision; the dispatcher starts activities). May fire events /
on_actions? AMBIGUOUS (C13-18). May create / destroy entities?
AMBIGUOUS (C13-18). Random? context.Tick.Random is accessible;
whether it is allowed is AMBIGUOUS (C13-18). Callable in Preview? No
— PolicyRunContext only carries Actor, Domain, Tick. Callable
in save / load? No. Result: RuleDecision (one of Continue,
Complete, Fail, Wait, Call, CallBest, CancelChild).
Failure mode: exception propagates from the EvaluateAllRules
iterator (uncaught) up to the dispatcher. Saved? No (the decision is
ephemeral).
C2 — policy Call — COMMITTED
(src/SECS.Engine/Policies/RuleDecision.cs:35 — RuleDecision.Call).
Caller: PolicyDispatcher.ProcessCall triggered when EvaluateRule
returns RuleDecision.Call (PolicyDispatcher.cs:137). Context:
implicit TickContext on PolicyDispatcher.Tick(policy, actor, tick).
Read-only? No — ProcessCall calls ActivityExecutor.Start.
Command-producing? Yes (activity start triggers costs / OnStart).
May start activities? Yes. May fire events / on_actions? Yes (through
the started activity). May create / destroy entities? Yes (through
the started activity). Random? Yes. Callable in Preview? No. Callable
in save / load? No. Result: PolicyDispatchResult.Fail if the start
returns null; otherwise iteration continues. Failure mode: returns
PolicyDispatchResult.Fail (no exception on failure to start). Saved?
The activity run is saved with ActivityRequestOrigin.Policy; the
Call decision itself is not.
C3 — policy CallBest — COMMITTED
(src/SECS.Engine/Policies/RuleDecision.cs:38 —
RuleDecision.CallBest).
Caller: PolicyDispatcher.ProcessCallBest →
PolicyExecutor.CallBest → ActivityExecutor.Start. Context:
PolicyRunContext inside CallBest. Read-only? No.
Command-producing? Yes. May start activities? Yes. May fire events /
on_actions? Yes. May create / destroy entities? Yes. Random? Yes.
Callable in Preview? No. Callable in save / load? No. Result:
PolicyDispatchResult.NoCandidate, StartRefused, or
ActivityStarted. Failure mode: returns an activity-level failure code
(no exception). Saved? The activity run is saved with
ActivityRequestOrigin.Policy; the CallBest decision itself is not.
C4 — policy CancelChild — COMMITTED
(src/SECS.Engine/Policies/RuleDecision.cs:41 —
RuleDecision.CancelChild).
Caller: PolicyDispatcher.ProcessCancelChild
(PolicyDispatcher.cs:163). Context: TickContext (held by the
dispatcher). Read-only? No. Command-producing? Yes (through
ActivityExecutor.Cancel → OnCancel). May start activities / fire
events / create / destroy entities? Yes (through the OnCancel body).
Random? Yes. Callable in Preview? No. Callable in save / load? No.
Result: PolicyDispatchAction.Skipped or ActivityCancelled. Failure
mode: silent — Skipped when no child is tracked. Saved? No.
C5 — policy dispatcher tick — COMMITTED
(src/SECS.Engine/Policies/PolicyDispatcher.cs:59 —
Tick(policy, actor, tick)).
Caller: host or SECS system body — host drives the dispatcher per
actor per tick. Context: TickContext. Read-only? No.
Command-producing? Yes (via the activities it starts or cancels). May
start activities / fire events / create / destroy entities? Yes.
Random? Yes. Callable in Preview? No. Callable in save / load? No.
Result: PolicyDispatchResult. Failure mode: throws
InvalidOperationException if policy or tick is null; throws if a
rule slot is not seeded (RequireSlot). Saved? The activeChildren
dictionary is in-memory and NOT persisted — child tracking is lost on
save / load.
C6 — candidate-builder delegate — COMMITTED
(src/SECS.Engine/Policies/CandidateBuilder.cs —
CandidateBuilderDelegate).
Surface form is a delegate registered by the host; there is no
committed source-form keyword for declaring one. Caller:
PolicyExecutor.BuildFromCollection →
CandidateBuilderDelegate(actor, slot, tick)
(PolicyExecutor.cs:199). Context: (EntityHandle actor, SlotKindId slot, TickContext tick). Read-only? Yes by convention — should only
read slot contents and return an ActivityRequest list; no flush in
the path. Command-producing? No (no flush in
BuildFromCollection). May start activities? No. May fire events /
on_actions? No. May create / destroy entities? No. Random? Accessible
via tick; inappropriate for deterministic candidate building.
Callable in Preview? Yes — called during policy scoring, which is a
read-only phase. Callable in save / load? No. Result:
IReadOnlyList<ActivityRequest>. Failure mode:
InvalidOperationException if the builder returns null (treated as
empty); InvalidOperationException if Builder.IsZero
(PolicyExecutor.cs:186). Saved? No. A builder may return request
origin when it knows it, but PolicyDispatcher.ProcessCallBest
normalizes the selected live start to ActivityRequestOrigin.Policy.
11.4 Group D — Contract / scope
D1 — contract query call — COMMITTED.
Same surface as A10 (TemplateQueryDelegate /
ISecsHostReads.CallScopeQuery); read-only, Preview-safe, no command
capability. See A10 for full classification.
D2 — contract method call — COMMITTED.
Same surface as A11 (TemplateCommandDelegate /
ISecsHostCommands.CallScopeCommand); command-producing, requires
ISecsHostCommands. See A11 for full classification.
D3 — scope field read — COMMITTED
(src/SECS.Abstractions/Interfaces/ISecsHostReads.cs:27-31 —
ReadInt/Long/Float/Double/Bool).
Caller: any code holding ISecsHostReads (formulas, triggers,
queries, system bodies, event bodies, activity bodies). Context:
ISecsHostReads. Read-only? Yes. Command-producing? No. May start
activities? No. May fire events / on_actions? No. May create / destroy
entities? No. Random? N/A. Callable in Preview? Yes. Callable in save
/ load? No. Result: typed scalar. Failure mode:
implementation-defined; typically returns 0 for an unknown entity or
field. Saved? No (host owns the data).
D4 — scope query method — COMMITTED.
Same surface as A10 / D1. Read-only, Preview-safe.
D5 — scope command method — COMMITTED.
Same surface as A11 / D2. Command-producing.
D6 — formula body — COMMITTED
(src/SECS.Abstractions/Delegates.cs:9 — FormulaIntDelegate and
siblings).
Caller: ChannelResolution during channel resolution; also
PolicyExecutor.ResolveNeedTarget (PolicyExecutor.cs:364). Context:
(EntityHandle, EntityHandle, EntityHandle, ISecsHostReads).
Read-only? Yes — the delegate signature has no command capability.
Command-producing? No. May start activities? No. May fire events /
on_actions? No. May create / destroy entities? No. Random? No (no
Random in the signature). Callable in Preview? Yes — formulas
evaluate lazily, including during preview channel reads. Callable in
save / load? No. Result: typed scalar (int, long, float,
double, bool). Failure mode: exception propagates from
ChannelResolution. Saved? No.
D7 — trigger body — COMMITTED
(src/SECS.Abstractions/Delegates.cs:3 — TriggerDelegate).
Same shape as the formula body but returns bool. Used by modifier
condition evaluation and AddModifierWhen. Read-only? Yes.
Command-producing? No. Random? No. Callable in Preview? Yes. Result:
bool.
D8 — modifier-effect formula — COMMITTED.
The modifier-effect "magnitude formula" is the same delegate type as
D6: modifier effects carry a formulaId resolved via
SecsRegistry.GetFormulaInt/Float/Long/Double/Bool. Same constraints
as D6.
11.5 Group E — Channel / template field / collection
E1 — template_field base read — COMMITTED
(src/SECS.Engine/Instances/TemplateEntry.cs:23-27 —
GetFieldInt/Long/Float/Double/Bool; also
ISecsHostReads.ResolveTemplateValueInt etc.).
Caller: host or engine channel resolution (TemplateValueResolver).
Context: TemplateId + fieldId + contextTargets (no TickContext
for the resolver itself). Read-only? Yes. Command-producing? No. May
start activities? No. May fire events / on_actions? No. May create /
destroy entities? No. Random? No. Callable in Preview? Yes. Callable
in save / load? No. Result: typed scalar. Failure mode: returns
default (0, false) for unknown field. Saved? No — template fields are
static declarations.
E2 — effective template value read — COMMITTED
(src/SECS.Engine/Resolution/TemplateValueResolver.cs).
Same constraints as E1. The effective template value is the modifier-resolved
value from ISecsHostReads.ResolveTemplateValueInt.
E3 — collection iteration — COMMITTED
(src/SECS.Abstractions/Interfaces/ISecsHostReads.cs:25 —
WalkChildren; ISecsHostReads.cs:10 — GetChildren).
Caller: ChannelResolution for aggregate channels, PolicyExecutor for
collection-driven candidate sources, system bodies, event bodies.
Context: ISecsHostReads. Read-only? Yes. Command-producing? No. May
start activities? No. May fire events / on_actions? No. May create /
destroy entities? No. Random? No. Callable in Preview? Yes. Callable
in save / load? No. Result: IEnumerable<EntityHandle> or
ReadOnlySpan<EntityHandle>. Failure mode: returns empty for unknown
collection. Saved? No (host owns the backing data).
E4 — slot read — COMMITTED
(src/SECS.Engine/Slots/ScopeSlotStore.cs:31 — Read<T>(scope, kind)).
Caller: PolicyExecutor.RequireSlot and host code; engine reads, host
seeds. Context: ScopeSlotStore (accessed via tick.SlotStore).
Read-only? Yes (read path). Command-producing? No. May start
activities? No. May fire events / on_actions? No. May create / destroy
entities? No. Random? No. Callable in Preview? Yes. Callable in save
/ load? No. Result: IReadOnlyList<T>. Failure mode:
InvalidOperationException if the slot is not initialized
(ScopeSlotStore.cs:37). Saved? Yes — ScopeSlotStore.Snapshot()
serializes all slots; Restore() rehydrates.
E5 — slot write — COMMITTED
(src/SECS.Engine/Slots/ScopeSlotStore.cs — Initialize, Append,
Insert, Replace, RemoveAt).
Caller: host at activation time (e.g. CharacterSlots.SeedDefaults)
and at runtime for player-edited policy rules. Context:
ScopeSlotStore directly. Read-only? No. Command-producing? No —
direct store mutation, not via CommandBuffer. May start activities?
No. May fire events / on_actions? No. May create / destroy entities?
No. Random? No. Callable in Preview? No (slot writes during preview
would corrupt state). Callable in save / load? No (initialization
must happen before policy evaluation; Restore is the load path).
Result: void. Failure mode: InvalidOperationException on
uninitialized slot for write ops; InvalidOperationException on
duplicate Initialize. Saved? Yes — slot contents are captured in
Snapshot(). Saved currently from SECS source? No — there is no
committed source-form syntax for slot writes; host code owns slot
seeding. Direct-call pattern endorsed; see § 17.5.
11.6 Group F — Commands
F1 — create_entity — COMMITTED
(src/SECS.Engine/Pipeline/TickContext.cs:229 —
CreateEntity(templateId, ...); also TemplateCommandContext.cs:66).
Caller: SECS system / event / activity bodies via
ctx.CreateEntity(templateId) or
ctx.CreateEntity(templateId, scopeTarget); host can also call.
Context: TickContext or TemplateCommandContext. Read-only? No —
creates the entity and runs activation. Command-producing? Yes
(activation registers channel sources / bindings via an internal
CommandBuffer). May start activities? No (activation does not start
activities directly; lifecycle hooks may). May fire events /
on_actions? No (no TickContext exposure inside activation). May
create / destroy entities? Yes (creates; does not destroy here).
Random? Accessible via ctx.Random on TickContext. Callable in
Preview? No — CreateEntity throws if IdGenerator is null
(TickContext.cs:259). Callable in save / load? No. Result:
EntityHandle. Failure mode: InvalidOperationException on null
IdGenerator. Saved? The new entity must be persisted by the host;
activated channel sources / bindings are tracked by the engine.
F2 — destroy-entity surface — PARTIAL
(src/SECS.Engine/TemplateActivator.cs:401 — Destroy;
TemplateActivator.cs:386 — DestroyWithChildren).
Caller: host or system bodies via an exposed TemplateActivator
reference. Context: host holds TemplateActivator directly; no
TickContext wrapper. Read-only? No. Command-producing? Yes (removes
channel sources / bindings internally). May start activities? No. May
fire events / on_actions? No. May create / destroy entities? Yes
(destroys; DestroyWithChildren recurses). Random? No. Callable in
Preview? No. Callable in save / load? No. Result: void. Failure
mode: InvalidOperationException on missing instance. Saved?
Destruction must be mirrored in host save data; the engine removes
from InstanceStore. Status: PARTIAL — there is no committed
ctx.DestroyEntity(handle) lowering on TickContext; the only
committed surface is the host-direct TemplateActivator API. See § 12.1.
F3 — add_modifier — COMMITTED
(src/SECS.Abstractions/Commands/CommandBufferExtensions.cs:107 —
AddModifier; also TickContext.AddModifierWhen).
Caller: any code with CommandBuffer access. Context: CommandBuffer.
Read-only? No. Command-producing? Yes — this IS the command. May
start activities? No. May fire events / on_actions? No. May create /
destroy entities? No (a modifier is registered via command processing;
it does not start activities). Random? N/A. Callable in Preview? No
(would mutate state). Callable in save / load? No. Result: void.
Failure mode: InvalidOperationException at flush time if the
modifier is not registered. Saved? Modifier bindings are volatile and
currently NOT persisted by the engine — host must serialize
ModifierBindingStore contents (not in ScopeSlotStore or
ActivityRunState).
F4 — remove_modifier — COMMITTED
(src/SECS.Abstractions/Commands/CommandBufferExtensions.cs:132 —
RemoveModifier).
Same constraints as F3. Saved? See F3 — modifier state is transient.
F5 — increment / set scope-field command — COMMITTED
(src/SECS.Abstractions/Commands/CommandBufferExtensions.cs:171 —
IncrementScopeField; also SetScopeField).
Caller: any code with CommandBuffer. Context: CommandBuffer.
Read-only? No. Command-producing? Yes. May start activities? No. May
fire events / on_actions? No. May create / destroy entities? No.
Random? N/A. Callable in Preview? No (would mutate the host field
value). Callable in save / load? No. Result: void. Failure mode: at
flush, ISecsHostCommands.CallScopeCommand dispatches to host;
errors are host-implementation-defined. Saved? Host owns the scope
field; SECS does not persist field values.
F6 — fire-event surface — NOT-COMMITTED.
There is no ctx.FireEvent(eventId) API on TickContext. Pulse
events are fired by EventDispatcher.TickPulse from
EventPulseStep.Execute in the pipeline; SECS source fires events
through on_actions only. The committed scripted-fire path is
FireOnAction (A7). See § 12.2.
F7 — on_action fire (from a body) — COMMITTED.
Same surface as A7 (ctx.Events.FireOnAction).
F8 — save_scope_as / get-saved-scope — COMMITTED
(src/SECS.Engine/Pipeline/TickContext.cs:62 — SaveScope;
TickContext.cs:69 — GetSavedScope).
Caller: SECS system / event / activity bodies; host bridge code.
save_scope_as lowers to ctx.SaveScope(hash, entity). Context:
TickContext. Read-only? SaveScope is a write; GetSavedScope is
a read. Command-producing? No (in-memory transient dictionary). May
start activities / fire events / create / destroy entities? No.
Random? N/A. Callable in Preview? No (saved scopes are dispatch-frame
specific; preview does not run through dispatch). Callable in save /
load? No — saved scopes are cleared between ticks via frame push /
pop. Result: void / EntityHandle. Failure mode: GetSavedScope
returns EntityHandle.Null if not found (no exception). Saved? No —
saved scopes are transient within a dispatch frame; PendingChoice
captures a snapshot for deferred player choices
(EventDispatcher.cs:298) but that snapshot is also not persisted
.
11.7 Group G — Registry / save
G1 — registry validate — COMMITTED
(src/SECS.Engine/SecsRegistry.cs:2945 — Validate()).
Caller: host at boot, after all Register* calls. Context:
SecsRegistry (self). Read-only? Yes — analysis only, no mutations.
Command-producing? No. May start activities? No. May fire events /
on_actions? No. May create / destroy entities? No. Random? No.
Callable in Preview? N/A. Callable in save / load? No. Result:
RegistryDiagnostics (list of RegistryDiagnostic with codes and
severities). Failure mode: never throws — returns diagnostics that
include error-level items. Saved? No.
G2 — partial runtime restore primitives — PARTIAL. Two store-level restore surfaces exist today; this is not a unified SECS save/load boundary:
- Surface A:
ScopeSlotStore.Snapshot()/ScopeSlotStore.Restore(snapshots)(ScopeSlotStore.cs:89, 110). - Surface B:
ActivityExecutor.SeedNextRunId(nextValue)beforeActivityRunStore.Restore(activity, state, durationTicks)(ActivityRunStore.cs:190,ActivityExecutor.cs:57). - Re-derived runtime state:
PrevTickSnapshotStoreis warmed from live channel values after restore / first tick. ItsSnapshot*methods are per-tick capture APIs for previous-value reads, not durable save payload.
Caller: host save / load driver. Context: host-driven; no
TickContext. Read-only? Save reads existing state; restore writes to
fresh stores. Command-producing? No. May start activities? No. May
fire events / on_actions? No. May create / destroy entities? No.
Random? No. Callable in Preview? N/A. Callable in save / load? Yes —
this is the current partial restore surface. Result: void (restore) /
IReadOnlyList<SlotSnapshot> (slot snapshot) / ActivityRunState[]
(activity restore — host materializes). PrevTickSnapshotStore has no
durable restore result because it is re-derived runtime state. Failure mode:
InvalidOperationException on restore to a non-empty store, type
mismatch, unknown slot kind, or schema violation. Saved? Yes — these
are partial runtime save/load primitives only for ScopeSlotStore and
activity runs. A unified SecsSavePayload / SaveSnapshot entry point
remains future work.
12. Uncommitted invocation surfaces
12.1 TickContext destruction helpers
TickContext exposes creation helpers but not DestroyEntity or
DestroyWithChildren. The committed destruction path remains
host-direct TemplateActivator use.
12.2 Direct pulse-event fire by id
There is no committed ctx.FireEvent(eventId) API. Pulse events fire
through the pipeline, and scripted dispatch goes through on_action.
12.3 Structural hardening for read-only contexts
The live semantic rule is in § 13, but some read-only contexts still need stronger structural guards against command access. That hardening work is backlog, not current contract.
13. Read-only vs command-producing taxonomy
This section defines the read-only vs command-producing boundary for callable SECS surfaces.
Resolution: rule bodies are read-only.
Policy rule bodies (the body of EvaluateRule(...)) may call only:
ISecsHostReadsmethods (read scope fields, walk graph, enumerate collections, call read-only scope methods).- Channel resolution (
ctx.Tick.ChannelResolver.ResolveIntFast, etc.). RuleDecisionconstructors (RuleDecision.Continue,Call(...),CallBest(...),Complete(),Fail(...),Wait(...),CancelChild(...)).
Policy rule bodies may NOT call:
ctx.Tick.Commands.*(any command-buffer method).ctx.Tick.FlushCommands().ctx.Tick.CreateEntity(...)or any entity-creation surface.ISecsHostCommandsmethods (novoidscope methods, no host-side side effects).ISecsHostWritesmethods (the dirty-sync writer; that is the engine's job at end-of-tick).
This matches what generated policies already do today. The
SECS-Compiler-Plan.md:143 mention of "policy rule bodies" in the
flush-contract list was a drafting error and has been removed in this
wave.
13.1 Read-only surfaces
The following surfaces are read-only by declaration kind. They receive contexts or signatures that structurally exclude the command buffer:
| Surface | Context / signature | Structural read-only enforcement |
|---|---|---|
formula body | (EntityHandle, EntityHandle, EntityHandle, ISecsHostReads) -> T | No TickContext; only ISecsHostReads admitted. |
trigger body | same as formula | Same. |
| modifier effect formula (math, not the apply step) | ISecsHostReads only | Same. |
template query | (ScopeFrame, ISecsHostReads, in TArgs) -> TResult | No TickContext. |
contract query | same as template query | Same. |
| scope query method (non-void) | ISecsHostReads.CallScopeMethod | The void / non-void split is the discriminator. |
event Condition | EventContext (carries TickContext) | Normative read-only; future compiler/analyzer handoff currently reserved as SECS0803. |
activity IsVisible | ActivityContext (no FlushCommands()) | Weak structural barrier; future compiler/analyzer handoff currently reserved as SECS0803. |
activity IsTargetValid | ActivityContext | Same. |
activity CanStart | ActivityContext | Same. |
activity GetDurationTicks | ActivityContext | Same. |
activity Preview | ActivityContext (calls EffectPlanner) | Same. |
policy rule Decision (C13-18) | PolicyRunContext (no FlushCommands()) | Weak structural barrier; future compiler/analyzer handoff currently reserved as SECS0803. |
| policy candidate scoring / preview | PolicyExecutor.ScoreCandidate reads EffectPlan data | Read-only by construction. |
| UI / tooling dry-run | future read-only context | Not yet implemented; reserved for future implementation. |
13.2 Command-producing surfaces
The following surfaces are command-producing. They receive contexts that
expose Commands and (for the lifecycle methods) FlushCommands():
| Surface | Context | Notes |
|---|---|---|
system Execute | TickContext | Canonical host of ctx.Commands.* + ctx.FlushCommands(). |
pulse event Execute | EventContext | Distinct from Condition which is read-only. |
| event option handler | EventContext | Generated examples call ctx.Commands.AddModifier, FlushCommands. |
| on_action firing site | TickContext (system context) or EventContext (event context) | The fire is a command; subscribers run with their own contexts. |
activity OnStart / OnUpdate / OnComplete / OnStop / OnCancel / OnFail | ActivityRunContext | Has public CommandProcessSummary FlushCommands() shorthand. |
void scope method | ITemplateCommandContext (template) or TickContext.CallScopeCommand | Routes through ISecsHostCommands.CallScopeCommand. |
template command method (method void) | ITemplateCommandContext | Same flush contract as system/event/activity bodies. |
entity creation / destruction (create_entity, destroy_entity) | TickContext.CreateEntity, etc. | Requires IdGenerator != null; legal only from command-producing contexts. |
| modifier attach / remove (apply step) | ctx.Commands.AddModifier / RemoveModifier | Distinct from modifier effect math (read-only). |
| scope field increment / write commands | ctx.Commands.IncrementScopeField | Canonical mutation surface. |
13.3 Diagnostics that enforce the taxonomy
These compiler diagnostics are reserved by this doc; their concrete implementation lands as part of the future SECS compiler (Roslyn fork) or as Roslyn analyzers running against the generated tree:
- SECS0803 — future compiler/analyzer handoff for "command call in read-only body". Would fire when a
read-only surface listed in § 13.1 lowers to
CommandBuffer.*,ctx.Commands.*,ctx.FlushCommands(),ctx.CreateEntity(...),ctx.CallScopeCommand(...), or any host void-method call throughISecsHostCommands/ISecsHostWrites. Catches the "read-only body emitting a command" mistake at compile time. Earlier drafts usedSECS0801; the doc now usesSECS0803to avoid colliding with the runtimeModDiagnosticCode.InjectClosedSlot = 801(shipped public API, immovable) and with thepropagates_to wherediagnostic now assigned toSECS0802. See § 21.5 for the collision history. - SECS0804 — future compiler/analyzer handoff for "host command without command context". Would fire when
generated code reaches
ISecsHostCommandswithout being inside a command-producing surface listed in § 13.2. Earlier drafts usedSECS0802; the doc now usesSECS0804becausepropagates_to wherelanded onSECS0802. See § 21.5.
13.4 Structural vs normative enforcement
The taxonomy mixes two kinds of enforcement:
- Structural — the type signature of the surface excludes the
command surface entirely. Examples:
Formula*Delegate,TriggerDelegate,template query(onlyISecsHostReadsparameter). Any code that tries to enqueue commands fails to compile in C#. - Normative — the type signature transitively reaches the command
surface (via
EventContext.Tick.Commands,ActivityContext.Tick.Commands,PolicyRunContext.Tick.Commands), but the discipline forbids using it. Generated code today honors the discipline; a future compiler/analyzer pass would enforce it under the reserved SECS0803/SECS0804 ids.
A future packaging follow-up may eliminate the normative gap by
introducing read-only-only context types (EventQueryContext,
ActivityQueryContext, PolicyQueryContext) that physically lack
Tick.Commands. That is a structural improvement, not a semantic change;
the design intent is identical to today's normative discipline.
13.5 Cross-references
docs/design/04-behavior.md— system / event / activity / policy declarations.docs/design/05-expressions.md— query / scope-method / formula signatures.docs/design/09-ai-policies-and-activities.md— policy semantics and lowering surface.SECS-Compiler-Plan.md— flush contract for generated command-producing bodies.docs/design/10-host-secs-execution-boundary.md § 11— invocation matrix per surface.
14. Host-callable SECS API
This section documents the public surfaces host code calls to drive SECS.
Every entry point listed here lives in SECS.Engine or SECS.Abstractions
and accepts typed arguments (typed ids, ScopeFrame, EntityHandle,
TickContext, etc.). Host code must not reach past these surfaces into
Generated/*.cs private members; the only exception is H.* hash
constants and compiler-emitted typed-args record types, which are part
of the SECS contract by design.
14.1 Boot sequence
The host wires SECS once at startup, in this order:
var registry = new SecsRegistry();Generated.<GameName>.SecsModule.Initialize(registry);— generated entry point that calls everyRegister*API on the registry.registry.RegisterCandidateBuilder(...)for each candidate builder the host owns (currently runtime-only; see § 13.1 and § 17.5).registry.RegisterActivityMod(...)/registry.RegisterPolicyMod(...)for each loaded mod.var modResult = registry.FinalizeModRegistration();— produces the merged read-only view.var diagnostics = registry.Validate();— static analysis of the populated registry; host should warn or fail ondiagnostics.HasErrors.registry.ValidateDependencies();— formula cycle detection.
After step 5, the merged view is frozen for the rest of the process.
14.2 Tick loop
| Surface | Signature | Notes |
|---|---|---|
TickPipeline.Execute | void Execute(TickContext) | Single canonical tick entry. Runs all registered ITickStep instances in order. |
GameRuntime.Update (host helper) | void Update() | Host-side after-mutation re-sync: BindingUpdater.UpdateAll() + ChannelResolver.SyncDirty(dirtySet, Bridge). Must be called after any direct world mutation that the engine has not seen. |
14.3 Entity lifecycle
| Surface | Signature | Notes |
|---|---|---|
TickContext.CreateEntity | EntityHandle CreateEntity(TemplateId, ...) and overloads with initial fields / scope target | Lowering of create_entity. Requires TickContext.IdGenerator != null. |
TemplateActivator.Create | EntityHandle Create(TemplateId, long entityId, EntityHandle scopeTarget, TemplateCreationInitializer? initialize = null, IReadOnlyDictionary<ulong, ITemplateValue>? initialData = null) | Host-controlled-id path (e.g. id=1 settlement, id=2 config). |
TemplateActivator.CreateEntity | EntityHandle CreateEntity(TemplateId, EntityHandle scopeTarget = default, IReadOnlyDictionary<ulong, ITemplateValue>? initialData = null) | Generated-id path; routes through registered IEntityIdGenerator. |
TemplateActivator.Destroy / DestroyWithChildren | void Destroy(EntityHandle) / void DestroyWithChildren(EntityHandle) | Throws if entity not tracked in InstanceStore. |
TemplateActivator.Transition | ActivationSummary Transition(EntityHandle, TemplateId) | Template upgrade along a declared transition edge. Paired with CanTransition and GetAvailableTransitions. |
14.4 Template queries and commands
| Surface | Signature | Notes |
|---|---|---|
SecsRegistry.EvaluateTemplateQuery<TResult> | overloads accepting EntityHandle or ScopeFrame and optional in TArgs | Returns the typed result. Throws on missing template / method / type mismatch. |
TemplateActivator.CallMethod | void CallMethod(TemplateId, ulong methodId, EntityHandle owner, EntityHandle scopeTarget) and typed-args overload | Lowers entity.method(args). Throws if method is a query, not a command. |
14.5 Activity lifecycle
| Surface | Signature | Notes |
|---|---|---|
ActivityExecutor.Start | ActivityRun? Start(ActivityId, ActivityContext) and Start(ActivityRequest, TickContext) | Returns null on CanStart == false; throws on args schema mismatch. |
ActivityExecutor.StartAt | Overloads for ActivityRequest + explicit clock, and ActivityId + context + explicit clock / args / origin | Time-banked / queued starts preserve explicit clock ticks. Request-based overloads preserve ActivityRequestOrigin; low-level id/context overloads use Unknown unless the origin overload is called. |
ActivityExecutor.Preview | EffectPlan Preview(ActivityId, EntityHandle actor, EntityHandle target, TickContext, ActivityArgsBlob = default) | Throws InvalidOperationException if the activity is not registered. Read-only — see § 13.1. |
ActivityExecutor.Cancel / Stop / Fail | bool Cancel(ActivityRun, TickContext) (etc.) | Returns false on already-terminal runs; transitions on success. |
ActivityExecutor.Tick / TickAt | void Tick(TickContext) / void TickAt(TickContext, int clockTick) | Drives lifecycle for all live runs. Called by the pipeline step or host-controlled scheduler. |
ActivityExecutor.ActiveRuns | IReadOnlyList<ActivityRun> | Snapshot in insertion order. |
ActivityRunStore.GetByActor / TryGet / TryGetActive | run inspection by actor / id / lane | All read-only; safe in any context. |
ActivityRunStore.Restore | ActivityRun Restore(SecsActivity, ActivityRunState, int durationTicks) | Save/load path. Validates ActivityId, SchemaId, Lane (SECS0810 / 0811 / 0812). |
14.6 Policy dispatch
| Surface | Signature | Notes |
|---|---|---|
PolicyDispatcher.Tick | PolicyDispatchResult Tick(SecsPolicy, EntityHandle actor, TickContext) | Throws if slots not seeded; returns Continue / Complete / Fail. Slots seed at entity activation via the direct-call pattern in § 17.5. |
PolicyExecutor.CallBest | ActivityCandidate? CallBest(SecsPolicy, SelectorId, PolicyRunContext) | Returns the best-scored candidate or null. |
PolicyExecutor.EvaluateAllRules | IEnumerable<(RuleId, RuleDecision)> EvaluateAllRules(SecsPolicy, EntityHandle, TickContext) | Used for dry-run and tooling. Read-only — see § 13.1. |
There is no single EvaluatePolicy(PolicyId, EntityHandle, TickContext) convenience entry today. Hosts must look up the SecsPolicy from registry.GetPolicy(policyId) and call PolicyDispatcher.Tick. A future ergonomics follow-up may add the one-call form; this is recorded as a known gap.
14.7 on_action dispatch
| Surface | Signature | Notes |
|---|---|---|
TickContext.SaveScope | void SaveScope(ulong nameHash, EntityHandle) and string overload | Required pre-fire for every scope listed in OnActionDeclaration.ProvidedScopes. |
EventDispatcher.FireOnAction | void FireOnAction(ulong onActionId, EntityHandle target, TickContext) | Throws if a ProvidedScopes entry is not pre-saved. Silent on no subscribed events. |
on_action is metadata-only at the source level; firing is a host-side or system-side command. See § 11 and docs/design/04-behavior.md.
14.8 Player choice resolution
| Surface | Signature | Notes |
|---|---|---|
EventDispatcher.PendingChoices | IReadOnlyList<PendingChoice> | Snapshot of unresolved player choices. |
EventDispatcher.ResolveChoice | void ResolveChoice(int choiceIndex, int optionIndex, TickContext) | Silent no-op on invalid indices. |
14.9 Validation surfaces
| Surface | Signature | Notes |
|---|---|---|
SecsRegistry.Validate | RegistryDiagnostics Validate() | Warn-only static analysis. Never throws. Host should call at boot and react to HasErrors / HasWarnings. |
SecsRegistry.ValidateDependencies | IReadOnlyList<string> | Formula cycle detection. |
14.10 Bridged read / write / command surfaces (host implements)
These are the inverse direction — SECS calls host code. Listed for completeness; full contract in § 5.
ISecsHostReads— read scope fields, walk graph, enumerate collections, call read-only scope methods.ISecsHostWrites— push resolved-channel dirty values back to host storage.ISecsHostCommands— callvoidscope methods that produce host-side mutation.IEntityIdGenerator/IEntityCreator— host id generation and creation hooks.
14.11 Open API gaps
These are surfaces present-but-fragile or absent-but-needed. Keep them as backlog inventory, not as live contract changes.
- AddResource bypass.
GameRuntime.AddResource()callstemplate.Activate(scope, Bridge, buffer)directly on the internalTemplateEntry, bypassingTemplateActivator.Create'sInstanceStore.Addand lifecycle validation. Future refactor: route throughActivator.Createor extract anActivator.ActivateInPlace(templateId, entity, scopeTarget)for the shared-entity case. registry.Validate()not called at boot.GameRuntime.Create()never invokesregistry.Validate()orValidateDependencies()after registration. Static-analysis errors are silently missed. A future host-hardening follow-up should add a startup validation call and warn or fail onHasErrors.- No
EvaluatePolicy(PolicyId, ...)convenience entry. Host must manually look up theSecsPolicyand callPolicyDispatcher.Tick. A future follow-up may add a one-call wrapper. Valenar.Generated.Templates.Skillsdirect sub-namespace use.examples/valenar/Host/ReadModels/CharacterSheetReadModel.csimports a generated sub-namespace directly. This couples the host read-model to the compiler-emitted directory hierarchy. Future doc-hygiene cleanup should replace it with hash-constant access.EffectPlanSnapshot.csreflection onValenar.Generated.H. Debugging-only reverse-name table built via reflection. Not load-bearing for correctness, but couples to a Generated type name.
These gaps do not change the committed boundary. They are tracked here so future ergonomics or host-hardening work has a stable punch list.
14.12 Cross-references
- § 5 — Host bridge implementation contract (the inverse direction).
- § 11 — Invocation matrix per surface.
- § 13 — Read-only vs command-producing taxonomy.
docs/design/04-behavior.md— system / event / activity / policy / on_action declarations.
15. Host bridge per-method semantics
These are the concrete per-method semantics for the bridge surfaces named abstractly in § 5. For source-set restrictions see § 6. For invocation context constraints see § 13.
15.1 ISecsHostReads (14 methods)
The read bridge. Every method is read-only — no command buffer, no engine state mutation. Implementations may throw InvalidOperationException on unrecognized scope/field/method ids; the Valenar bridge does, which catches bridge-gap bugs at test time.
| Method | Lowering target | Failure | Source-set |
|---|---|---|---|
WalkScope(source, scopeId) | walks_to edges; formula scope-walk; Base source resolution | returns EntityHandle.Null on miss | data-only mod cannot add walks (SECS0609) |
GetChildren(parent, collectionId) | system/event batch iteration of ScopedList/ScopedDictionary | empty span on miss | data-only mod cannot add collections (SECS0609) |
LookupTemplate(templateId) | read-only template metadata view | returns null silently | mods may add templates; lookup works post-register |
GetChildByTemplate(parent, collectionId, templateId) | keyed-by-template child lookup | returns EntityHandle.Null on miss | same as GetChildren |
WalkChildren(parent, collectionId) | aggregate channel iteration; BuildFromCollection | empty enumerable on miss | same as GetChildren |
ReadInt/Long/Float/Double/Bool | scope field read in formulas, triggers, system/event/activity bodies; Base channel source | host-defined (Valenar throws on unknown pair) | mods cannot add scope fields (SECS0609) |
ReadEntity | EntityHandle-typed scope field | host-defined (Valenar throws — no entity-typed fields used) | same |
CallScopeQuery<TArgs, TResult> | non-void scope method call | host-defined (Valenar throws on unknown method) | mods cannot add scope methods (SECS0609) |
ResolveChannelInt/Long/Float/Double/Bool | nested channel reads in formulas (resolved value, not raw field) | propagates from ChannelResolution | mods may declare and resolve channels |
ResolveTemplateValueInt/Long/Float/Double/Bool | effective template value with modifier effects applied | returns type default on unknown | mods may add template fields |
ReadPrevTickInt/Long/Float/Double/Bool | Previous-tick bridge reads; requires ChannelDeclaration.TrackPrev = true | throws on missing required snapshot or non-tracked channel | mods may declare track_prev channels |
Extension WalkScopePath(source, ReadOnlySpan<ulong> path) (in SecsHostExtensions) chains WalkScope calls; null on first null hop. Not part of the interface.
Three observations:
ReadDouble,ReadBool,ReadEntityexist in the interface but the Valenar bridge currently maps no field to any of them. Unreachable at runtime if any double/bool/entity-typed scope field is declared without bridge wiring. Bridge implementers must wire each typedRead*they expect to be called.HostBridge.AttachResolveris lazy-injected after construction (works around the bridge ↔ ChannelResolver constructor cycle). IfResolveChannel*is called beforeAttachResolver, the bridge returns zero silently — incorrect rather than an error. Boot order matters; see § 14.1.- The
ReadInt-throw-on-miss pattern is by convention, not by interface contract. Prev-tick bridge reads are stricter in the live contract here: missing required snapshots should throw so bridge/setup gaps fail loudly instead of silently manufacturing zeros.
15.2 ISecsHostWrites (5 methods)
The dirty-sync write bridge. Called only from command-producing contexts during ChannelResolution.SyncDirty and from CommandProcessing applying SetScopeField/IncrementScopeField.
| Method | Lowering target | Failure | Source-set |
|---|---|---|---|
WriteInt/Long/Float/Double/Bool | dirty-sync flush of resolved channel values; command-applied field mutation | host-defined (Valenar throws on unknown pair) | data-only mod-contributed channels have no host backing and are never written |
WriteDouble is part of the interface but no SyncDirty path in Valenar invokes it today. Similar to the Read* observation above.
15.3 ISecsHostCommands (1 method)
The command bridge for void scope methods. Called only from command-producing contexts.
| Method | Lowering target | Failure | Source-set |
|---|---|---|---|
CallScopeCommand<TArgs>(target, scopeId, methodId, in args, cmds) | void scope method calls in system/event/activity bodies; TickContext.CallScopeCommand; TemplateCommandContext.CallScopeCommand | host-defined (Valenar throws on unknown method) | mods cannot add or patch void scope methods (SECS0609) |
The CommandBuffer cmds parameter is the same buffer the engine will flush. Host implementations enqueue further field-mutation commands onto it rather than mutating host state directly when transactional safety matters.
15.4 IEntityIdGenerator (1 method)
| Method | Lowering target | Failure | Source-set |
|---|---|---|---|
NextEntityId() : long | TickContext.CreateEntity; TemplateActivator.CreateEntity (internal) | undefined; host must guarantee uniqueness | N/A (host service, not in any mod) |
Valenar implementation: GameWorld itself implements this and is passed as the id generator. Counter starts above 100 to avoid collision with host-controlled boot ids (id=1 settlement, id=2 config).
15.5 IEntityCreator (4 methods)
| Method | Lowering target | Failure | Source-set |
|---|---|---|---|
CreateEntity(id, contractId, scopeTarget, templateId) | TemplateActivator.Activate; TickContext.CreateEntity; TemplateCommandContext.CreateEntity | host-defined (Valenar EntityFactory throws on unknown contractId) | N/A (host service) |
SetEntityName(entity, name) | TemplateActivator.Activate when TemplateEntry.Name != null; SwapTemplate | undefined; Valenar guards against overwriting richer host names with placeholder template names | N/A |
SetEntityTemplate(entity, templateId) | TemplateActivator.SwapTemplate (template swap at runtime) | undefined by interface; host must update any channel-source references keyed on the template id | N/A |
DestroyEntity(entity) | TemplateActivator.Destroy and DestroyWithChildren (after engine has removed channel sources and bindings) | host-defined | N/A |
There is no committed ctx.DestroyEntity(handle) surface on TickContext (F2 in § 11 is PARTIAL). The only committed destruction path is host-direct TemplateActivator.Destroy / DestroyWithChildren.
15.6 Bridge wiring and validation
The host wires the five interfaces in GameRuntime.Create (or its game-specific equivalent):
HostBridge bridge = new HostBridge(world)implementsISecsHostReads,ISecsHostWrites,ISecsHostCommands.bridge.AttachRegistry(registry)enablesLookupTemplate.bridge.AttachResolver(channelResolver)enablesResolveChannel*andResolveTemplateValue*.bridge.AttachTemplateValues(templateValues)enables template-value resolver delegation.bridge.AttachPrevTickStore(prevTickStore)enablesReadPrevTick*.EntityFactory entityFactory = new EntityFactory(world)implementsIEntityCreator.world(GameWorld) implementsIEntityIdGeneratordirectly.TemplateActivator(...)constructor wires reads, commands, creator, idgen.TickContext { Host = bridge, Commands = cmd, IdGenerator = world, ... }named-property init.
There is no compile-time or runtime "all required bridge methods implemented" check beyond C# interface satisfaction. Missing (scopeId, fieldId) coverage is a runtime InvalidOperationException at the first call site. The RegistryDiagnostics surface (§ 14.9) validates declaration metadata but does not probe the bridge.
15.7 Source-set restriction enforcement (cross-reference to § 6)
| Diagnostic | Status | Notes |
|---|---|---|
SECS0604 | Implemented | src/SECS.Engine/Modding/ModDiagnosticCode.cs — operation-kind/declaration-kind mismatch in mod registry. |
SECS0609 | Compiler-phase-only | Cited in docs/design/01-world-shape.md:253-272 and § 6 above as the data-only-mod-declares-host-surface diagnostic. No ModDiagnosticCode / runtime enum entry is applicable: ModRegistry handles activity/policy patches after lowering and never sees source-set declarations for scopes, fields, collections, scope methods, walks_to, existing contract method/lifecycle surfaces, or on-action provides entries. |
Today, no runtime enforcement of the source-set boundary exists, by design. The future SECS compiler (Roslyn fork) is the intended enforcement point. A data-only mod that somehow introduced a new scope field would generate a ReadInt call with an unrecognized field hash, which would throw InvalidOperationException in Valenar's bridge — incidental coverage, not a designed guard.
15.8 Cross-references
- § 5 — abstract host bridge contract.
- § 6 — host-capable vs data-only mod boundary.
- § 11 — invocation matrix per surface.
- § 13 — read-only vs command-producing taxonomy.
- § 14 — host-callable SECS API (the inverse direction).
docs/design/01-world-shape.md:253-272— source-set restrictions.
16. Universal ActivityRequest model
This section formalizes the activity request / candidate / run / save model used by the current runtime and hand-written stand-ins.
16.1 ActivityRequest — the single handshake type
public readonly record struct ActivityRequest(
ActivityId ActivityId,
EntityHandle Actor,
EntityHandle Target,
ActivityArgsBlob Args,
ActivityRequestOrigin Origin)
5 fields. Three shorter convenience overloads exist for legacy / best-effort call sites (no-target, no-args, 4-arg form); all delegate to the 5-field constructor and default the missing fields to EntityHandle.Null / ActivityArgsBlob.Empty / ActivityRequestOrigin.Unknown. Live player, policy, system, event, and mod producers should use the five-field form or a producer-specific wrapper that supplies origin. Source: src/SECS.Engine/Activities/ActivityRequest.cs.
16.2 ActivityRequestOrigin
public enum ActivityRequestOrigin
{
Unknown = 0,
Player,
Policy,
System,
Event,
Mod,
}
6 values. No expansion to Host / Activity / TemplateMethod / Debug / Test is committed; any such addition requires an explicit ADR. Source: src/SECS.Engine/Activities/ActivityRequestOrigin.cs.
Producer contract. Unknown is a legacy / best-effort value for direct executor call sites that do not own a declared producer boundary. Live public producers stamp their boundary explicitly: Valenar's player queue starts through ActivityRequestOrigin.Player; PolicyDispatcher.ProcessCall creates policy-origin requests; and PolicyDispatcher.ProcessCallBest normalizes the selected candidate request to ActivityRequestOrigin.Policy before start. This is runtime/save provenance, not a client-visible field by default.
16.3 ActivityCandidate — the policy decision shape
public sealed record ActivityCandidate(
ActivityRequest Request,
EffectPlan? Preview,
float PolicyScore,
PredictionConfidence ScoreConfidence,
CandidateStatus Status)
CandidateStatus lifecycle: Available → Filtered | Selected → Started | Rejected. ActivityCandidate.Empty(request) produces a zero-score, null-preview Available candidate. Used by PolicyExecutor.CallBest and BuildCandidates. Source: src/SECS.Engine/Activities/ActivityCandidate.cs, CandidateStatus.cs.
16.4 ActivityRun and ActivityRunState — runtime + persist identity
ActivityRun is the live runtime object. Its constructor is internal — only ActivityExecutor.StartAt (live start) and ActivityRun.FromState (save/load restore) can produce instances.
ActivityRunState is the 11-field persist record:
public sealed record ActivityRunState(
ActivityRunId RunId,
ActivityId ActivityId,
EntityHandle Actor,
EntityHandle Target,
ActivityLaneId Lane,
ActivityRunStatus Status,
ActivityArgsBlob Args,
ActivityRequestOrigin Origin,
ActivityRunId ParentRunId,
ActivityRunId ChildRunId,
int TicksElapsed)
ToState() projects exactly these 11 fields. FromState() round-trips them, including Origin. Restore never fabricates provenance from the host/load call site; a restored run keeps the origin saved by the original live start.
ActivityRunStatus: 5-value enum — Running, Completed, Stopped, Canceled, Failed.
ActivityRunStore.Restore enforces three hard preconditions before reconstruction:
SECS0810—ActivityIdmismatch (the recorded id does not match the activity registered today).SECS0811—ExpectedArgsSchemaIdmismatch (the args blob's schema does not match what the activity declares).SECS0812— zeroLane(lane id must be non-zero).
These are stable id-based checks; restore never indexes by array position.
16.5 ActivityLaneId, ActivityLanePolicy, and concurrency slot identity
ActivityLaneId(ulong Value). None = 0. FNV-1a-64 of a wire id string via ActivityLaneDeclaration.Create(string).
ActivityLanes ships 5 built-in declarations: Primary, CombatEncounter, CombatActivity, Movement, Interaction. Hosts may declare additional lanes through their content; the engine treats each ActivityLaneId as opaque.
ActivityLanePolicy: 3-value enum.
Reject— implemented. The defaultSecsActivity.LaneOccupiedPolicy. Executor enforces at bothCanStartAtandStartAt.Queue— RESERVED. ThrowsNotImplementedException. No silent fallback.Preempt— RESERVED. ThrowsNotImplementedException. No silent fallback.
The reserved policies are committed enum values; their semantics are deferred until queue / preempt mechanics are designed.
16.6 ActivityArgsBlob and IActivityArgs
ActivityArgsBlob(ulong SchemaId, ReadOnlyMemory<byte> Payload). Empty = (0, empty). Decode<T>() verifies SchemaId against the target type before decoding. Encode<T>(T) static factory builds the blob from a T : IActivityArgs<TSelf>.
IActivityArgs<TSelf> (CRTP):
ulong SchemaId { get; }ReadOnlyMemory<byte> Encode()static abstract TSelf DecodeFrom(ActivityArgsBlob blob)
Concrete example: CastSpellArgs(TemplateId SpellDefinitionId). SchemaId = FnvHash.Compute("CastSpellArgs(TemplateId)"). Payload is 8-byte little-endian TemplateId.Value.
SecsActivity.ExpectedArgsSchemaId (virtual, defaults 0UL) binds an activity to its args record's SchemaId. Activities decoding typed args MUST override this. Mismatch is enforced at:
ActivityExecutor.StartAt—InvalidOperationExceptionat start boundary.ActivityRunStore.Restore—SECS0811at save/load boundary.
16.7 One core start path
There is exactly one implementation of activity start: ActivityExecutor.StartAt(ActivityId activityId, ActivityContext context, int clockTick, ActivityArgsBlob args, ActivityRequestOrigin origin) (src/SECS.Engine/Activities/ActivityExecutor.cs).
The public start surface funnels into this implementation:
| Public method | Delegates to |
|---|---|
Start(ActivityId, ActivityContext) | StartAt(id, ctx, tick.TickNumber) |
Start(ActivityRequest, TickContext) | StartAt(req, tick, tick.TickNumber) |
StartAt(ActivityRequest, TickContext, int) | builds ActivityContext, calls StartAt(req.ActivityId, ctx, clockTick, req.Args, req.Origin) |
StartAt(ActivityId, ActivityContext, int) | StartAt(id, ctx, clockTick, ActivityArgsBlob.Empty) |
StartAt(ActivityId, ActivityContext, int, ActivityArgsBlob) | StartAt(id, ctx, clockTick, args, ActivityRequestOrigin.Unknown) |
StartAt(ActivityId, ActivityContext, int, ActivityArgsBlob, ActivityRequestOrigin) | the core implementation |
Execute(ActivityId, ActivityContext) | wraps Start and returns bool |
ExecuteAt(ActivityId, ActivityContext, int) | wraps StartAt and returns bool |
The pipeline executed by the core implementation:
CanStartAt(read-only validation; see § 13).ExpectedArgsSchemaIdvalidation againstargs.SchemaId.GetDurationTicks(read-only).DeductCosts(command-producing).ApplyCooldown(command-producing).new ActivityRun(internal constructor invocation, includingorigin).runStore.Add(run).OnStart(run, runContext)(command-producing).- Terminal-lifecycle handling for instant activities (
OnComplete/OnFail/OnStop/OnCancel) andrunStore.Removeif instant.
There is no second start path. The host UI, player queue, policy dispatcher, event handlers, system bodies, mods, debug tools, and tests all funnel through the same six public entry methods, all delegating to the same private StartAt.
16.8 No separate AI / player / host hidden semantics
The orchestration prompt's Discipline 8 ("one core executor path") is satisfied:
ActivityRunconstructor isinternaland is invoked from exactly one place: the coreStartAt. The only other producer ofActivityRuninstances isFromState(save/load restore).runStore.Addis called from exactly two places: the coreStartAt(live start) andActivityRunStore.Restore(save/load).- The host's
PlayerActivityQueuedoes not own or sequence runs separately; it builds anActivityRequestwithActivityRequestOrigin.Player, callsexecutor.StartAt(request, tick, activityClockTick), and reads backrun.RunIdfor queue tracking. - The policy
PolicyDispatcher.ProcessCall / ProcessCallBestcallsexecutor.Start(request, tick)withActivityRequestOrigin.Policy— same path. - Tests use the same public methods as host code.
If a future requirement demands per-origin start hooks (e.g. analytics, audit log, pre-start cancellation policy), they must be added inside the core StartAt and may branch on request.Origin. Adding a parallel start method that bypasses the core pipeline would be an architectural failure caught by this section's contract.
16.9 Cross-references
- § 5 — host bridge contract (the read/write/command surfaces the activity body uses).
- § 11 — invocation matrix per surface. Activity surfaces appear under Group B.
- § 13 — read-only vs command-producing taxonomy. Each lifecycle method is classified there.
- § 14 — host-callable SECS API. § 14.5 lists the public start/preview/cancel surface.
src/SECS.Engine/Activities/ActivityExecutor.cs— the single implementation.src/SECS.Engine/Activities/ActivityRequest.cs,ActivityRequestOrigin.cs,ActivityCandidate.cs,ActivityRun.cs,ActivityRunState.cs,ActivityLaneId.cs,ActivityLanePolicy.cs,ActivityArgsBlob.cs,IActivityArgs.cs— the model types.
17. Policy / slot / candidate-builder
Documents the policy-as-request-producer model, the no-fallback slot invariant, the CandidateBuilder runtime-only surface, and the content-family parameterization that prevents content explosion.
17.1 Policy as request producer
SecsPolicy.EvaluateRule(PolicyRunContext) → RuleDecision is read-only (see § 13.1). The dispatcher is the only site that translates a decision into a side effect.
PolicyDispatcher.Tick(SecsPolicy, EntityHandle actor, TickContext):
- Calls
policyExecutor.EvaluateAllRules(policy, actor, tick)— read-only iteration over rule slots. - Translates each
RuleDecisioninto one of:Continue(advance),Complete(stop, success),Fail(stop, failure),Wait(keep child, advance),Call(activityId, target, args)→ActivityExecutor.Start,CallBest(selector)→policyExecutor.CallBest→ActivityExecutor.Start, orCancelChild→ActivityExecutor.Cancel.
The policy itself never starts or cancels an activity. Policy is one of the six request producers (see § 16.2 — ActivityRequestOrigin.Policy). Source: src/SECS.Engine/Policies/PolicyDispatcher.cs:59, PolicyExecutor.cs:91.
17.2 No-fallback slot model
ScopeSlotStore.Read<T>(actor, slot) throws InvalidOperationException on missing slot or type mismatch. There is no ResolveSlotOrDefault method in the engine — grep confirms zero hits. PolicyExecutor.RequireSlot<T> calls store.Contains first; if not seeded it throws with an actionable message pointing at the seed helper.
SecsPolicy.Rules / Needs / Selectors properties are compile-time seed sources only — used as the initial argument to store.Initialize<T>(actor, kind, policy.Rules) inside the generated <ScopeName>Slots.SeedDefaults helper. They are not fallback read paths at evaluation time.
If a policy's slots are not seeded for an actor when PolicyDispatcher.Tick runs, the throw is loud and immediate. Sources: ScopeSlotStore.cs:31-45, PolicyExecutor.cs:117-140.
17.3 CandidateBuilder runtime-only surface
public delegate IReadOnlyList<ActivityRequest> CandidateBuilderDelegate(
EntityHandle actor, SlotKindId slot, TickContext tick);
Registered at boot via registry.RegisterCandidateBuilder(builderId, delegate). Looked up at evaluation time by policyExecutor.CallBest when SelectorSource is FromCollection(SlotKindId Slot, CandidateBuilderId Builder).
There is no committed .secs source syntax for declaring this bridge. Today it is a C# runtime/lowering surface implemented in hand-written stand-ins under Generated/Policies/, carrying the canonical no-source marker.
Unregistered or zero CandidateBuilderId throws at evaluation time. Source: src/SECS.Engine/Policies/PolicyExecutor.cs:186-196.
17.4 Content-family parameterization
The spell family is the canonical example:
Activity_CastSpellis one of seven allowlisted generic mechanic activities (seedocs/design/behavior-vocabulary.md § Group D). It is registered exactly once.CharacterSlots.KnownSpellsis aSlotKindIdslot whose elements areTemplateIdvalues — one per knownSpellDefinitiontemplate. The slot stores typed data ids, not activity ids.KnownSpellsCandidateBuilder.Build(Generated/Policies/KnownSpellsCandidateBuilder.cs) readstick.SlotStore.Read<TemplateId>(actor, slot)and produces oneActivityRequest(H.Activity_CastSpell, actor, EntityHandle.Null, encodedArgs, ActivityRequestOrigin.Policy)per entry, whereencodedArgs = ActivityArgsBlob.Encode(new CastSpellArgs(spellId)).
Adding a new spell:
- Add a
SpellDefinitiontemplate inContent/spells/. - Append its
TemplateIdto the actor'sKnownSpellsslot at activation.
No new SecsActivity declaration is required. The pattern generalizes: RecipeDefinition + CraftRecipe, Building template + BuildStructure, ItemDefinition + UseItem, SkillDefinition + TrainSkill, WorkOrderDefinition + PerformWorkOrder, RitualDefinition + PerformRitual.
The bad pattern this prevents: KnownSpells storing ActivityId values and one Activity_FrostBolt / Activity_FireBolt / etc. per spell. That would be content explosion ("200 spells = 200 activities"), explicitly banned in docs/design/behavior-vocabulary.md § Group D.
17.5 Slot seeding ownership
Verdict: endorse the direct-call pattern. No ISecsHostSlotSeeder interface is needed.
Slot seeding is a generated-system or generated-code responsibility within the tick pipeline, not a host bridge concern. The canonical call site is a SECS ISystem body (typically a world-initialization or actor-activation system) that calls the generated static helper:
// In Generated/Systems/.../SomeActorActivationSystem.cs:
CharacterSlots.SeedDefaults(ctx.SlotStore, character, survivalPolicy);
CharacterSlots.SeedDefaults is itself a hand-written stand-in (carrying a real // Source: Content/characters/slots.secs provenance) that the future SECS compiler would emit from a template<RuleSlot> / template<NeedSlot> / template<KnownSpells> block in .secs source. The helper performs four tick.SlotStore.Initialize<T>(actor, slot, policy.X) calls — one per architectural slot kind.
Why direct-call wins:
- The caller is generated SECS code, not raw host code. The valenar production call site (
Generated/Systems/World/MapGenerationSystem.cs:152) is compiler-emitted system code. Adding a host bridge interface would route a generated-to-engine call through host indirection for no benefit. - The engine never drives seeding. The seeding direction is always
generated/system code → engine slot store. AnISecsHostSlotSeederinterface only makes sense when the engine needs to call out to host code; here it is a passive receiver of typed slot inits. - A lifecycle phase would violate game-agnosticism. "Seed policies X and Y for any Character" is a game-specific decision; the engine is game-agnostic. The decision belongs in
<ScopeName>Slots.SeedDefaults, not in an engine lifecycle hook. - No-fallback invariant fully satisfied. If seeding is skipped,
RequireSlotthrows immediately atPolicyDispatcher.Tickwith an actionable error message naming the missing slot and the seed helper. Tests catch this loudly.
ScopeSlotStore itself lives in SECS.Engine. Even if future packaging work adds narrower query-only interfaces elsewhere, the seeding call sites remain the same.
17.6 Cross-references
- § 11 E4 / E5 — slot read / slot write surfaces in the invocation matrix.
- § 13.1 —
policy rule Decisionlisted as read-only. - § 14.6 —
PolicyDispatcher.Tickhost-callable surface. - § 16 — universal
ActivityRequestmodel and the one core start path. src/SECS.Engine/Policies/PolicyDispatcher.cs,PolicyExecutor.cs,CandidateBuilder.cs,SelectorSource.cs,Slots/ScopeSlotStore.cs.examples/valenar/Generated/Slots/CharacterSlots.cs—SeedDefaultsreference implementation.examples/valenar/Generated/Policies/KnownSpellsCandidateBuilder.cs— content-family parameterization reference.
18. Template / contract / entity creation boundary
Formalizes the lifecycle ordering for entity creation, activation, and lifecycle methods. Cross-references § 11 A8-A11, § 14.3, § 14.4, § 17.5.
18.1 Entity creation paths
Four creation paths exist, all converging on TemplateActivator.CreateCore:
TemplateActivator.Create(templateId, entityId, scopeTarget, ...)— host-controlled id. Used at boot for fixed-id entities (e.g. settlement id=1, config id=2).TemplateActivator.CreateEntity(templateId, scopeTarget, ...)— generated-id path viaIEntityIdGenerator.TickContext.CreateEntity(templateId, ...)— lowering of SECScreate_entity. Delegates to path 2. RequiresIdGenerator != null.ITemplateCommandContext.CreateEntity(templateId, ...)— inside lifecycle / command method bodies. Flushes pending commands first, then delegates to path 2 to avoid mid-activation command-buffer entanglement.
18.2 Activation phase ordering
CreateCore runs these phases atomically from the caller's perspective:
ApplyInitialState— onlySetScopeFieldcommands. Validates onlyownerorrootare targeted. Throws if any channel source command appears. Flush isolated to initial-state commands.SetEntityName— only iftemplate.Name != null.RunActivationBody—template.Activate(scope, host, cmds)runs in a dedicatedcmdsbuffer. Channel sources must targetscope.Rootonly. Cross-scope effects must use named modifiers — validated byValidateActivationChannelSources. Buffer flushes after.InstanceStore.Add— entity becomes tracked.RunLifecycle(Activation)— looks upContractLifecycleIds.Activationbinding. If binding exists and template implements the matching method, invokes via freshTemplateCommandContextand flushes after. If no binding or method missing, silent skip (returnsdefault).
The five phases use separate command buffers; nothing carries between them.
For destruction (Destroy):
RunLifecycle(Deactivation).RemoveAllChannelSources+RemoveAllBindings+RemoveAllBindingsOnTargetwith flush.InstanceStore.Remove.IEntityCreator.DestroyEntity.
18.3 Host-callable vs SECS-callable call shapes
| Operation | Delegate signature | Context |
|---|---|---|
| Template query (read-only) | (ScopeFrame, ISecsHostReads, in TArgs) → TResult | No TickContext; no command buffer |
| Activation delegate | (ScopeFrame, ISecsHostReads, CommandBuffer) | Direct cmds; no TickContext |
Template command method (method void) | (ITemplateCommandContext, in TArgs) | CommandBuffer accessible via context; no TickContext |
Both host-callable and SECS-callable paths land on the same registered delegate with the same SecsTypeRef-driven type check.
- Host-callable query:
SecsRegistry.EvaluateTemplateQuery<TArgs, TResult>(templateId, queryId, ScopeFrame|EntityHandle, in args, host). - SECS-callable query (from formula / event-condition / read-only body):
ISecsHostReads.CallScopeQuery<TArgs, TResult>(target, scopeId, methodId, in args). - Host-callable command:
TemplateActivator.CallMethod<TArgs>(templateId, methodId, owner, scopeTarget, in args). ConstructsTemplateCommandContext, validates args, invokes, flushes. - SECS-callable command (from command-producing context):
ITemplateCommandContext.CallScopeCommand<TArgs>(target, scopeId, methodId, in args)orTickContext.CallScopeCommand<TArgs>(...).
The query/command discriminator is ContractMethodDeclaration.IsQuery + ReturnType.IsVoid. Calling a void method through a query path or a non-void method through a command path throws.
18.4 ScopeFrame construction rules
ScopeFrame has three named constructors and no empty factory:
| Constructor | When |
|---|---|
ScopeFrame.ForInstance(owner, root) | Standard case: activation, lifecycle methods, CallMethod, Transition, deactivation. Owner / This = the entity; Root / Current = scope root. |
ScopeFrame.ForCreation(root) | Pre-instance queries — EvaluateTemplateQuery with EntityHandle scopeTarget overload. Owner = EntityHandle.Null, HasOwner = false. |
ScopeFrame.WithCurrent(current) | Iteration lowering inside generated bodies; changes Current without changing Owner or Root. |
Host code never constructs ScopeFrame directly for activation; TemplateActivator does it internally. Host constructs ScopeFrame.ForCreation(scopeTarget) only for pre-instance eligibility queries (e.g. "can this template be placed under this parent?").
The zero-value ScopeFrame struct (all-null handles) is invalid for all surface calls.
18.5 registry_only contracts — current state vs design intent
A registry_only contract qualifier marks a contract whose templates are pure data (template fields + structured fields), with no entity instantiation. Canonical examples: SpellDefinition, RecipeDefinition.
Design intent (docs/design/02-templates.md:772-831):
ContractDeclaration.IsRegistryOnly = true.TemplateEntry.Activate = null.- Engine skips activation;
create_entityrejects registry-only template ids. - Diagnostic
SECS0213blocks queries / methods / lifecycle bindings on registry-only contracts.
Current implementation:
ContractDeclaration.IsRegistryOnlydoes NOT yet exist as a property.SecsRegistry.ValidateTemplateEntryrequiresActivate != null— registry-only templates wire aNoOpActivatedelegate as a workaround.- Current content follows the intended metadata-only convention: registry-only-style contracts keep
LifecycleBindings,QueryMethodIds, andMethodIdsempty, soRunLifecyclefinds no binding and silently skips. No entity is materialized only because host/content code avoidsCreateEntity; the engine does not enforce that yet. create_entityrejection for registry-only template ids is not enforced today. Closing this gap requires addingIsRegistryOnlytoContractDeclarationand a guard inTickContext.CreateEntity/TemplateActivator.CreateCore. This remains future engine work.
18.6 Missing lifecycle / missing method behavior
| Case | Behavior |
|---|---|
Contract has no binding for a lifecycle id (e.g. Deactivation) | contract.GetLifecycleMethodId(id) returns 0 → RunLifecycle returns default. Silent no-op. |
| Contract has the binding but template does not implement the method | template.Methods.TryGetValue returns false → RunLifecycle returns default. Silent no-op. |
CallMethod on a method the template does not implement | TemplateActivator.CallMethod throws InvalidOperationException naming the missing method id. |
EvaluateTemplateQuery on a query the template does not implement | SecsRegistry.EvaluateTemplateQuery throws InvalidOperationException naming the missing query id. |
Channel source command emitted from a method void body | TemplateCommandValidation.RejectMethodChannelSources throws at flush time. Channel sources are activation-only. |
ApplyInitialState emitting any command other than SetScopeField targeting owner / root | Throws at flush time. |
Lifecycle methods are optional by design — templates only implement the bindings they need. Direct method calls fail loudly because the caller named a specific method id; the contract is "name a method that exists or fail."
18.7 No-fallback invariant for templates and entities
SecsRegistry.GetTemplate(id)throwsInvalidOperationExceptionon unregistered template id. No engine-synthesized fallback.SecsRegistry.GetDefaultTemplate(scopeId)returnsTemplateId.Nonewhen no default registered;TickContext.Spawn(scopeId)returnsEntityHandle.Nullin that case. No silent fabrication of a "bare" template.- "Bare" templates in valenar (
BareCharacter,BareLocation, etc.) are explicit registered templates that the host registers as defaults. They are content, not engine fallbacks. ScopeSlotStore.Read<T>throws on uninitialized slot — see § 17.2.
If no template is registered for a scope id and Spawn(scopeId) is called, the result is EntityHandle.Null — host code must check and fail rather than relying on the engine to fabricate something.
18.8 Cross-references
- § 11 A8 (template activation), A9 (template deactivation), A10 (template query), A11 (template command method).
- § 14.3 (entity-lifecycle host-callable surfaces).
- § 14.4 (template-query / template-command host-callable surfaces).
- § 17.5 (slot seeding ownership; the post-activation step that runs in a generated system body).
src/SECS.Engine/TemplateActivator.cs,TemplateCommandContext.cs,TemplateCommandValidation.cs,Instances/TemplateEntry.cs,Instances/InstanceStore.cs.src/SECS.Abstractions/Contracts/ContractDeclaration.cs,ContractMethodDeclaration.cs,ContractLifecycleIds.cs,LifecycleBindingDeclaration.cs.src/SECS.Abstractions/Scopes/ScopeFrame.cs.docs/design/02-templates.md § Templates under registry_only contracts.
19. Systems / events / on_actions ordering
Specifies dispatch ordering, subscriber command visibility, and direct-call legality for the three command-producing dispatchable surfaces. Cross-references § 11.1 (A1-A11, F6-F8), § 14.7-14.8, § 13.
19.1 System execution ordering
TickPipeline.Execute(TickContext) is the single canonical tick entry. Source: src/SECS.Engine/Pipeline/TickPipeline.cs:156.
On the first call, Materialize() stable-sorts all SystemStep entries:
- Primary key:
phaseRankascending (lower = earlier). - Secondary key: original registration index (preserves registration order within the same phase).
Phase rank is built by UseRegisteredPhases(registry) from registry.OrderedPhases(), which itself is populated from Generated/Declarations.cs phase declarations. Systems without a defined phase (PhaseId.None) sort after all phased systems.
Non-SystemStep entries — ContractTickStep, EventPulseStep, custom ITickStep — are NOT moved by Materialize. They remain at their literal insertion position. This means inserting a custom step between two systems in the same phase gives that step a deterministic place in the materialized order.
SystemStep.Execute flow (SystemStep.cs:26-63):
- Frequency gate:
ctx.TickNumber % frequency == 0(or run-once gate forSystemFrequency.Once = -1). system.Execute(ctx).ctx.FlushCommands()on success path.ctx.DiscardCommands()on exception path, then rethrow.
Frequency is resolved at registration time from registry.AllTickRateDeclarations() and is fixed for the system's lifetime.
System direct-call legality. Systems are not host-callable outside the pipeline by any documented API. The interface ITickSystem.Execute(TickContext) is technically public, but no host or test in the codebase calls it directly. Treat direct calls as architecturally undefined: not guarded by the engine, not documented as a host surface.
SECS-callable systems. No surface starts a system from inside another body. There is no ctx.StartSystem(...) API. Cross-system invocation must go through fire-event or fire-on_action.
19.2 Event pulse evaluation ordering
Pulse events are evaluated in registration order — EventDispatcher.pulseEvents is a plain List<EventEntry> with no sort applied. Source: src/SECS.Engine/Events/EventDispatcher.cs:19, 134.
Per entity per pulse, EventDispatcher.TickPulse runs:
- Frequency gate:
ctx.TickNumber % evt.Frequency == 0. - Chance roll:
ctx.Random.Next(100) >= evt.Chanceshort-circuits the event for this entity. Conditionbody — read-only by enforcement (commands discarded infinally). SeeEvaluateConditionlines 344-354.- If
Conditionreturns true:BuildOptions(read-only) —InvokeBuildOptionslines 356-366. - If
BuildOptionsproduced any options: park aPendingChoice(see § 19.5). - If no options:
Executebody — command-producing —InvokeEventBodylines 368-380.
Execute and option handlers call ctx.FlushCommands() after the body and ctx.DiscardCommands() on throw.
There is no committed ctx.FireEvent(eventId) API for direct programmatic fire of a specific pulse event. Pulse events fire only through the pipeline's EventPulseStep. Cited as F6 NOT-COMMITTED in § 11.6.
19.3 on_action subscriber ordering and command visibility
EventDispatcher.FireOnAction(onActionId, target, ctx) at src/SECS.Engine/Events/EventDispatcher.cs:166 is the single firing surface. Selectable callers: host code outside the pipeline (e.g. GameRuntime.OnBuildingComplete), system bodies, event Execute and option handlers, activity lifecycle hooks. All must hold a live TickContext.
Required pre-fire. For every scope id named in OnActionDeclaration.ProvidedScopes, the caller must call ctx.SaveScope(nameHash, entity) before FireOnAction. Missing scope throws InvalidOperationException at fire time. FireOnAction calls ctx.FlushCommands() at entry, then pushes a child saved-scope frame from the pre-fire pending scopes.
Subscriber order. Subscribers (events that subscribe to this on_action) are sorted by EventEntry.Priority ascending. Lower priority value fires first. Default priority = 0; negative priorities fire before zero, positive after. The runtime sorts the list at every RegisterEvent call (line 50). Today, the only ordering guarantee is that lower priorities run before higher priorities; callers must not rely on registration-order preservation among equal-priority subscribers because the runtime uses List<T>.Sort, which does not guarantee stability.
Synchronous, in-tick dispatch. FireOnAction runs all selected subscribers inline within the calling thread of execution. There is no deferred queue for on_action subscribers.
Selection mode. OnActionDeclaration.SelectionMode controls how many of the priority-sorted subscribers actually run:
All— every subscriber whoseConditionpasses runs.FirstValid— the first subscriber (by priority) whoseConditionpasses runs; the rest are skipped.WeightedRandom— exactly one subscriber is chosen, weighted byEventEntry.Weight.
Selection mode does not change ordering for the subscribers that do fire.
Command visibility between subscribers. Subscribers do NOT share a live command buffer. Each subscriber's Execute body calls ctx.FlushCommands() before the next subscriber starts. Subscriber N+1 sees the committed world state after subscriber N's commands have been applied — it does not see N's buffered-but-unflushed commands.
This means subscribers are a sequence, not a transaction. If subscriber N adds a modifier and subscriber N+1 reads the modified channel, N+1 will see the new value. Author intent must take this into account when assigning priorities.
Saved-scope visibility within a dispatch frame. All subscribers of a single FireOnAction dispatch share the same pushed child frame. A save_scope_as performed by subscriber N is visible to subscriber N+1 in the priority order. Scope state accumulates across the dispatch and is popped when FireOnAction returns.
on_action has no effect bodies. OnActionDeclaration is metadata-only: OnActionId, Name, ScopeId, ProvidedScopes, SelectionMode, plus reserved runtime-only fields (ChainedOnActionIds, FallbackOnActionId). All effects belong to subscribed events. Reserved fallback / chaining fields are stored as deferred metadata only: live dispatch ignores them, and the registry does not validate them as active follow-on links.
19.4 Host-outside-tick fire legality
FireOnAction is callable from any code holding a live TickContext. The host pattern:
ctx.SaveScope(H.Location, location);
ctx.SaveScope(H.Building, entity);
ctx.Events.FireOnAction(H.OnAction_BuildingComplete, settlement, ctx);
There is no engine guard restricting FireOnAction to inside-tick. The host stores TickCtx on the runtime and may fire on_actions between ticks (e.g., GameRuntime.OnBuildingComplete fires after a synchronous host operation). FireOnAction's entry-time FlushCommands ensures any pre-existing buffered commands are applied before the dispatch begins.
If the host wants to defer firing to inside the next tick, it must implement that deferral itself; the engine does not.
19.5 Pending choice save/load identity
PendingChoice carries:
EventId(ulong, FNV-1a-64 of the event canonical id string) — stable across saves if the event's canonical identity is unchanged.EventName(string) — display only.Target(EntityHandle) — the entity the event was targeting at fire time.Options(IReadOnlyList<EventOption>) — built at fire time, held in memory.SavedScopes(Dictionary<ulong, EntityHandle>?) — snapshot of the dispatch frame at queue time.
Pending choices are NOT currently persisted. The pending-choice queue is in-memory only. After a save/load, all pending choices are lost. GameSnapshotBuilder projects pending choices to a PendingChoiceSnapshot DTO for transport to the UI, but there is no deserialization path back into live PendingChoice objects.
Option identity within a choice uses integer index (optionIndex parameter to ResolveChoice) rather than a stable string key. If event content changes between saves (option added or removed at a different index), index-based deserialization would silently misalign options. Section 20.3 defines the replacement protocol.
A future save-protocol design must decide whether to persist pending choices, and if so what option identity to use (stable key string, hash of option label, or accept the "lose pending choices on save/load" semantic as final).
19.6 Cross-references
- § 11.1 A1 (system Execute), A2 (Condition), A3 (pulse Execute), A4 (BuildOptions), A5 (option handler), A6 (on_action declaration), A7 (on_action fire), F6 (fire-event-by-id NOT-COMMITTED).
- § 13.1 / § 13.2 — read-only vs command-producing taxonomy. Condition, BuildOptions are read-only by enforcement; Execute, option handler, on_action firing are command-producing.
- § 14.7 — on_action dispatch host-callable surface table.
- § 14.8 — player choice resolution.
- § 20.2 — save payload protocol (covers pending-choice persistence).
src/SECS.Engine/Pipeline/TickPipeline.cs:207-241—Materialize.src/SECS.Engine/Pipeline/SystemStep.cs:26-63— system frequency + flush.src/SECS.Engine/Events/EventDispatcher.cs:50— subscriber priority sort.src/SECS.Engine/Events/EventDispatcher.cs:166—FireOnAction.src/SECS.Engine/Events/EventDispatcher.cs:344-380— Condition / BuildOptions / Execute discriminators.src/SECS.Engine/Events/PendingChoice.cs— choice carrier.
20. Save-load / determinism / reentrancy
Documents the per-surface save-load contract, the deterministic-replay contract, and the reentrancy boundary.
20.1 Per-surface save-load matrix
| Surface | Persisted today? | Stable identity | Failure mode on restore mismatch | Deterministic on replay? |
|---|---|---|---|---|
ActivityRunStore | PARTIAL — ToState/Restore exists | ActivityRunId (monotonic ulong) + seed via NextRunIdSeed | SECS0810 / 0811 / 0812 throws | Yes given seed |
ScopeSlotStore | YES — Snapshot/Restore | (EntityHandle, SlotKindId) | SECS0820 / 0821 throws on bad input | Yes |
PrevTickSnapshotStore | NO — re-derived after first tick | (EntityHandle, ChannelId) | missing snapshot should throw until warm-up has produced one | Yes after one tick warm-up |
PendingChoice queue | NO — implementation gap | (today: array index; future candidate: EventOption.Key string) | silent wrong choice today | No until a future save protocol lands |
ModifierBindingStore | NO — biggest gap | (Owner, Target, ModifierId) | N/A — no surface today | No |
PolicyDispatcher.activeChildren | NO — gap | (EntityHandle, PolicyId) → ActivityRunId | N/A | No |
TickContext.Random | NO — host owns seed | seed int | depends on host re-seed contract | Replay-equivalent only with seed re-feed; not bit-for-bit across save/load |
TickContext.TickNumber | NO — host owns | int | run progression freezes if mis-set lower than LastUpdatedTick | Yes given correct value |
InstanceStore | NO — restore path blocked | EntityHandle | Current TemplateActivator.Create / Activate fires activation and lifecycle behavior; a no-lifecycle rehydrate path is not designed yet | No until restore-only rehydrate lands |
20.2 Future SecsSavePayload protocol candidate
Status: backlog design, not a committed runtime boundary.
Today there is no unified SecsSavePayload record and no single SECS-owned SaveSnapshot/RestoreSnapshot entry point. The following shape is a future candidate direction for closing the current partial-save gap while keeping the host in charge of byte serialization.
If this protocol is implemented, the SecsSavePayload record would carry:
record SecsSavePayload(
int TickNumber,
ulong NextRunIdSeed,
IReadOnlyList<ActivityRunState> ActivityRuns,
IReadOnlyList<SlotSnapshot> SlotSnapshots,
IReadOnlyList<ModifierBindingEntry> ModifierBindings,
IReadOnlyList<PendingChoiceSave> PendingChoices,
IReadOnlyList<PolicyChildEntry> PolicyChildren)
Proposed restore order:
- Call
ActivityExecutor.SeedNextRunId(NextRunIdSeed). - Restore each
ActivityRunStateviaActivityRunStore.Restore. - Restore
ScopeSlotStoreon a fresh instance viaRestore(SlotSnapshots). - Restore
ModifierBindingStoreviaRestore(ModifierBindings)— new surface. - Restore
EventDispatcher.PendingChoicesfromPendingChoiceSavelist — new surface. - Restore
PolicyDispatcher.activeChildrenfromPolicyChildEntrylist — new surface. - Set
TickContext.TickNumber.
If adopted, SECS would provide the record shape and the host would serialize it (JSON, binary, MemoryPack, etc. — host's choice). No wire format is committed here.
PrevTickSnapshotStore is intentionally not in the payload — it warms up after one post-restore tick and is not save-critical.
InstanceStore restore remains a blocker. The current reactivation route through TemplateActivator.Create / Activate fires activation and lifecycle behavior, so it is not a valid durable restore path for already-created entities. A future no-lifecycle rehydrate path must repopulate InstanceStore and any required template runtime state without normal activation side effects.
20.3 Future PendingChoice option-identity protocol
The current EventDispatcher.ResolveChoice(choiceIndex, optionIndex, tick) uses array indices as identity, which is unstable across content patches. A future protocol upgrade would:
- Add
string KeytoEventOption. Keys are designer-authored, unique within a single event's option list, and content-stable across patches. - Add
EventDispatcher.ResolveChoiceByKey(int choiceIndex, string optionKey, TickContext)companion to the existing index-based method. PendingChoiceSavecarriesEventId,Target, and (for already-selected choices)SelectedOptionKey. On restore the host re-presents choices with nullSelectedOptionKey; pre-selected choices auto-resolve via key match.
Migration semantics:
- Adding a new option in a patch: existing saves restore with no impact (the new key was unknown at save time).
- Removing an option in a patch: saved choices that selected the removed key auto-cancel on restore. Host responsibility to surface the cancellation to the player or rebuild a new choice.
- Renaming an option's display label: no save impact (label is presentation-layer, key is identity-layer).
Hash-of-label and array-index were both rejected as identity — labels are localizable presentation strings, indices are content-order-fragile. Designer-authored stable keys are the only option-identity that survives patches.
20.4 Random / determinism contract
TickContext.Random is a standard System.Random initialized by the host. The engine has no seed-save or seed-restore API.
Determinism contract:
- The host generates and saves a per-game seed at new-game creation.
- At restore, the host constructs
TickContextwithRandom = new Random(savedSeed). - Post-load RNG is not bit-for-bit equivalent to a "continued without saving" run —
System.Randomstate cannot be portably serialized in netstandard2.1 without reflection hacks. - This is acceptable for grand-strategy save semantics: the seed re-feed gives reproducible RNG sequences from the load point forward; pre-load RNG calls cannot be replayed.
Strict replay determinism (same seed → same outputs from tick 1) is not currently a protocol guarantee. A host that needs strict replay must serialize and replay the full input log from game start, not the world snapshot.
20.5 Reentrancy boundary
FireOnAction is synchronous and runs all subscribers inline (see § 19.3). A subscriber may, in turn, call FireOnAction to trigger another on_action — this is reentrant by design and the engine supports it via the saved-scope frame stack (PushSavedScopeFrameFromCurrent / PopSavedScopeFrame).
ActivityExecutor.Start may be called from inside a running activity's lifecycle hook (parent → child run). The child run is tracked via ActivityRunState.ParentRunId / ChildRunId. PolicyDispatcher.activeChildren records the policy → child run linkage for cancel/replace semantics; persisting that linkage remains future protocol work.
ScopeSlotStore.Read<T> and Initialize<T> are not reentrant on the same (actor, slot) key within a single transaction — the standard pattern is "seed at activation, never re-init during the same tick." Reentry on different (actor, slot) keys is safe.
ChannelResolver.SyncDirty must not be called from inside a system body that is itself producing dirty channels — the pipeline calls SyncDirty once per tick at the post-system-step boundary. Re-calling it from a system body would double-flush bindings and is an architectural error caught at runtime by BindingUpdater's "already-syncing" guard.
20.6 Deferred source items
The future-protocol candidate above would require the following source-code additions:
src/SECS.Engine/Events/EventOption.cs— addstring Keyproperty.src/SECS.Engine/SaveLoad/SecsSavePayload.cs— new file, the structured record.src/SECS.Engine/SaveLoad/PendingChoiceSave.cs— new serializable pending-choice shape.src/SECS.Engine/SaveLoad/PolicyChildEntry.cs— new(EntityHandle, PolicyId, ActivityRunId)triple.src/SECS.Engine/SaveLoad/ModifierBindingEntry.cs— new serializable modifier-binding shape.src/SECS.Engine/Events/EventDispatcher.cs— addResolveChoiceByKey.src/SECS.Engine/ModifierBindingStore.cs— addSnapshot()/Restore(...).src/SECS.Engine/Policies/PolicyDispatcher.cs— exposeActiveChildrenenumerable andRestoreChildren(...).
These additions remain future engine work. This section records a proposed direction for later implementation work; it is not describing shipped runtime surfaces.
20.7 Cross-references
- § 11.1 A5 (option handler), F8 (player choice resolution), F3 / F4 (modifier attach / remove).
- § 14.7 (on_action dispatch host API), § 14.8 (player choice resolution).
- § 16.4 (
ActivityRunState11-field record). - § 19.5 (PendingChoice in-memory queue).
src/SECS.Engine/Activities/ActivityRunStore.cs— Restore + SECS0810/0811/0812.src/SECS.Engine/Slots/ScopeSlotStore.cs— Snapshot/Restore + SECS0820/0821.src/SECS.Engine/Resolution/PrevTickSnapshotStore.cs— re-derived after first tick.src/SECS.Engine/ModifierBindingStore.cs— current API (no Snapshot/Restore yet).src/SECS.Engine/Policies/PolicyDispatcher.cs—activeChildrenmap.src/SECS.Engine/Events/PendingChoice.cs,EventOption.cs.
21. Mod / source-set / diagnostic catalog
Consolidates the mod-runtime architecture, the host-capable vs data-only source-set boundary, and the diagnostic catalog cross-reference.
21.1 Compile-time merge plus startup-finalization runtime target
Mod composition has two layers:
- Compile-time semantic merge — the future SECS compiler (Roslyn fork, currently deferred) performs source-set-aware merge in Phase 3, producing a single immutable Generated artifact. This is the long-term target.
- Startup-finalization runtime target —
SecsRegistry.RegisterActivityModandRegisterPolicyModaccumulate mod patches at boot;FinalizeModRegistration()merges them once into a frozen view. Source:src/SECS.Engine/Modding/ModRegistry.cs:114. Documented as "the committed permanent architecture, not a transitional stand-in" in `docs/design/06-overrides-and-modding.md § 1.2.
After Finalize() returns, the merged registry is read-only for the rest of the process. Subsequent calls to RegisterActivityMod or RegisterPolicyMod throw InvalidOperationException ("ModRegistry is finalized"). The ticking game sees the frozen merged view; there is no runtime hot-patching.
21.2 Host-capable vs data-only source-set boundary
Two classes of source set with different rights:
| Capability | Host-capable (base game, official expansion) | Data-only mod (third-party) |
|---|---|---|
| Add scopes / scope fields | Yes | No |
Add walks_to edges | Yes | No |
| Add scoped collections | Yes | No |
| Add scope methods (query or void) | Yes | No |
| Add templates, modifiers, formulas, triggers | Yes | Yes |
| Add systems, events, on_actions, activities, policies | Yes | Yes |
| Add inject / replace mods | Yes | Yes |
The boundary exists because adding host-backed surfaces requires host-side bridge code (see § 5, § 15). A data-only mod cannot ship that code, so SECS prohibits the declaration at compile time.
Enforcement today is compiler / doc level only. The runtime has no SourceSet enum or HostCapable / DataOnly flag (see § 6, § 15.7). Diagnostic codes:
SECS0604— operation-kind / declaration-kind mismatch in mod registry. Implemented inModDiagnosticCode.cs.SECS0609— third-party data-only mod declares host-backed surface. Compiler-phase-only; no runtime enum entry. TheModRegistrydoes not see scope / field / walk declarations (those are registered directly inSecsRegistryfrom generated C#), so a runtime check is architecturally not applicable. Reserved for the Phase 3 source-set-aware merger.
21.3 No runtime hot-patching
Once FinalizeModRegistration() returns, the merged view is immutable for the lifetime of the process:
- Mod registration calls throw post-finalize.
- Activity / policy declarations may not be added or removed.
- The host may not "reload mods" without recreating
SecsRegistryand re-running the entire boot sequence.
This is by design. The runtime is single-pass at startup, then frozen. Any future save/load protocol will use the frozen registry as its schema.
21.4 Phase / cadence — content-authored pass-through C#
PhaseDeclaration and TickRate instances are declared as plain C# static readonly fields inside .secs files (e.g. static readonly PhaseId DayStart = ...;). The compiler does not parse phase / cadence / tick_rate as SECS keywords — those keywords were explicitly rejected (06-overrides-and-modding.md § 19.2-19.3).
Ownership classification: a fourth category alongside host-owned (§ 3.1), SECS-owned (§ 3.2), and shared/bridged (§ 3.3) — content-authored pass-through C#. They are:
- NOT host-owned (no host storage).
- NOT SECS-owned (parser does not treat them as keywords).
- NOT data-only-mod territory (they affect system scheduling, which requires host-capable rights).
Source-set classification: host-capable source sets only (base game, official expansion). Data-only mods may not add new phase or tick rate declarations (06 § 1805).
Compiler treatment: the Phase 4 compiler type-checks phase = X; and frequency = Y; slot expressions in system declarations as PhaseDeclaration and TickRate expressions respectively, then emits the resolved ids into SystemRegistration. The compiler consumes static helper class references (Phases.Growth, Cadence.Daily) as plain C# identifiers, not as SECS keywords.
21.5 Diagnostic band allocation in the 08xx range
The committed 08xx allocations are:
SECS0800— runtimeSlotConflictwarning (ModDiagnosticCode.SlotConflict = 800).SECS0801— runtimeInjectClosedSlot(ModDiagnosticCode.InjectClosedSlot = 801).SECS0802— compiler reservation forpropagates_to wherenon-structural-predicate restriction.SECS0803— compiler/analyzer reservation for "command call in read-only body".SECS0804— compiler/analyzer reservation for "host command without command context".SECS0810-SECS0812— runtime activity-run restore validation (RegistryDiagnosticCode.ActivityRestore*andActivityRunStore.Restorethrows).SECS0820-SECS0821— runtime slot-store restore validation (RegistryDiagnosticCode.SlotRestore*andScopeSlotStore.Restorethrows).SECS0822— compiler binder reservation forScopedList<T>indexed access.SECS0830— compiler/analyzer reservation for prev-tick reads withouttrack_prev = true.SECS0840— runtime collection hook-name collision (RegistryDiagnosticCode.CollectionHookNameCollisionandSecsRegistry.RegisterCollectionsthrows).
Before adding a new 08xx diagnostic, audit both the runtime enums and SECS-Compiler-Plan.md so the band stays collision-free.
21.6 Diagnostic catalog cross-reference
The single source of truth for the SECS diagnostic catalog is SECS-Compiler-Plan.md § Diagnostic Code Catalog plus the runtime-shipped enums:
src/SECS.Engine/Modding/ModDiagnosticCode.cs— mod-registry diagnostics (06xx,08xxmod codes).src/SECS.Engine/Diagnostics/RegistryDiagnosticCode.cs— registry diagnostics (03xx,04xx,08xx,09xx).
For the compact status split between runtime-shipped, compiler-planned, doc-only analyzer reservation, and current test coverage, see SECS-Compiler-Plan.md § Mod-operation / slot / source-boundary status crosswalk.
Band allocations:
01xx— channel declaration validity.02xx— template / contract declaration validity.03xx— formula / dependency analysis.04xx— activity / policy declaration validity.05xx— name resolution / binder.06xx— mod merger and source-set rules.07xx— structured-type rules.08xx— modifier / propagation / runtime-restore rules.09xx— runtime registry validation.
Before adding a new diagnostic, audit existing band occupants in both runtime enums and the compiler plan to avoid collisions.
21.7 Cross-references
- § 6 — host-capable vs data-only mod boundary (high-level summary).
- § 15.7 —
SECS0609compiler-phase-only status. docs/design/06-overrides-and-modding.md § 1.2 / § 19.2-19.3— startup-finalization architecture, phase/cadence keyword rejection.docs/design/01-world-shape.md:253-272— host-capable vs data-only declaration rules.SECS-Compiler-Plan.md § Diagnostic Code Catalog— single source of truth.src/SECS.Engine/Modding/ModRegistry.cs:114—Finalize()implementation.
22. Engine-owned public API and context placement
22.1 Runtime context placement
TickContext, EventContext, ActivityRunContext, and PolicyRunContext stay in SECS.Engine, not SECS.Abstractions. That placement is intentional: the contexts carry engine-internal types, some have internal constructors, and moving them into SECS.Abstractions would invert the current assembly boundary rather than clarify it.
BRIDGED in § 3.3 therefore means "types the host receives through generated callbacks", not "types that must physically live in SECS.Abstractions". Future hardening may add narrower read-only query interfaces, but it does not require moving the concrete runtime contexts.
22.2 SECS public engine API
SecsRegistry and the executor/dispatcher family are a fourth ownership bucket: SECS public engine API. These are concrete SECS.Engine types that the host constructs, calls, or inspects directly with no bridge interface in between.
Members of this bucket include SecsRegistry, ActivityExecutor, PolicyDispatcher, PolicyExecutor, EventDispatcher, TemplateActivator, ScopeSlotStore, and ActivityRunStore. They are not host-owned, and they are not bridge interfaces; they are engine API that host code consumes directly.
No ISecsRegistry abstraction is planned. The concrete registry exposes engine-internal declaration and finalize semantics that would become less usable, not more, behind an artificially thin interface.
22.3 Doc-hygiene backlog
Remaining Valenar-label normalization belongs in docs/design/FUTURE_WORK.md § 4a. It does not change the boundary contract above.