@mindot/will 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +87 -22
  2. package/dist/channels/discord.d.ts +1 -1
  3. package/dist/channels/whatsapp.d.ts +1 -1
  4. package/dist/cli.js +10823 -10454
  5. package/dist/cli.js.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +562 -226
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp/effectors.d.ts +1 -1
  10. package/dist/{will-DAW0l-lY.d.ts → will-cS6k4uiJ.d.ts} +470 -84
  11. package/package.json +1 -1
  12. package/src/cognition/agency/engines/action.selector.ts +2 -1
  13. package/src/cognition/agency/engines/reafference.engine.ts +12 -2
  14. package/src/cognition/agency/reconcile.learning.ts +16 -2
  15. package/src/cognition/agency/schemas/repertoire.ts +12 -5
  16. package/src/cognition/config.mirror.entities.ts +1 -1
  17. package/src/cognition/faculties/executive.engine/engine.ts +136 -58
  18. package/src/cognition/faculties/executive.engine/facet.ts +10 -2
  19. package/src/cognition/faculties/executive.engine/prompt.factory.ts +2 -1
  20. package/src/cognition/index.ts +4 -0
  21. package/src/cognition/memory/vector.embedder.ts +9 -5
  22. package/src/cognition/utilities/token.tracker.ts +191 -96
  23. package/src/host/boot.ts +78 -22
  24. package/src/index.ts +35 -0
  25. package/src/llm/index.ts +397 -96
  26. package/src/llm/routing.ts +198 -0
  27. package/src/llm/summarizer.ts +5 -1
  28. package/src/runners/thin-shim.runner.ts +18 -6
  29. package/src/sdk/will.ts +82 -16
  30. package/src/stem/guards/identity.coherence.ts +17 -6
  31. package/src/stem/index.ts +3 -3
  32. package/src/stem/mind.ts +155 -24
  33. package/src/stem/policy/arbiter.ts +49 -14
  34. package/src/stem/policy/rule.table.ts +2 -2
  35. package/src/stem/tracts/effector.controller.ts +56 -9
package/src/stem/mind.ts CHANGED
@@ -25,6 +25,7 @@ import { validateWillIdentity } from '#stem/guards/identity.guard'
25
25
  import { OutboxWriter } from '#stem/tracts/outbox.writer'
26
26
  import { ExecutiveSummarizer } from '#llm/summarizer'
27
27
  import type { LLMProvider } from '#llm/index'
28
+ import { TableRouter, chainRouters, type ModelRouter, type RoutingRule } from '#llm/routing'
28
29
  import { resolveProfile } from '#profiles/index'
29
30
  import { DefaultVectorMemoryAdapter } from '#memory/vector.adapter'
30
31
  import { OpenAICompatibleEmbedder, MockEmbedder } from '#memory/vector.embedder'
@@ -46,6 +47,7 @@ import { effectorName, type EffectorDeclaration } from '#agency/types'
46
47
 
