Skip to main content

09 — AI, Policies, and Activities

This doc covers the current SECS AI subsystem built around policies, selectors, needs, rule decisions, and activity candidates:

  • policy — a first-class declaration of compile-time AI decision logic. A policy ties an actor scope to a behavior domain (Survival, Economy, Combat, Travel, Forage…) and carries need, selector, and rule Decision sub-declarations.
  • need — a declarative goal carried by a policy. Names a channel, a target value, an urgency curve, a threshold, and a weight. Read by the utility-AI selector when scoring candidates.
  • selector — a candidate-source declaration that builds ActivityCandidate lists for call_best to score and pick from.
  • rule Decision — a method-bodied dispatch that returns one of seven RuleDecision cases (continue, complete, fail, wait, call(...), call_best(...), cancel_child).
  • PolicyExecutor — the engine runtime that builds candidates, scores them against the policy's needs, and dispatches.

Executable behavior is activity + policy. Valenar docs should use activity as the canonical behavior / planning noun, with more specific terms such as Mission or Assignment where they clarify the player-facing surface. Read 04-behavior.md § Policies for the lowering contract; this doc covers the AI scoring math and the philosophy of needs-vs-effects.

The design is motivated by three concrete pressure tests:

  1. The AI must score crafted/recipe-based items correctly without per-template AI weights — there are too many channels and too many runtime modifier combinations for hand-authored scoring formulas to scale.
  2. The player must be able to configure AI behavior in-game (toggle rules, tune thresholds, add custom rules) without recompiling the game.
  3. The target AI design must persist behavior across save/load, including mid-execution policy state, with a migration story when definitions change between versions. The current runtime has partial store-level primitives only; policy child linkage remains a save/load gap.

Prerequisites

  • 00-overview.md — the SECS skeleton and doc map.
  • 01-world-shape.mdscope, contract, top-level channel declarations.
  • 02-templates.md — template declarations, fields, channels.
  • 03-channels-and-modifiers.md — channel resolution pipeline, modifier stacks.
  • 04-behavior.mdsystem, event, on_action, activity, and the ## Policies section that defines the lowering contract.
  • 08-collections-and-propagation.mdScopedList<T> / ScopedDictionary<TKey, T>, used for the player-authored extension surfaces below.

Rules

rule Decision Name() { ... } — method-bodied dispatch

What: A method that the host evaluates each time the policy runs. It inspects the actor's current state and returns a RuleDecision. Multiple rules per policy; walked in declaration order (or by explicit Order slot when the lowering keeps a numeric ordering).

SECS:

rule Decision EmergencyRest()
{
if (actor.resolve(HP_Current) * 4 < actor.resolve(HP_Max))
return call_best(RestSelector);
return continue;
}

rule Decision EatWhenHungry()
{
if (actor.resolve(Hunger) > 50)
return call_best(FoodSelector);
return continue;
}

rule Decision DoneWhenHealthy()
{
if (actor.resolve(HP_Current) >= actor.resolve(HP_Max))
return complete;
return continue;
}

Compiles to:

public override RuleDecision EvaluateRule(RuleId ruleId, PolicyRunContext context)
{
if (ruleId.Value == H.Rule_EmergencyRest) return EvaluateEmergencyRest(context);
if (ruleId.Value == H.Rule_EatWhenHungry) return EvaluateEatWhenHungry(context);
if (ruleId.Value == H.Rule_DoneWhenHealthy) return EvaluateDoneWhenHealthy(context);
return RuleDecision.Continue.Instance;
}

private static RuleDecision EvaluateEmergencyRest(PolicyRunContext context)
{
var hp = context.Tick.ChannelResolver.ResolveIntFast(context.Actor, H.HP_Current);
var hpMax = context.Tick.ChannelResolver.ResolveIntFast(context.Actor, H.HP_Max);
if (hpMax > 0 && hp * 4 < hpMax)
return new RuleDecision.CallBest(new SelectorId(H.Selector_RestSelector));
return RuleDecision.Continue.Instance;
}

