@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.
- package/README.md +87 -22
- package/dist/channels/discord.d.ts +1 -1
- package/dist/channels/whatsapp.d.ts +1 -1
- package/dist/cli.js +11104 -10312
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +972 -213
- package/dist/index.js.map +1 -1
- package/dist/mcp/effectors.d.ts +1 -1
- package/dist/{will-Bikuk4s2.d.ts → will-cS6k4uiJ.d.ts} +510 -86
- package/package.json +1 -1
- package/src/cognition/agency/engines/action.selector.ts +42 -9
- package/src/cognition/agency/engines/affordance.synthesizer.ts +9 -0
- package/src/cognition/agency/engines/motor.schema.executor.ts +4 -0
- package/src/cognition/agency/engines/reafference.engine.ts +40 -5
- package/src/cognition/agency/reconcile.learning.ts +23 -0
- package/src/cognition/agency/schemas/repertoire.ts +114 -7
- package/src/cognition/agency/selection.scoring.ts +7 -1
- package/src/cognition/agency/types.ts +9 -0
- package/src/cognition/config.mirror.entities.ts +1 -1
- package/src/cognition/faculties/executive.engine/engine.ts +136 -58
- package/src/cognition/faculties/executive.engine/facet.ts +10 -2
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +2 -1
- package/src/cognition/index.ts +4 -0
- package/src/cognition/memory/vector.embedder.ts +9 -5
- package/src/cognition/utilities/token.tracker.ts +191 -96
- package/src/host/boot.ts +78 -22
- package/src/index.ts +35 -0
- package/src/llm/index.ts +397 -96
- package/src/llm/routing.ts +198 -0
- package/src/llm/summarizer.ts +5 -1
- package/src/runners/thin-shim.runner.ts +18 -6
- package/src/sdk/will.ts +82 -16
- package/src/stem/guards/identity.coherence.ts +17 -6
- package/src/stem/index.ts +18 -3
- package/src/stem/mind.ts +155 -24
- package/src/stem/policy/arbiter.ts +171 -0
- package/src/stem/policy/rule.table.ts +172 -0
- package/src/stem/policy/verdict.recorder.ts +0 -0
- 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
|
-
// ──
|
|
481
|
+
// ── Named executive cadences ──────────────────────────────────
|
|
363
482
|
|
|
364
483
|
/**
|
|
365
484
|
* Named executive cadences — ticks between LLM calls. Lower = reasons more often
|
|
366
|
-
* (more responsive,
|
|
367
|
-
* (clamped to `minExecutiveInterval`).
|
|
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, //
|
|
371
|
-
balanced: 60, //
|
|
372
|
-
economy: 90, //
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
+
}
|
|
Binary file
|