47
48
  import {
48
49
  TokenTracker,
50
+ type PriceTable,
49
51
  EnergyRegulator,
50
52
  SleepPressureRegulator,
51
53
  CircadianOscillator,
@@ -137,12 +139,54 @@ export interface WillModelConfig {
137
139
  * `apiKey` is held in memory only — it is never mirrored into state entities,
138
140
  * session logs, or the PMA.
139
141
  */
142
+ export interface WillProviderConfig {
143
+ /** Credential for this provider. Held in memory only — never state/logs/PMA. */
144
+ apiKey?: string
145
+ /** Base URL override — self-hosted or OpenAI-compatible endpoints. */
146
+ baseUrl?: string
147
+ /**
148
+ * USD per 1M tokens, keyed by model id. Host-owned on purpose: prices change
149
+ * on a vendor's schedule, differ per account, and are ~0 self-hosted, so they
150
+ * cannot be tracked from inside an npm release. These win over the engine's
151
+ * built-in fallback table.
152
+ *
153
+ * Cost is telemetry only — it never enters simulation state — so changing a
154
+ * price can never change what a mind does or break a replay.
155
+ */
156
+ prices?: PriceTable
157
+ }
158
+
140
159
  export interface WillLLMConfig {
141
160
  provider?: LLMProvider
142
161
  apiKey?: string
143
162
  baseUrl?: string
144
163
  maxOutputTokens?: number
145
164
  timeoutMs?: number
165
+ /**
166
+ * Everything the host knows about each provider — credential, endpoint, and
167
+ * prices — declared once per provider. The single-provider fields above stay
168
+ * the simple path; this map is for hosts reaching more than one.
169
+ */
170
+ providers?: Partial<Record<LLMProvider, WillProviderConfig>>
171
+ /**
172
+ * Per-call model selection. Omitted (or NULL_ROUTER) means every call uses
173
+ * `model` above, exactly as before the seam existed.
174
+ *
175
+ * The router sees only the call's attribution — what kind of work it is and
176
+ * how much the moment demands — never who is paying or what anything costs.
177
+ * Routes name providers from the `providers` map above; a route to a provider
178
+ * with no credential falls back to the default rather than failing the call.
179
+ */
180
+ router?: ModelRouter | null
181
+ /**
182
+ * Concrete LLM model id(s) for this Will — a single id for every role, or a
183
+ * per-role map. An explicit WILL_LLM_MODEL env pins the thinking roles
184
+ * (operator single-model deployments); unset roles fall back to `executive`,
185
+ * then the LLMDirector's built-in default. Product-level labels (pricing
186
+ * tiers, model families) live host-side and resolve to concrete ids BEFORE
187
+ * reaching the engine.
188
+ */
189
+ model?: string | WillModelConfig
146
190
  }
147
191
 
148
192
  /** Executive-side resolved roles (embedding is threaded separately). */
@@ -159,6 +203,91 @@ export interface ExecutiveModelRoles {
159
203
  * single-model deployment, full stop. Embedding is untouched by the pin
160
204
  * (different model family; the embedding stack has its own env).
161
205
  */
206
+ /**
207
+ * Flatten the per-provider `prices` maps into one model→price table.
208
+ *
209
+ * Providers declare their own models, so collisions are not expected; if two
210
+ * do claim the same id, the first declared wins rather than silently taking
211
+ * whichever iterated last.
212
+ */
213
+ export function mergeProviderPrices(
214
+ providers?: Partial<Record<LLMProvider, WillProviderConfig>>,
215
+ ): PriceTable | undefined {
216
+ if( !providers ) return undefined
217
+ const out: PriceTable = {}
218
+ for( const entry of Object.values( providers ) ){
219
+ for( const [ model, price ] of Object.entries( entry?.prices ?? {} ) ){
220
+ if( !( model in out ) ) out[ model ] = price
221
+ }
222
+ }
223
+ return Object.keys( out ).length > 0 ? out : undefined
224
+ }
225
+
226
+ /**
227
+ * Narrow the per-provider map to just what the LLM transport needs — the
228
+ * prices ride to the TokenTracker instead, so a credential map never carries
229
+ * pricing into the call path.
230
+ */
231
+ export function providerCredentials(
232
+ providers: Partial<Record<LLMProvider, WillProviderConfig>>,
233
+ ): Partial<Record<LLMProvider, { apiKey: string; baseUrl?: string }>> {
234
+ const out: Partial<Record<LLMProvider, { apiKey: string; baseUrl?: string }>> = {}
235
+ for( const [ name, entry ] of Object.entries( providers ) ){
236
+ if( !entry?.apiKey ) continue // no key ⇒ unusable; the router falls back
237
+ out[ name as LLMProvider ] = {
238
+ apiKey: entry.apiKey,
239
+ ...( entry.baseUrl ? { baseUrl: entry.baseUrl } : {} ),
240
+ }
241
+ }
242
+ return out
243
+ }
244
+
245
+ /**
246
+ * Compile the per-role model map into routing rules — MODEL_ROUTING W7.
247
+ *
248
+ * The role map and the router were two mechanisms answering one question
249
+ * ("which model serves this call?"). The map was implemented by giving each
250
+ * role its own cached `LLMDirector`, which meant role selection happened at
251
+ * *facet-spawn* time while routing happened at *call* time — two answers, one
252
+ * question, and a facet could be pinned to a model its work had since stopped
253
+ * matching. The map is now sugar: it desugars to rules and there is one
254
+ * mechanism left.
255
+ *
256
+ * The mapping is exact, because every role's call sites already tag themselves
257
+ * with the matching axis:
258
+ *
259
+ * | role | rule | call sites |
260
+ * | :--- | :--- | :--- |
261
+ * | `summarizer` | `category: 'summarizer'` | the rolling summariser |
262
+ * | `deliberation` | `function: 'deliberation'` | the deliberation facet + its propose pass |
263
+ * | `conversation` | `function: 'conversation'` and `'outreach'` | the audition facets |
264
+ * | `executive` | — it *is* the default | master, and every unlabelled facet |
265
+ *
266
+ * Roles equal to `executive` emit no rule: a Will on a single model keeps an
267
+ * empty chain and stays byte-identical to one built before this seam existed.
268
+ *
269
+ * Routes carry no provider — a role has never had one. They inherit the Will's
270
+ * default provider, which is exactly what the per-role directors did.
271
+ */
272
+ export function compileRoleRouter( roles: ExecutiveModelRoles ): ModelRouter | null {
273
+ const { executive, summarizer, deliberation, conversation } = roles
274
+ const distinct = ( model: string | null ): model is string => !!model && model !== executive
275
+
276
+ const rules: RoutingRule[] = []
277
+ if( distinct( summarizer ) )
278
+ rules.push( { category: 'summarizer', route: { model: summarizer } } )
279
+ if( distinct( deliberation ) )
280
+ rules.push( { function: 'deliberation', route: { model: deliberation } } )
281
+ if( distinct( conversation ) ){
282
+ // Outreach is the same voice speaking first — it has always shared the
283
+ // conversation model, and splitting it is the host's call, via a router.
284
+ rules.push( { function: 'conversation', route: { model: conversation } } )
285
+ rules.push( { function: 'outreach', route: { model: conversation } } )
286
+ }
287
+
288
+ return rules.length > 0 ? new TableRouter( rules, 'role-map') : null
289
+ }
290
+
162
291
  export function resolveModelRoles( model?: string | WillModelConfig ): ExecutiveModelRoles & { embedding: string | null } {
163
292
  const map = typeof model === 'string' ? { executive: model } : ( model ?? {} )
164
293
  const pin = process.env.WILL_LLM_MODEL
@@ -223,16 +352,6 @@ export interface WillConfig {
223
352
  /** Anatomy — 'mind' (default) or the no-LLM 'reflex' shell. */
224
353
  anatomy?: Anatomy
225
354
 
226
- /**
227
- * Concrete LLM model id(s) for this Will — a single id for every role, or a
228
- * per-role map. An explicit WILL_LLM_MODEL env pins the thinking roles
229
- * (operator single-model deployments); unset roles fall back to `executive`,
230
- * then the LLMDirector's built-in default. Product-level labels (pricing
231
- * tiers, model families) live host-side and resolve to concrete ids BEFORE
232
- * reaching the engine.
233
- */
234
- model?: string | WillModelConfig
235
-
236
355
  /**
237
356
  * Per-Will LLM transport overrides (provider, BYO apiKey, baseUrl, output
238
357
  * cap, timeout). Unset fields fall back to WILL_LLM_* envs. The apiKey never
@@ -359,17 +478,17 @@ export interface MindAssembly {
359
478
  outbox: OutboxMessage[]
360
479
  }
361
480
 
362
- // ── Cadence defaults per tier ─────────────────────────────────
481
+ // ── Named executive cadences ──────────────────────────────────
363
482
 
364
483
  /**
365
484
  * Named executive cadences — ticks between LLM calls. Lower = reasons more often
366
- * (more responsive, higher COGS). Customers pick via `config.executiveInterval`
367
- * (clamped to `minExecutiveInterval`). See monetization.md "Cost Per Will-Hour".
485
+ * (more responsive, more tokens per Will-hour). Callers pick via
486
+ * `config.executiveInterval` (clamped to `minExecutiveInterval`).
368
487
  */
369
488
  export const EXECUTIVE_CADENCE = {
370
- responsive: 30, // Sonnetpremium/Enterprise; opt in via executiveInterval
371
- balanced: 60, // Sonnet — Pro default
372
- economy: 90, // Haiku — Starter default
489
+ responsive: 30, // most attentive, highest spend — opt in via executiveInterval
490
+ balanced: 60, // default
491
+ economy: 90, // least attentive, lowest spend
373
492
  } as const
374
493
 
375
494
 
@@ -666,6 +785,10 @@ function _constructCognition(
666
785
  // (attachTokenTracker), so usage/cost never conflates across Wills and
667
786
  // parallel runs stay isolated.
668
787
  const tokenTracker = new TokenTracker({
788
+ // Host prices, flattened from the per-provider map. Cost is telemetry only
789
+ // (it never enters state), so this can differ run to run without touching
790
+ // determinism.
791
+ prices: mergeProviderPrices( config.llm?.providers ),
669
792
  emitCostEvents: true,
670
793
  costWarningThresholdUsd: 0.02,
671
794
  willId,
@@ -708,7 +831,7 @@ function _constructCognition(
708
831
 
709
832
  // Per-Will, per-ROLE models (env WILL_LLM_MODEL pins all thinking roles —
710
833
  // operator single-model deployments). No tier vocabulary inside the engine.
711
- const modelRoles = resolveModelRoles( config.model )
834
+ const modelRoles = resolveModelRoles( config.llm?.model )
712
835
 
713
836
  const { embedder, vectorMemory } = _resolveVectorMemory( willId, randomSeed, config.vectorMemoryAdapter, config.disableVectorMemory, tokenTracker, config.testMode, modelRoles.embedding ?? undefined )
714
837
  const episodicConsolidator = new EpisodicConsolidator( vectorMemory ? { vectorMemory, ...(embedder ? { embedder } : {}) } : {} )
@@ -746,13 +869,21 @@ function _constructCognition(
746
869
  const executiveEngine = new ExecutiveEngine({ executiveInterval, cooldownTicks: 5 })
747
870
 
748
871
  executiveEngine.willId = willId
749
- executiveEngine.llm = config.llm ?? null
750
- executiveEngine.models = {
751
- executive: modelRoles.executive,
752
- summarizer: modelRoles.summarizer,
753
- deliberation: modelRoles.deliberation,
754
- conversation: modelRoles.conversation,
755
- }
872
+ // Narrow the host's per-provider map to credentials for the call path; the
873
+ // prices from that same map went to the TokenTracker above.
874
+ // W7 — the per-role model map compiles into routing rules and joins the
875
+ // host's own router in one chain, so the engine receives a single answer to
876
+ // "which model serves this call?" instead of two. The host's router leads:
877
+ // that is the precedence the two mechanisms already had.
878
+ const roleRouter = compileRoleRouter( modelRoles )
879
+ executiveEngine.llm = config.llm
880
+ ? {
881
+ ...config.llm,
882
+ ...( config.llm.providers ? { credentials: providerCredentials( config.llm.providers ) } : {} ),
883
+ router: chainRouters( config.llm.router, roleRouter ),
884
+ }
885
+ : ( roleRouter ? { router: roleRouter } : null )
886
+ executiveEngine.modelId = modelRoles.executive
756
887
  if( config.testMode ) executiveEngine.setTestMode( true )
757
888
  executiveEngine.attachWorkingMemory( workingMemory )
758
889
  executiveEngine.attachGoalManager( goalManager )
@@ -33,15 +33,28 @@
33
33
  export type PolicyDecision = 'allow' | 'deny' | 'escalate'
34
34
 
35
35
  /**
36
- * Is this denial a property of the ACTION CLASS (never permitted under the
37
- * active policy) or of THIS INSTANCE (wrong target, exceeded bound)?
36
+ * WHY this denial is final the distinction that makes a refusal learnable
37
+ * rather than a wall to re-probe forever. Each value selects a different
38
+ * cognitive fate; they are not degrees of one severity.
38
39
  *
39
- * This is the distinction that makes a refusal learnable rather than a wall to
40
- * re-probe forever, and it is the field we are proposing upstream to HELM. A
41
- * 'class' denial should suppress the affordance; an 'instance' denial should
42
- * only narrow the parameter envelope the Will reaches for.
40
+ * 'class' — the ACTION ITSELF is never permitted. Suppress the
41
+ * affordance hard, erase any learned envelope, and let go of
42
+ * a commitment currently deliberating toward it.
43
+ * 'parameter' the action is fine; THESE ARGUMENTS were not (bound
44
+ * exceeded, wrong target). Narrow the envelope the Will
45
+ * reaches for; the ability stays.
46
+ * • 'context' — the refusal was NOT ABOUT THE ACTION at all (tainted
47
+ * context, unavailable dependency). Touch nothing: no
48
+ * availability delta, no envelope, no competence.
49
+ *
50
+ * POLICY_REAFFERENCE P5 widened this from 'class' | 'instance' after the HELM
51
+ * joint RFC ("Denials That Teach") identified that an instance-scoped refusal
52
+ * splits in two, and that the two halves demand opposite responses. These are
53
+ * OUR names for the distinctions, deliberately not HELM's wire spellings — see
54
+ * the naming-boundary note in .TODO/POLICY_REAFFERENCE.md. A provider adapter
55
+ * translates; this interface stays vendor-neutral.
43
56
  */
44
- export type DenialFinality = 'class' | 'instance'
57
+ export type DenialFinality = 'class' | 'parameter' | 'context'
45
58
 
46
59
  /**
47
60
  * The nearest allowed envelope — what WOULD have been permitted. Structured so
@@ -64,9 +77,8 @@ export interface Verdict {
64
77
  decision: PolicyDecision
65
78
  /** Stable machine-readable code, e.g. 'TARGET_NOT_ALLOWED'. Never prose. */
66
79
  reasonCode?: string
67
- /** Meaningful on 'deny' only. Absent ⇒ treat as 'instance' (the safe default:
68
- * it narrows rather than suppresses, so a mis-tagged denial cannot silently
69
- * delete an ability from the Will's reach). */
80
+ /** Meaningful on 'deny' only. Absent ⇒ treat as 'parameter' see
81
+ * `asFinality` for why that, and not 'context', is the safe default. */
70
82
  finality?: DenialFinality
71
83
  counterfactual?: PolicyCounterfactual
72
84
  /** Free-text for logs and host UX. NEVER parsed by cognition. */
@@ -127,10 +139,33 @@ export function isNullArbiter( arbiter: PolicyArbiter | null | undefined ): bool
127
139
  return !arbiter || arbiter === NULL_ARBITER
128
140
  }
129
141
 
142
+ /** Normalize a denial's finality. Absent ⇒ 'parameter' (see `asFinality`). */
143
+ export function finalityOf( verdict: Verdict ): DenialFinality {
144
+ return asFinality( verdict.finality )
145
+ }
146
+
130
147
  /**
131
- * Normalize a denial's finality. Absent 'instance', the conservative reading:
132
- * an unlabelled refusal narrows the envelope but never removes the ability.
148
+ * Normalize an UNTYPED finality one read back off entity metadata, a verdict
149
+ * tape, or a host ack, where the type system cannot help.
150
+ *
151
+ * Every such read goes through here rather than comparing string literals at
152
+ * the call site: the enum is a moving target (it widened once for the HELM
153
+ * joint RFC and may again), and a hand-written `=== 'class'` scattered through
154
+ * cognition is a mis-route that still typechecks.
155
+ *
156
+ * THE DEFAULT IS 'parameter', AND NEVER 'context'. The intuitive reading — an
157
+ * unlabelled denial should do the LEAST, so default to the fate that touches
158
+ * nothing — is wrong, and dangerously so. 'context' means *the mind learns
159
+ * nothing from this denial*, so it re-probes the same wall forever: the exact
160
+ * failure this whole epoch exists to fix, silently re-enabled for any provider
161
+ * that doesn't tag its refusals. 'context' is a claim only a provider that
162
+ * actually knows can make — it must be ASSERTED, never defaulted to.
163
+ * 'parameter' narrows without deleting, which is the honest conservative
164
+ * reading and preserves pre-P5 behaviour for an untagged denial exactly.
165
+ *
166
+ * Legacy 'instance' (the pre-P5 spelling) normalizes to 'parameter' by the same
167
+ * fallback, so tapes and snapshots written before the split replay unchanged.
133
168
  */
134
- export function finalityOf( verdict: Verdict ): DenialFinality {
135
- return verdict.finality === 'class' ? 'class' : 'instance'
169
+ export function asFinality( raw: unknown ): DenialFinality {
170
+ return raw === 'class' ? 'class' : raw === 'context' ? 'context' : 'parameter'
136
171
  }
@@ -37,7 +37,7 @@ export interface ParamConstraint {
37
37
  *
38
38
  * `require` is meaningful with `decision: 'allow'` only: the scope matched, and
39
39
  * these constraints must hold for the allow to stand. A violation flips the
40
- * verdict to deny — carrying the counterfactual — with finality 'instance',
40
+ * verdict to deny — carrying the counterfactual — with finality 'parameter',
41
41
  * because the ability itself was permitted and only these arguments were not.
42
42
  *
43
43
  * A rule with `decision: 'deny'` and no `require` is a flat class-level ban;
@@ -94,7 +94,7 @@ export class RuleTableArbiter implements PolicyArbiter {
94
94
  return {
95
95
  decision: 'deny',
96
96
  reasonCode: rule.reasonCode ?? violation.reasonCode,
97
- finality: rule.finality ?? 'instance',
97
+ finality: rule.finality ?? 'parameter',
98
98
  counterfactual: violation.counterfactual,
99
99
  }
100
100
 
@@ -24,7 +24,8 @@
24
24
  import { logger } from '#core/logger'
25
25
  import { reconcileInvocation } from '#agency/reconcile.learning'
26
26
  import { NULL_ARBITER, isNullArbiter } from '#stem/policy/arbiter'
27
- import type { PolicyArbiter, PolicyInvocation, Verdict } from '#stem/policy/arbiter'
27
+ import type { PolicyArbiter, PolicyInvocation, Verdict, DenialFinality, PolicyCounterfactual } from '#stem/policy/arbiter'
28
+ import { finalityOf, asFinality } from '#stem/policy/arbiter'
28
29
  import {
29
30
  getVerdictRecorder, getVerdictSource, type PolicyVerdictRecord,
30
31
  } from '#stem/policy/verdict.recorder'
@@ -36,9 +37,38 @@ interface PendingRefusal {
36
37
  intentId: string
37
38
  schema: string
38
39
  reasonCode: string
39
- finality: string
40
+ finality: DenialFinality
41
+ /** ENVELOPE_NARROWING P0 — what WOULD have been allowed, carried through to
42
+ * the outcome the mind learns from. Absent on refusals that have no bound to
43
+ * report (a flat ban, a fault, an unanswered escalation). */
44
+ counterfactual?: PolicyCounterfactual
40
45
  }
41
46
 
47
+ /**
48
+ * The verdict a fault produces (POLICY_REAFFERENCE P5, conformance S9).
49
+ *
50
+ * An arbiter that throws or rejects has always failed CLOSED — the effect is
51
+ * withheld — but it used to withhold *silently*, queueing no refusal. The held
52
+ * intent then expired at the executor's AWAIT_TIMEOUT and reconciled as a plain
53
+ * failure, landing on COMPETENCE: a PDP outage taught the mind it was unskilled
54
+ * at something it is perfectly capable of. So a fault now yields a real verdict:
55
+ *
56
+ * • 'deny' — still fail-closed, unchanged. The effect never reaches the world.
57
+ * • 'context' — but it teaches NOTHING. The arbiter being unreachable is not a
58
+ * fact about the ability, so nothing about the ability may move.
59
+ *
60
+ * It goes through `_recordAndApply` rather than straight to the refusal queue so
61
+ * the fault lands on the VERDICT TAPE too. That closes a replay hole: an
62
+ * unrecorded fault left the source with nothing to re-feed, and a source miss
63
+ * reproduces a buffered ALLOW — so a live run that withheld the effect would
64
+ * have replayed as one that dispatched it.
65
+ */
66
+ const ARBITER_FAULT_VERDICT: Readonly<Verdict> = Object.freeze({
67
+ decision: 'deny' as const,
68
+ reasonCode: 'ARBITER_UNAVAILABLE',
69
+ finality: 'context' as const,
70
+ })
71
+
42
72
  /** How long an escalated intent is held awaiting a resolution before it degrades
43
73
  * to a refusal — 2× the host-ack timeout, so a human has real time to answer. */
44
74
  const ESCALATION_TTL_TICKS = 30
@@ -136,6 +166,7 @@ export class effectorController {
136
166
  try { verdict = this._arbiter.evaluate( invocation ) }
137
167
  catch( err ){
138
168
  logger.error(`[policy] arbiter "${this._arbiter.name}" threw for "${invocation.schema}" — failing closed:`, err )
169
+ this._recordAndApply( instance, payload, invocation, ARBITER_FAULT_VERDICT )
139
170
  return
140
171
  }
141
172
 
@@ -145,7 +176,10 @@ export class effectorController {
145
176
  // queue drains each tick, so a verdict landing a few ticks late still lands.
146
177
  void verdict.then(
147
178
  v => this._recordAndApply( instance, payload, invocation, v ),
148
- err => logger.error(`[policy] arbiter "${this._arbiter.name}" rejected for "${invocation.schema}" — failing closed:`, err ),
179
+ err => {
180
+ logger.error(`[policy] arbiter "${this._arbiter.name}" rejected for "${invocation.schema}" — failing closed:`, err )
181
+ this._recordAndApply( instance, payload, invocation, ARBITER_FAULT_VERDICT )
182
+ },
149
183
  )
150
184
  return
151
185
  }
@@ -218,7 +252,8 @@ export class effectorController {
218
252
  intentId: invocation.intentId,
219
253
  schema: invocation.schema,
220
254
  reasonCode: verdict.reasonCode ?? 'POLICY_DENIED',
221
- finality: verdict.finality ?? 'instance',
255
+ finality: finalityOf( verdict ),
256
+ ...( verdict.counterfactual ? { counterfactual: verdict.counterfactual } : {} ),
222
257
  })
223
258
  this._pendingRefusals.set( instance.config.id, queue )
224
259
  return
@@ -272,7 +307,8 @@ export class effectorController {
272
307
  this.confirmExecution( instance, refusal.intentId, {
273
308
  success: false,
274
309
  refused: true,
275
- finality: refusal.finality === 'class' ? 'class' : 'instance',
310
+ finality: refusal.finality,
311
+ ...( refusal.counterfactual ? { counterfactual: refusal.counterfactual } : {} ),
276
312
  description: `refused by policy: ${refusal.reasonCode} (${refusal.finality})`,
277
313
  } )
278
314
  }
@@ -317,7 +353,16 @@ export class effectorController {
317
353
  }
318
354
  }
319
355
 
320
- /** Degrade escalations no one answered in time into instance-refusals (P4). */
356
+ /**
357
+ * Degrade escalations no one answered in time into light refusals (P4).
358
+ *
359
+ * Finality 'parameter' is chosen for its BEHAVIOUR, not its name: silence is
360
+ * not literally an argument problem, but the light-dent-with-recovery it
361
+ * produces is exactly right — a Will whose asks go unanswered should ask
362
+ * progressively less, and should resume asking if someone starts answering.
363
+ * 'class' would be a lie (nobody said never) and 'context' would teach
364
+ * nothing, leaving the mind to escalate forever into an empty room.
365
+ */
321
366
  private _expireEscalations( instance: WillInstance, tick: number ): void {
322
367
  const active = this._activeEscalations.get( instance.config.id )
323
368
  if( !active || active.size === 0 ) return
@@ -325,14 +370,14 @@ export class effectorController {
325
370
  if( tick < esc.expiresAt ) continue
326
371
  active.delete( intentId )
327
372
  this._clearEscalated( instance, intentId )
328
- this._queueRefusal( instance, esc.intentId, esc.schema, 'ESCALATION_EXPIRED', 'instance')
373
+ this._queueRefusal( instance, esc.intentId, esc.schema, 'ESCALATION_EXPIRED', 'parameter')
329
374
  logger.info(`[policy] escalation EXPIRED → refusing "${esc.schema}" intent "${intentId}"`)
330
375
  }
331
376
  }
332
377
 
333
378
  /** Push a refusal onto the queue drained by _applyRefusals this same tick. */
334
379
  private _queueRefusal(
335
- instance: WillInstance, intentId: string, schema: string, reasonCode: string, finality: 'class' | 'instance',
380
+ instance: WillInstance, intentId: string, schema: string, reasonCode: string, finality: DenialFinality,
336
381
  ): void {
337
382
  const queue = this._pendingRefusals.get( instance.config.id ) ?? []
338
383
  queue.push({ intentId, schema, reasonCode, finality })
@@ -419,7 +464,9 @@ export class effectorController {
419
464
  /** POLICY_REAFFERENCE P2 — set when the ack is a policy refusal, so the
420
465
  * ReafferenceEngine routes it to availability rather than competence. */
421
466
  refused?: boolean
422
- finality?: 'class' | 'instance'
467
+ finality?: DenialFinality
468
+ /** ENVELOPE_NARROWING P0 — the bound that was exceeded, if the arbiter said. */
469
+ counterfactual?: PolicyCounterfactual
423
470
  },
424
471
  ): void {
425
472
  const tick = instance.tickCount