Why this shape: The user pressure was that the previous rule X when Y call Z; micro-syntax was hard to read because it didn't share shape with established query / method declarations. Rules-as-method-bodies match exactly: function name, parameter list, C# body. Imperative inside the body, declarative as a registered rule. The keyword rule Decision (vs reusing method) is justified because rules have a specific runtime contract — the executor walks them in order, reads the returned RuleDecision, and stops on first dispatch.

RuleDecision — the discriminated return type

public abstract record RuleDecision
{
public sealed record Continue : RuleDecision { ... }
public sealed record Complete : RuleDecision { ... }
public sealed record Fail : RuleDecision { ... }
public sealed record Wait : RuleDecision { ... }
public sealed record Call(ActivityId ActivityId, EntityHandle Target, ActivityArgsBlob Args) : RuleDecision;
public sealed record CallBest(SelectorId SelectorId) : RuleDecision;
public sealed record CancelChild : RuleDecision { ... }
}
CaseMeaning
ContinueRule had nothing to say; move to the next rule.
CompleteMark the policy run successful; no further rules this tick.
FailMark the policy run failed; no further rules this tick.
WaitPark this tick; keep any active child intact.
Call(activity, target, args)Dispatch a specific activity directly.
CallBest(selector)Dispatch the highest-scoring candidate from the named selector.
CancelChildCancel the actor's current child run before re-evaluating.

The no-payload cases are singleton Instance constants so trivial branches ("nothing to say this tick") do not allocate. Call and CallBest carry payload as records.

Needs

need Name { channel = ...; target = ...; curve = ...; threshold = N; weight = N; }

What: A declarative goal carried by a policy. Names a channel the actor cares about, a target value, an urgency curve, a threshold (where urgency = 1.0), and a weight (the contribution to the final score). Read by PolicyExecutor.ScoreCandidates when evaluating call_best.

SECS:

policy CharacterSurvival
{
actor Character;
domain Survival;

need StayAlive
{
channel = HP_Current;
target = HP_Max;
curve = inverse_quad;
threshold = 50;
weight = 100;
}

need KeepFed
{
channel = Stamina_Current;
target = Stamina_Max;
curve = inverse_quad;
threshold = 30;
weight = 60;
}
}

Compiles to:

public override IReadOnlyList<NeedDeclaration> Needs { get; } = new[]
{
new NeedDeclaration(
new NeedId(H.Need_StayAlive), "Stay Alive",
H.HP_Current, H.HP_Max,
NeedCurveKind.InverseQuad,
Threshold: 50, Weight: 100),
new NeedDeclaration(
new NeedId(H.Need_KeepFed), "Keep Fed",
H.Stamina_Current, H.Stamina_Max,
NeedCurveKind.InverseQuad,
Threshold: 30, Weight: 60),
};

Why this shape: Needs describe what the actor wants. They are declarative data (a channel reference, a target, a curve, a threshold, a weight). The keyword form gives the compiler a structural slot to emit; the underlying NeedDeclaration is a flat positional record, mirroring OnActionDeclaration and RuleDeclaration.

Needs live on the policy, never directly on the entity template. Settlements and characters are data; policies are behavior; mixing them confuses both. A single character may have multiple policies attached (Survival, Combat, Travel, Forage), each with its own needs relevant to its domain.

Curve kinds

SECS provides four built-in curves. Each maps (current, target, threshold) to [0, 1] urgency:

CurveShapeUse case
linearStraight line from 0 (at target) to 1 (at threshold).Even urgency rise (gold reserves, generic resources).
quadlinear². Sharper at the threshold.Scarcity-sensitive needs (food in famine).
inverse_quad1 - (1 - linear)². Smoother far from threshold, sharper at it."Low urgency for high HP, very high urgency near death" — the canonical survival curve.
sigmoidLogistic, centered at the midpoint between threshold and target.Step-like response (combat re-engagement gates).

