@mindot/will 0.6.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 (40) 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 +11104 -10312
  5. package/dist/cli.js.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +972 -213
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp/effectors.d.ts +1 -1
  10. package/dist/{will-Bikuk4s2.d.ts → will-cS6k4uiJ.d.ts} +510 -86
  11. package/package.json +1 -1
  12. package/src/cognition/agency/engines/action.selector.ts +42 -9
  13. package/src/cognition/agency/engines/affordance.synthesizer.ts +9 -0
  14. package/src/cognition/agency/engines/motor.schema.executor.ts +4 -0
  15. package/src/cognition/agency/engines/reafference.engine.ts +40 -5
  16. package/src/cognition/agency/reconcile.learning.ts +23 -0
  17. package/src/cognition/agency/schemas/repertoire.ts +114 -7
  18. package/src/cognition/agency/selection.scoring.ts +7 -1
  19. package/src/cognition/agency/types.ts +9 -0
  20. package/src/cognition/config.mirror.entities.ts +1 -1
  21. package/src/cognition/faculties/executive.engine/engine.ts +136 -58
  22. package/src/cognition/faculties/executive.engine/facet.ts +10 -2
  23. package/src/cognition/faculties/executive.engine/prompt.factory.ts +2 -1
  24. package/src/cognition/index.ts +4 -0
  25. package/src/cognition/memory/vector.embedder.ts +9 -5
  26. package/src/cognition/utilities/token.tracker.ts +191 -96
  27. package/src/host/boot.ts +78 -22
  28. package/src/index.ts +35 -0
  29. package/src/llm/index.ts +397 -96
  30. package/src/llm/routing.ts +198 -0
  31. package/src/llm/summarizer.ts +5 -1
  32. package/src/runners/thin-shim.runner.ts +18 -6
  33. package/src/sdk/will.ts +82 -16
  34. package/src/stem/guards/identity.coherence.ts +17 -6
  35. package/src/stem/index.ts +18 -3
  36. package/src/stem/mind.ts +155 -24
  37. package/src/stem/policy/arbiter.ts +171 -0
  38. package/src/stem/policy/rule.table.ts +172 -0
  39. package/src/stem/policy/verdict.recorder.ts +0 -0
  40. package/src/stem/tracts/effector.controller.ts +426 -0
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 )
@@ -0,0 +1,171 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/stem/policy/arbiter.ts — the policy seam (PEP side)
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // POLICY_REAFFERENCE P0. A Policy Decision Point is consulted before a
6
+ // host-owned effector invocation is handed to the world. This file is the
7
+ // *interface* only — the enforcement point lives in effectorController
8
+ // (`bufferInvocation`), which is the single tract every external effect
9
+ // already passes through.
10
+ //
11
+ // Deliberately provider-agnostic: no transport, no vendor types, no Go. The
12
+ // null arbiter below is the default and is a strict no-op, so a Will with no
13
+ // policy configured runs byte-identically to one built before this file
14
+ // existed. `RuleTableArbiter` (rule.table.ts) is the local reference adapter;
15
+ // an external PDP (e.g. helm-ai-kernel) is a later phase and must implement
16
+ // nothing more than this interface.
17
+ //
18
+ // WHY THE STEM AND NOT COGNITION: the mind must never meet a permission
19
+ // dialog. It dispatches an intent and the world either yields or resists —
20
+ // a refusal arrives as reafference, in the same currency as any other
21
+ // outcome. Keeping the arbiter below the SDK and outside the cognition layer
22
+ // is what preserves that (and is the honest model besides: a body that
23
+ // cannot do the thing).
24
+ //
25
+ // SYNC OR ASYNC: `evaluate` may return a Verdict or a Promise of one. An
26
+ // external PDP will be async, and that is fine by construction — the intent
27
+ // is already held 'awaiting' for AWAIT_TIMEOUT (15 ticks) by the executor, so
28
+ // arbiter latency is absorbed by machinery that already exists. Both P0
29
+ // adapters are synchronous, which is why P0 changes no behaviour.
30
+ // ─────────────────────────────────────────────────────────────
31
+
32
+ /** What the boundary decided about a proposed effect. */
33
+ export type PolicyDecision = 'allow' | 'deny' | 'escalate'
34
+
35
+ /**
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.
39
+ *
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.
56
+ */
57
+ export type DenialFinality = 'class' | 'parameter' | 'context'
58
+
59
+ /**
60
+ * The nearest allowed envelope — what WOULD have been permitted. Structured so
61
+ * a learner can consume it; `field` names the constraint that bit.
62
+ *
63
+ * Every denial branch of a policy evaluator already computes this and usually
64
+ * discards it. We keep it.
65
+ */
66
+ export interface PolicyCounterfactual {
67
+ /** The constrained field, e.g. 'ttl_days', 'target', 'amount'. */
68
+ field: string
69
+ /** What the Will asked for. */
70
+ requested?: unknown
71
+ /** The bound or permitted set, e.g. 30, or [ 'a', 'b' ]. */
72
+ allowed?: unknown
73
+ }
74
+
75
+ /** A boundary decision about one proposed invocation. */
76
+ export interface Verdict {
77
+ decision: PolicyDecision
78
+ /** Stable machine-readable code, e.g. 'TARGET_NOT_ALLOWED'. Never prose. */
79
+ reasonCode?: string
80
+ /** Meaningful on 'deny' only. Absent ⇒ treat as 'parameter' — see
81
+ * `asFinality` for why that, and not 'context', is the safe default. */
82
+ finality?: DenialFinality
83
+ counterfactual?: PolicyCounterfactual
84
+ /** Free-text for logs and host UX. NEVER parsed by cognition. */
85
+ detail?: string
86
+ }
87
+
88
+ /**
89
+ * The proposed effect, as the stem sees it. Mirrors the `agency.invocation`
90
+ * bus payload the MotorSchemaExecutor emits — no cognitive internals cross
91
+ * this boundary, only the act itself.
92
+ */
93
+ export interface PolicyInvocation {
94
+ willId: string
95
+ /** The awaiting `agency.intent` id — the correlation handle, end to end. */
96
+ intentId: string
97
+ /** The motor schema id, i.e. the ability being enacted. */
98
+ schema: string
99
+ parameters: Record<string, unknown>
100
+ targetEntityId?: string
101
+ /** The ability's declared meaning, as given by the host at wiring time. */
102
+ description?: string
103
+ tick: number
104
+ }
105
+
106
+ /**
107
+ * A Policy Decision Point. Implementations must be PURE with respect to the
108
+ * Will: an arbiter may read its own policy and the invocation, and nothing
109
+ * else. It must not reach into simulation state.
110
+ *
111
+ * DETERMINISM CONTRACT: an arbiter is an external oracle, exactly like the LLM.
112
+ * Its verdicts are recorded on the tape and replayed back — replay never
113
+ * re-consults an arbiter (see P1). Implementations therefore need not be
114
+ * deterministic themselves, but MUST be free of side effects on the Will.
115
+ */
116
+ export interface PolicyArbiter {
117
+ /** Stable identifier, recorded alongside the verdict for audit. */
118
+ readonly name: string
119
+ evaluate( invocation: PolicyInvocation ): Verdict | Promise<Verdict>
120
+ }
121
+
122
+ /** The single allow verdict, frozen and shared — the null arbiter allocates nothing. */
123
+ const ALLOW: Readonly<Verdict> = Object.freeze({ decision: 'allow' as const })
124
+
125
+ /**
126
+ * The default. Allows everything, allocates nothing, logs nothing.
127
+ *
128
+ * A Will running this must be byte-identical to one built before the policy
129
+ * seam existed — that property is asserted by test, and it is what lets this
130
+ * ship dark.
131
+ */
132
+ export const NULL_ARBITER: PolicyArbiter = {
133
+ name: 'null',
134
+ evaluate(): Verdict { return ALLOW },
135
+ }
136
+
137
+ /** True when the arbiter is the no-op default (used to skip the seam entirely). */
138
+ export function isNullArbiter( arbiter: PolicyArbiter | null | undefined ): boolean {
139
+ return !arbiter || arbiter === NULL_ARBITER
140
+ }
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
+
147
+ /**
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.
168
+ */
169
+ export function asFinality( raw: unknown ): DenialFinality {
170
+ return raw === 'class' ? 'class' : raw === 'context' ? 'context' : 'parameter'
171
+ }
@@ -0,0 +1,172 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/stem/policy/rule.table.ts — the local reference PDP
3
+ // ─────────────────────────────────────────────────────────────
4
+ //
5
+ // POLICY_REAFFERENCE P0. A declarative, in-process arbiter: enough policy to
6
+ // prove the seam is sufficient WITHOUT an external dependency, and the
7
+ // fallback if an external-PDP track stalls.
8
+ //
9
+ // It is also a working demonstration of the two receipt fields we are
10
+ // proposing upstream (`finality`, `counterfactual`): when a bound is violated
11
+ // this arbiter *returns what would have been allowed* instead of discarding
12
+ // it. That is the whole argument, implemented in ~40 lines — a denial that
13
+ // says "never" and one that says "not with these parameters" are different
14
+ // facts, and a learner needs to tell them apart.
15
+ //
16
+ // Evaluation is first-match-wins over an ordered rule array, so a verdict is a
17
+ // pure function of ( rules, invocation ) — no clock, no IO, no iteration-order
18
+ // surprises. Deterministic by construction, which keeps replay honest.
19
+ // ─────────────────────────────────────────────────────────────
20
+
21
+ import type {
22
+ PolicyArbiter, PolicyInvocation, Verdict,
23
+ PolicyDecision, DenialFinality, PolicyCounterfactual,
24
+ } from '#stem/policy/arbiter'
25
+
26
+ /** A bound on one parameter. Checked in declaration order: max, min, equals, oneOf. */
27
+ export interface ParamConstraint {
28
+ max?: number
29
+ min?: number
30
+ equals?: unknown
31
+ oneOf?: readonly unknown[]
32
+ }
33
+
34
+ /**
35
+ * One rule. `schema`/`target` scope it (omitted ⇒ matches any); the FIRST rule
36
+ * whose scope matches decides, so order is policy.
37
+ *
38
+ * `require` is meaningful with `decision: 'allow'` only: the scope matched, and
39
+ * these constraints must hold for the allow to stand. A violation flips the
40
+ * verdict to deny — carrying the counterfactual — with finality 'parameter',
41
+ * because the ability itself was permitted and only these arguments were not.
42
+ *
43
+ * A rule with `decision: 'deny'` and no `require` is a flat class-level ban;
44
+ * it reports finality 'class' unless told otherwise.
45
+ */
46
+ export interface PolicyRule {
47
+ schema?: string
48
+ target?: string
49
+ decision: PolicyDecision
50
+ require?: Record<string, ParamConstraint>
51
+ reasonCode?: string
52
+ finality?: DenialFinality
53
+ }
54
+
55
+ export interface RuleTableOptions {
56
+ rules: readonly PolicyRule[]
57
+ /**
58
+ * The verdict when NO rule matches. Required — deliberately not defaulted.
59
+ * A policy component that silently defaults open is a trap; make the posture
60
+ * an explicit decision at the call site. 'deny' is fail-closed and is the
61
+ * right choice once a rule set is complete.
62
+ */
63
+ fallthrough: PolicyDecision
64
+ /** Recorded with every verdict for audit. Defaults to 'rule-table'. */
65
+ name?: string
66
+ }
67
+
68
+ export class RuleTableArbiter implements PolicyArbiter {
69
+ readonly name: string
70
+ private readonly _rules: readonly PolicyRule[]
71
+ private readonly _fallthrough: PolicyDecision
72
+
73
+ constructor( opts: RuleTableOptions ){
74
+ this.name = opts.name ?? 'rule-table'
75
+ this._rules = opts.rules
76
+ this._fallthrough = opts.fallthrough
77
+ }
78
+
79
+ evaluate( invocation: PolicyInvocation ): Verdict {
80
+ for( const rule of this._rules ){
81
+ if( !scopeMatches( rule, invocation ) ) continue
82
+
83
+ if( rule.decision !== 'allow')
84
+ return {
85
+ decision: rule.decision,
86
+ ...( rule.reasonCode ? { reasonCode: rule.reasonCode } : {} ),
87
+ ...( rule.decision === 'deny'
88
+ ? { finality: rule.finality ?? 'class' }
89
+ : {} ),
90
+ }
91
+
92
+ const violation = firstViolation( rule.require, invocation.parameters )
93
+ if( violation )
94
+ return {
95
+ decision: 'deny',
96
+ reasonCode: rule.reasonCode ?? violation.reasonCode,
97
+ finality: rule.finality ?? 'parameter',
98
+ counterfactual: violation.counterfactual,
99
+ }
100
+
101
+ return { decision: 'allow' }
102
+ }
103
+
104
+ return {
105
+ decision: this._fallthrough,
106
+ ...( this._fallthrough !== 'allow' ? { reasonCode: 'NO_MATCHING_RULE', finality: 'class' as const } : {} ),
107
+ }
108
+ }
109
+ }
110
+
111
+ // ── matching ─────────────────────────────────────────────────────────────────
112
+
113
+ function scopeMatches( rule: PolicyRule, inv: PolicyInvocation ): boolean {
114
+ if( rule.schema !== undefined && rule.schema !== '*' && rule.schema !== inv.schema ) return false
115
+ if( rule.target !== undefined && rule.target !== '*' && rule.target !== inv.targetEntityId ) return false
116
+ return true
117
+ }
118
+
119
+ interface Violation {
120
+ reasonCode: string
121
+ counterfactual: PolicyCounterfactual
122
+ }
123
+
124
+ /**
125
+ * The first constraint that fails, in declared order — so the counterfactual a
126
+ * caller receives is stable, not whichever check happened to run first.
127
+ *
128
+ * An ABSENT parameter is a violation: a bound cannot be honoured by a value
129
+ * that was never supplied, and habitual enaction (which carries no args) must
130
+ * not slip past a constraint the deliberate path would have to satisfy.
131
+ */
132
+ function firstViolation(
133
+ require: Record<string, ParamConstraint> | undefined,
134
+ parameters: Record<string, unknown>,
135
+ ): Violation | null {
136
+ if( !require ) return null
137
+
138
+ for( const [ field, constraint ] of Object.entries( require ) ){
139
+ const present = Object.prototype.hasOwnProperty.call( parameters, field )
140
+ if( !present )
141
+ return {
142
+ reasonCode: 'PARAM_MISSING',
143
+ counterfactual: { field, allowed: describe( constraint ) },
144
+ }
145
+
146
+ const value = parameters[ field ]
147
+
148
+ if( constraint.max !== undefined && !( typeof value === 'number' && value <= constraint.max ) )
149
+ return { reasonCode: 'PARAM_ABOVE_MAX', counterfactual: { field, requested: value, allowed: constraint.max } }
150
+
151
+ if( constraint.min !== undefined && !( typeof value === 'number' && value >= constraint.min ) )
152
+ return { reasonCode: 'PARAM_BELOW_MIN', counterfactual: { field, requested: value, allowed: constraint.min } }
153
+
154
+ if( 'equals' in constraint && value !== constraint.equals )
155
+ return { reasonCode: 'PARAM_NOT_EQUAL', counterfactual: { field, requested: value, allowed: constraint.equals } }
156
+
157
+ if( constraint.oneOf !== undefined && !constraint.oneOf.includes( value ) )
158
+ return { reasonCode: 'PARAM_NOT_IN_SET', counterfactual: { field, requested: value, allowed: [ ...constraint.oneOf ] } }
159
+ }
160
+
161
+ return null
162
+ }
163
+
164
+ /** A compact description of a constraint, for the missing-parameter case. */
165
+ function describe( c: ParamConstraint ): unknown {
166
+ if( c.oneOf !== undefined ) return [ ...c.oneOf ]
167
+ if( 'equals' in c ) return c.equals
168
+ if( c.max !== undefined && c.min !== undefined ) return { min: c.min, max: c.max }
169
+ if( c.max !== undefined ) return { max: c.max }
170
+ if( c.min !== undefined ) return { min: c.min }
171
+ return null
172
+ }