All four boundary values are clamped: current ≥ target → 0, current ≤ threshold → 1, in between the curve interpolates. Math lives in src/SECS.Engine/Policies/NeedCurves.cs.

Selectors and the utility-AI scorer

selector Name { consider activity X from <source>; }

What: A candidate-source declaration. Selectors enumerate the activity pool for call_best to score and pick from. This contract uses the following source kinds:

  • from all_registered — match the selector's bound ActivityId against the registry. Used when the selector targets one specific activity (rest, food, etc.).
  • from tags = X, Y, Z — match every registered activity whose Tags contains at least one of the listed tag ids. Used for tagged candidate pools (food-providing items, healing items, defense-building activities).

How the scorer picks

For each candidate the executor calls activity.Preview(context) to read the candidate's EffectPlan, the unified preview surface. Then for each need on the policy:

current = channel_resolve(actor, need.channel)
target = ResolveNeedTarget(need)
urgency = need.curve(current, target, need.threshold)

if candidate has no preview or preview is empty:
relevance = 0 // unrelated: no info
else:
scan preview for channel-targeting effects matching need.ChannelId
if no match:
relevance = 0 // unrelated: touches different channels
else:
project current through matching effects via ApplyOpToProjection
projectedUrgency = need.curve(projected, target, need.threshold)
gain = max(0, urgency - projectedUrgency)
if gain > 0:
relevance = gain // improving
else:
relevance = 0.25 * urgency // matched-but-no-gain

contribution = need.weight * relevance
totalScore += contribution

The candidate with the highest score wins. The relevance heuristic has three cases:

  • Unrelated candidates score 0. An activity that does not touch a need's channel contributes nothing to that need's score. That includes both "no preview" and "preview but no matching effect" cases. Authors who want an idle-fallback option to rank above unrelated candidates must express that intent through an explicit preview row that touches the relevant channel with Add 0 (matched-but-no-gain ranks above unrelated).
  • Matched-but-no-gain candidates score 0.25 × urgency × weight. These are activities whose preview touches the need's channel but does not move the urgency curve (e.g. a +5 HP heal where the actor is already above the threshold, or a Modify row whose ModifierId is zero). Useful for tie-breaking against unrelated, but ranks below any gain.
  • Improving candidates score gain × weight. Both gain and 0.25 × urgency live in the same numeric space ([0, 1]), so they compose cleanly under the weight without further normalisation.

The compiler/registry static-analysis pass emits SECS0415 when a policy's selector can route to an activity that does not override Preview() — under this scoring contract that activity ranks unrelated against every need and is almost certainly an authoring oversight.

Hosts can attach a PreviewDriftRecorder to the ActivityExecutor.PreviewRecorder property to capture per-run preview-vs-actual channel deltas. The recorder is opt-in (null = telemetry off, no overhead) and emits one PreviewDriftRecord per successful run.

Why the inversion matters

Templates declare what they DO (effects extracted from Preview). Policies declare what they WANT (needs). The executor multiplies them. There are no per-template AI weights; the AI weight of any option is computed from (effects × needs) at scoring time. This:

  • Scales — adding a new template, activity, or recipe-spawned item requires zero AI-side changes; effects + needs evaluate them automatically.
  • Composes with modifiers — a crafted potion with +HealAmount modifiers scores higher than a vanilla potion because the Preview pulls the modifier-stacked value through EffectPlan.
  • Allows central player tuning — the player adjusts a need's weight/threshold and every decision the policy makes shifts coherently.

Player-authored extensions

This section describes the slot contract for runtime rule, need, selector, and definition-reference edits.

Engine surface — ScopeSlotStore

The runtime stores per-actor slot lists in ScopeSlotStore keyed by (EntityHandle scope, SlotKindId kind). The store is a required field on TickContext.SlotStore so every tick path (system, event, activity preview) can read or mutate slots without an extra dependency injection hop. Each slot list is strongly typed at the API boundary:

store.Initialize<RuleDeclaration>(actor, PolicySlotKinds.Rules, defaultRules);
var rules = store.Read<RuleDeclaration>(actor, PolicySlotKinds.Rules);
store.Append(actor, PolicySlotKinds.Rules, newRule);
store.Replace(actor, PolicySlotKinds.Rules, index, replacement);
store.RemoveAt<RuleDeclaration>(actor, PolicySlotKinds.Rules, index);

Hard invariants — the store throws on uninitialized reads, double initialization, and type mismatch. There is no _or_create fallback; slots must be explicitly seeded at activation time. For Valenar's character actor, Generated/Slots/CharacterSlots.cs is the lowering target, with SeedDefaults invoked from MapGenerationSystem right after the MainCharacter entity is created.

Runtime player rules — template<RuleSlot>

A policy defines compile-time RuleDeclaration defaults. The compiler seeds them into the actor's PolicySlotKinds.Rules slot at activation time; runtime evaluation reads from the slot.

Slots MUST be seeded at activation time. There is no fall-through to the policy's compile-time defaults — a runtime read of an unseeded slot throws InvalidOperationException ("Policy slot ... is not seeded for actor ..."). Hosts call CharacterSlots.SeedDefaults (or the equivalent host bootstrap for non-character actors) right after the actor entity is activated. Tests bypass the host bootstrap by calling TestSlotSeeder.SeedFromPolicy(tick.SlotStore, actor, policy) before any rule walk or CallBest invocation; the helper lives under tests/SECS.Engine.Tests/TestUtilities/.

// examples/valenar/Content/characters/slots.secs
template<RuleSlot> on Character
{
seed_from_policy CharacterSurvival.Rules;
}

Lowering target (examples/valenar/Generated/Slots/CharacterSlots.cs):

public static readonly SlotKindId Rules = new(H.Slot_PolicyRules);

public static void SeedDefaults(ScopeSlotStore store, EntityHandle actor, SecsPolicy policy)
{
store.Initialize<RuleDeclaration>(actor, Rules, policy.Rules);
store.Initialize<NeedDeclaration>(actor, Needs, policy.Needs);
store.Initialize<SelectorDeclaration>(actor, Selectors, policy.Selectors);
store.Initialize<TemplateId>(actor, KnownSpells, Array.Empty<TemplateId>());
}

The final KnownSpells line is a Valenar-specific example of a game-owned definition-reference list; the generic slot model is simply "typed per-actor lists."

PolicyExecutor.EvaluateAllRules calls PolicyExecutor.ResolveRules per context. Edits to the slot (insert, replace, remove) take effect on the next rule walk — no cache, no dirty bit, no synchronization step.

Runtime player needs — template<NeedSlot>

NeedDeclaration itself uses the slot model. The bare ulong TargetChannelId field is replaced with a NeedTargetExpr discriminated union so designers and runtime authoring tools can pick between literal, channel-resolved, and formula-driven satiation targets:

public sealed record NeedDeclaration(
NeedId Id,
string Name,
ulong ChannelId,
NeedTargetExpr Target,
NeedCurveKind Curve,
int Threshold,
int Weight);

public abstract record NeedTargetExpr
{
public sealed record Literal(int Value) : NeedTargetExpr;
public sealed record ChannelRef(ulong ChannelId) : NeedTargetExpr;
public sealed record Formula(ulong FormulaId) : NeedTargetExpr; // formula-backed target
}

Same slot pattern: PolicyExecutor.ResolveNeeds reads PolicySlotKinds.Needs from the actor's slot store; an unseeded read throws InvalidOperationException. Player-added needs participate in scoring the same way authored needs do.

Definition-reference slots and collection-backed selectors

A policy may also read a typed per-actor list of definition references for a content family. The generic rule is the same one used throughout the behavior docs: content definition data stores row-specific knobs, one generic activity implements the mechanic, and typed args carry the chosen definition reference into each ActivityRequest.

The runtime bridge that turns those stored references into candidates is part of the C# execution boundary, not standalone .secs vocabulary. This doc stays at the design level; 10-host-secs-execution-boundary.md § 17.3-17.4 documents the current runtime types and registration path.

Valenar example: CharacterSlots.KnownSpells stores TemplateId values pointing at SpellDefinition templates. The runtime bridge packages each stored spell definition id into CastSpellArgs and emits a generic Activity_CastSpell request. Adding a new spell definition is therefore a data change, not a new activity declaration.

Predicted post-values — Set, Multiply, Modify

PolicyExecutor.CandidateRelevance now resolves the actor's current value via ChannelResolver.ResolveIntFast and projects each effect:

PredictionOpProjection
Addprojected + Amount
SetAmount
Multiplyprojected * (Amount / 100f) (matches EffectPlanner's percent-scaled long encoding)
Modifyprojected + Amount * (stacks + 1) where stacks = resolver.BindingStore.CountBindings(effect.Target, effect.ModifierId); a row with ModifierId == 0 contributes nothing (the candidate still scores as "touches the channel")

Effects compose left-to-right within the same EffectPlan, so an Add +20 followed by Multiply 200% yields a projected value of 40 on top of a current value of 0.

Save / load and migration

Save/load currently covers only partial policy and activity state. The future unified payload must preserve the distinction between host-owned world data and SECS-owned runtime records:

StateCurrent statusFuture target / gap
Settlement / character entity dataHost-owned entity store; not a SECS runtime payload.Host world snapshot/container persists scope fields, collections, and entities before SECS runtime records are restored.
Policy entity (for policy-attached state)Host-owned entity data if the game models policies as entities.Same host world snapshot path as any other entity; not part of SecsSavePayload.
Need tunable params (Need_*_Threshold, Need_*_Weight)Host-owned channel backing / scope-field data when a game exposes tunables.Persist through the host world snapshot; SECS validates definitions after load.
Player rule slotsCurrent ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicyRules).Included in future unified payload as slot snapshots.
Player need slotsCurrent ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicyNeeds).Included in future unified payload as slot snapshots.
Player selector slotsCurrent ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicySelectors).Included in future unified payload as slot snapshots.
Valenar known-spell listCurrent ScopeSlotStore row keyed by (EntityHandle actor, SlotKindId Slot_Character_KnownSpells), with TemplateId[] entries for SpellDefinition templates.Included in future unified payload as slot snapshots.
Active activity runsCurrent ActivityRun.ToState() produces ActivityRunState, including Origin; restore seeds ActivityExecutor before calling ActivityRunStore.Restore(activity, state, durationTicks). Mismatches throw SECS0810/SECS0811/SECS0812.Included in future unified payload as activity-run records.
Slot contents (rules, needs, selectors, known-spells)Current ScopeSlotStore.Snapshot() returns IReadOnlyList<SlotSnapshot>; Restore(snapshots) runs on a fresh store. Hard invalidation on type mismatch (SECS0821) or unknown slot kind / non-empty store (SECS0820).Same data should be carried by the future unified payload without changing slot ownership.
PolicyDispatcher.activeChildrenCurrent in-memory dictionary; not persisted. Child run tracking is lost on save/load.Future PolicyChildEntry-style runtime record must restore (actor, policyId) -> ActivityRunId after activity runs are restored.

Slot persistence is per-actor, per-slot-kind. The conceptual on-disk shape of a single slot row is { EntityHandle actor, SlotKindId kind, T[] entries } where T is the slot's payload type (e.g. RuleDeclaration, NeedDeclaration, SelectorDeclaration, TemplateId). Restore rehydrates each row by invoking store.Initialize<T>(actor, kind, entries) on a fresh ScopeSlotStore. There is no ScopedList<RuleSlot> collection on the policy entity — slot data hangs off the actor, never off the policy.

When the game updates and policy/activity/need definitions change between the saved game's version and the current build:

ChangeMigration policy
Compile-time rule addedEnabled by default; no save-side action
Compile-time rule removedSave's enabled flag for that rule dropped (no-op)
Compile-time need addedTunable params get default values
Compile-time need removedTunable params dropped from save
RuleSlot's TargetActivityId references a removed activitySlot disabled, UI surfaces warning, slot retained for player to fix
NeedSlot's ChannelId references a removed channelSame — disabled with warning
Policy itself removedActive policy run cancelled, retained slots discarded
Activity signature changed (e.g. now requires a target it didn't before)In-flight ActivityRun invalidated, parent policy resumes from the rule that dispatched it

End-to-end example

A character survival policy demonstrating the complete flow.

1. Authored definitions (compile time)

// examples/valenar/Content/policies/character_survival.secs
policy CharacterSurvival
{
actor Character;
domain Survival;

need StayAlive
{
channel = HP_Current;
target = HP_Max;
curve = inverse_quad;
threshold = 50;
weight = 100;
}

need KeepFed
{
channel = Stamina_Current;
target = Stamina_Max;
curve = inverse_quad;
threshold = 30;
weight = 60;
}

selector RestSelector
{
consider activity RestAtCamp from all_registered;
}

rule Decision EmergencyRest()
{
if (actor.resolve(HP_Current) * 4 < actor.resolve(HP_Max))
return call_best(RestSelector);
return continue;
}
}

2. Game runtime — host evaluates the policy

GameRuntime.Create
registers SecsModule.Policies, including CharacterSurvivalPolicy.Instance.

Each tick (or on-demand from a system / event):
policy = registry.GetPolicy(new PolicyId(H.Policy_CharacterSurvival))
foreach (ruleId, decision) in executor.EvaluateAllRules(policy, character, tick):
switch (decision):
case CallBest(selectorId):
best = executor.CallBest(policy, selectorId, ctx)
if (best != null)
request = best.Request with { Origin = ActivityRequestOrigin.Policy }
activityExecutor.Start(request, tick)
break
case Call(activityId, target, args):
activityExecutor.Start(
new ActivityRequest(activityId, character, target, args, ActivityRequestOrigin.Policy),
tick)
break
case Continue: continue
case Complete: stop walking, mark domain done
case Fail: stop walking, mark domain failed
case Wait: stop walking, leave child alive

3. Player edits the policy at runtime

The runtime path looks like:

At MainCharacter activation (MapGenerationSystem):
CharacterSlots.SeedDefaults(ctx.SlotStore, character, CharacterSurvivalPolicy.Instance);
→ store seeded with policy.Rules, policy.Needs, policy.Selectors, empty Valenar KnownSpells list

At runtime (player edits):
ctx.SlotStore.Replace<NeedDeclaration>(character, PolicySlotKinds.Needs, index, edited);
→ next PolicyExecutor.ScoreCandidates pass reads the edited need

At runtime (Valenar spell example):
ctx.SlotStore.Append<TemplateId>(character, CharacterSlots.KnownSpells, new TemplateId(H.Spell_TestSpell));
→ Valenar's collection-backed selector now sees one additional spell
definition reference and can produce a CastSpell candidate carrying
that template id in typed args.

The client-facing wire surface is a read/edit API on top of ScopeSlotStore. policy.catalog describes the authored policy defaults; per-actor edits are represented as slot read/write operations and surfaced by the policy-edit panel.

Execution notes

  1. Multi-policy-per-actor concurrency. One active policy run is allowed per domain at a time for a given actor. Re-entrancy is defined per policy kind.
  2. Sub-call failure semantics. PolicyDispatcher (src/SECS.Engine/Policies/PolicyDispatcher.cs) is the reference loop. When RuleDecision.Call(...)'s underlying ActivityExecutor.Start returns null (CanStart=false, lane occupied, costs unaffordable, etc.) the dispatcher returns PolicyDispatchResult.Fail. When RuleDecision.CallBest(...) produces no candidate, the dispatcher returns PolicyDispatchResult.Fail. Call creates a policy-origin ActivityRequest, and CallBest normalizes the selected candidate to ActivityRequestOrigin.Policy before start so the saved run records the policy dispatch boundary as provenance. See § Policy dispatch loop and 04-behavior.md § Policy dispatch loop for the full decision table.
  3. Rule ordering. Rules are walked in declaration order; the first non-Continue decision wins. Utility scoring ranks selector candidates, not rules.
  4. Need curves. The built-in curve set is Linear, Quad, InverseQuad, and Sigmoid. NeedCurveKind remains extensible.
  5. Predicted-change evaluation. Candidate scoring resolves the actor's current value via ChannelResolver.ResolveIntFast at scoring time and projects each effect left-to-right. Add, Set, Multiply, and Modify use the projection rules defined in § Predicted post-values.
  6. Preview source of truth. EffectPlan is the unified preview surface consumed by the AI. There is no separate analyzer-owned preview path.
  7. Save-versioning and player commands. Engine-wide migration callbacks and player-facing commands such as Command("policy.set_channel", ...), Command("policy.add_rule_slot", ...), and Command("scope.set_field", ...) sit above this policy contract and must preserve the slot semantics defined here.

Static analysis (warn-only)

SecsRegistry.Validate() walks every registered policy and reports patterns that almost never reflect designer intent. The pass never throws — every diagnostic carries Severity.Warning, surfaced through RegistryDiagnostics.Diagnostics.

CodeWhat fires itWhy warn-only
SECS0910policy need references an unknown channel id (SecsRegistry.ChannelExists returns false)a need targeting an unregistered channel will silently score zero forever; usually a typo or a forgotten RegisterChannels call
SECS0911policy selector references an activity id with no registered activitythe selector returns no candidates at scoring time; load order may legitimately register activities after policies so this is warn-only
SECS0912two needs over the same channel have weights of opposite signthe contributions cancel at scoring time; same-sign duplicates are allowed (multiple urgency curves on one channel is a legitimate design)
SECS0913SelectorSource.FromTags references an unknown tagthe selector enumerates an empty tag set forever
SECS0914policy actor scope id has no registered scope declarationmod patches can rewrite ActorScopeId post-finalize and bypass the per-policy hard-check

Implementation lives at src/SECS.Engine/Diagnostics/PolicyStaticAnalysis.cs. The pass cross-references activity ids registered through RegisterActivity (so the order of registration matters: register activities before policies that point at them, or accept the warning as a known-late-binding signal).

Relationship to existing SECS docs

  • 04-behavior.md § Policies covers the lowering contract for the policy keyword — declaration shell, generated SecsPolicy shape, PolicyExecutor runtime API, registration through SecsModule. This doc covers the AI scoring math and philosophy; the lowering doc covers the C# emission contract.
  • 03-channels-and-modifiers.md defines the modifier stack. Need tunable parameters are channels and participate in the modifier pipeline — a status effect can stack a modifier onto a need's weight to shift the policy's priorities globally.
  • 06-overrides-and-modding.md § 8.10–8.11 defines the activity / policy slot schema. Activity architectural slots (activity_actor_scope, activity_target_scope, activity_lane, activity_args_schema) and policy architectural slots (policy_actor_scope, policy_domain) are replace-only. Inject may patch ordinary activity fields such as duration, cooldown, metadata, tags/cost lists, lifecycle bodies, and the preview body; policy inject may patch or append needs, selectors, rules, and rule evaluate bodies by child id. Slot identity follows the structured SlotKey(DeclarationKind, DeclarationId, SlotKind, ChildId?) shape from § 7.3 of that document.
  • 08-collections-and-propagation.md defines ScopedList<T>, used for player-authored PlayerRules and PlayerNeeds.

Scope boundary

  • Specific game-side AI content stays game-owned. Combat policy, settlement governance, forage/rest behavior, and dungeon orchestration are Valenar or per-game authoring decisions, not engine surface.
  • Multi-actor coordination is out of scope for this contract. The current policy model is single-actor; squad plans or cross-actor goal sharing would require a separate primitive.
  • Long-horizon planning is also out of scope. The selector scores immediate options; GOAP-style multi-step planning would sit above this layer.