@mindot/will 0.9.0 → 0.10.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 +15 -11
- package/dist/index.d.ts +188 -141
- package/dist/index.js +16660 -15206
- package/dist/index.js.map +1 -1
- package/dist/{channels → surface/channels}/discord.d.ts +50 -3
- package/dist/{channels → surface/channels}/discord.js +102 -7
- package/dist/surface/channels/discord.js.map +1 -0
- package/dist/{channels → surface/channels}/whatsapp.d.ts +2 -2
- package/dist/{channels → surface/channels}/whatsapp.js +7 -4
- package/dist/surface/channels/whatsapp.js.map +1 -0
- package/dist/{cli.js → surface/cli.js} +4406 -2904
- package/dist/surface/cli.js.map +1 -0
- package/dist/{mcp → surface/mcp}/effectors.d.ts +1 -1
- package/dist/{mcp → surface/mcp}/effectors.js +10 -4
- package/dist/surface/mcp/effectors.js.map +1 -0
- package/dist/{will-DbDj_TEH.d.ts → will-evj9_vrd.d.ts} +5699 -4854
- package/package.json +11 -12
- package/src/cognition/agency/consequence.ts +237 -3
- package/src/cognition/agency/conversation.aim.ts +32 -0
- package/src/cognition/agency/engines/action.selector.ts +59 -3
- package/src/cognition/agency/engines/affordance.synthesizer.ts +114 -10
- package/src/cognition/agency/engines/deliberation.engine.ts +76 -3
- package/src/cognition/agency/engines/motor.schema.executor.ts +362 -27
- package/src/cognition/agency/engines/reafference.engine.ts +43 -4
- package/src/cognition/agency/execution.primitives.ts +17 -4
- package/src/cognition/agency/reconcile.learning.ts +5 -4
- package/src/cognition/agency/schemas/innate.ts +96 -1
- package/src/cognition/agency/schemas/repertoire.ts +19 -1
- package/src/cognition/agency/selection.scoring.ts +16 -0
- package/src/cognition/agency/settlement.ts +203 -0
- package/src/cognition/agency/types.ts +38 -1
- package/src/cognition/config.mirror.entities.ts +0 -1
- package/src/cognition/event.schemas.ts +0 -8
- package/src/cognition/faculties/affective.blender.ts +21 -4
- package/src/cognition/faculties/circadian.oscillator.ts +36 -7
- package/src/cognition/faculties/executive.engine/action.record.ts +129 -0
- package/src/cognition/faculties/executive.engine/context.ts +41 -35
- package/src/cognition/faculties/executive.engine/engine.ts +270 -113
- package/src/cognition/faculties/executive.engine/escalation.buffer.ts +126 -59
- package/src/cognition/faculties/executive.engine/facet.supervisor.ts +10 -0
- package/src/cognition/faculties/executive.engine/parser.ts +100 -38
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +168 -23
- package/src/cognition/faculties/executive.engine/types.ts +18 -15
- package/src/cognition/faculties/exteroception.ts +99 -98
- package/src/cognition/faculties/goal.manager.ts +50 -2
- package/src/cognition/faculties/known.entity.tracker.ts +22 -3
- package/src/cognition/faculties/planning.engine/engine.ts +4 -4
- package/src/cognition/faculties/planning.engine/plan.frontier.ts +1 -1
- package/src/cognition/faculties/semantic.engine/integrator.ts +1 -1
- package/src/cognition/faculties/working.memory.ts +6 -1
- package/src/cognition/instruction.handler.ts +1 -1
- package/src/cognition/memory/vector.embedder.ts +1 -3
- package/src/cognition/percept.entity.ts +122 -0
- package/src/cognition/sense.boundary.ts +176 -0
- package/src/cognition/senses/audition.engine/engine.ts +97 -16
- package/src/cognition/senses/base.sense.engine.ts +104 -6
- package/src/cognition/senses/index.ts +71 -13
- package/src/cognition/senses/provenance.ts +128 -0
- package/src/cognition/senses/somatosensation.engine.ts +117 -9
- package/src/cognition/social.identity.ts +23 -0
- package/src/cognition/utilities/token.tracker.ts +36 -5
- package/src/core/orchestrator.ts +13 -0
- package/src/index.ts +39 -5
- package/src/llm/gate.ts +48 -0
- package/src/llm/index.ts +102 -25
- package/src/llm/wire.contracts.ts +38 -0
- package/src/pma/index.ts +1 -1
- package/src/stem/index.ts +92 -21
- package/src/stem/mind.ts +26 -2
- package/src/stem/policy/arbiter.ts +10 -0
- package/src/stem/profiles/built-in.ts +7 -0
- package/src/{profiles → stem/profiles}/companion.ts +1 -1
- package/src/{profiles → stem/profiles}/company-brain.ts +1 -1
- package/src/{profiles → stem/profiles}/customer-service.ts +1 -1
- package/src/{profiles → stem/profiles}/game-npc.ts +1 -1
- package/src/{profiles → stem/profiles}/index.ts +1 -1
- package/src/{profiles → stem/profiles}/smart-home.ts +1 -1
- package/src/stem/tracts/effector/escalation.lifecycle.ts +239 -0
- package/src/stem/tracts/effector/policy.enforcement.ts +261 -0
- package/src/stem/tracts/effector/types.ts +110 -0
- package/src/stem/tracts/effector.controller.ts +112 -401
- package/src/stem/tracts/outbox.controller.ts +30 -17
- package/src/stem/tracts/sensory.controller.ts +6 -6
- package/src/stem/tracts/session.logger.ts +6 -1
- package/src/stem/tracts/transport/types.ts +20 -1
- package/src/stem/tracts/transport.controller.ts +33 -9
- package/src/{channels → surface/channels}/discord.ts +224 -7
- package/src/{channels → surface/channels}/roster.ts +1 -1
- package/src/{channels → surface/channels}/types.ts +1 -1
- package/src/{channels → surface/channels}/whatsapp.ts +9 -6
- package/src/{cli.ts → surface/cli.ts} +6 -6
- package/src/{host → surface/host}/boot.ts +3 -3
- package/src/{host → surface/host}/utterances.ts +2 -2
- package/src/{mcp → surface/mcp}/effectors.ts +19 -6
- package/src/{mcp → surface/mcp}/server.ts +19 -5
- package/src/{sdk → surface/sdk}/will.ts +156 -28
- package/src/{serve → surface/serve}/server.ts +11 -4
- package/src/types.ts +24 -3
- package/dist/channels/discord.js.map +0 -1
- package/dist/channels/whatsapp.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/mcp/effectors.js.map +0 -1
- package/src/extensions/livestream.ext.ts +0 -570
- package/src/extensions/time.ext.ts +0 -339
- package/src/profiles/built-in.ts +0 -7
- package/src/runners/coherence.runner.ts +0 -49
- package/src/runners/outreach.runner.ts +0 -155
- package/src/runners/social.runner.ts +0 -179
- package/src/runners/thin-shim.runner.ts +0 -205
- /package/dist/{cli.d.ts → surface/cli.d.ts} +0 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/stem/tracts/effector/escalation.lifecycle.ts
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// SIGNAL_BOUNDARY P1a — the escalation seam, extracted verbatim from
|
|
6
|
+
// `effector.controller.ts`. Hold, resolve, expire.
|
|
7
|
+
//
|
|
8
|
+
// An escalation is a Will asking before acting (POLICY_REAFFERENCE P4). The
|
|
9
|
+
// intent is held so the executor stops timing it out, the ask is voiced ONCE,
|
|
10
|
+
// and a host answer later approves (dispatch the withheld payload) or denies
|
|
11
|
+
// (refuse it). Unanswered, it degrades to a light refusal at the TTL.
|
|
12
|
+
//
|
|
13
|
+
// `_voiceEscalation` is here for now and does not belong here — see the note on
|
|
14
|
+
// the method. Moving it is P1a's third bullet, not this cut.
|
|
15
|
+
|
|
16
|
+
import { logger } from '#core/logger'
|
|
17
|
+
// The record type, not a string literal: `## What I've Said Lately` is built by
|
|
18
|
+
// reading it, and a second spelling here would be a contract kept in two places.
|
|
19
|
+
import { SENT_TYPE } from '#agency/conversation.aim'
|
|
20
|
+
import type { WillInstance } from '#stem/index'
|
|
21
|
+
import {
|
|
22
|
+
ESCALATION_TTL_TICKS,
|
|
23
|
+
type EffectorOps, type Escalation, type PendingResolution,
|
|
24
|
+
} from './types'
|
|
25
|
+
|
|
26
|
+
/** What the lifecycle needs from the seams either side of it. */
|
|
27
|
+
export interface EscalationDeps extends EffectorOps {
|
|
28
|
+
/** Queue a refusal on the policy side — a denial or an expiry. */
|
|
29
|
+
queueRefusal(
|
|
30
|
+
instance: WillInstance, intentId: string, schema: string,
|
|
31
|
+
reasonCode: string, finality: 'class' | 'parameter' | 'context',
|
|
32
|
+
): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class EscalationLifecycle {
|
|
36
|
+
/** Escalations awaiting their first application (mark intent + voice the ask). */
|
|
37
|
+
private _newEscalations = new Map<string, Escalation[]>()
|
|
38
|
+
/** Escalations currently held, keyed by intent id — the resolvable set. */
|
|
39
|
+
private _activeEscalations = new Map<string, Map<string, Escalation>>()
|
|
40
|
+
/** Host answers awaiting application at the next tick boundary. */
|
|
41
|
+
private _pendingResolutions = new Map<string, PendingResolution[]>()
|
|
42
|
+
|
|
43
|
+
constructor( private readonly _deps: EscalationDeps ){}
|
|
44
|
+
|
|
45
|
+
/** Raise a new escalation, applied (marked + voiced) at the next boundary.
|
|
46
|
+
* Called by policy enforcement when a verdict says 'escalate'. */
|
|
47
|
+
raise( instance: WillInstance, esc: Escalation ): void {
|
|
48
|
+
const escalations = this._newEscalations.get( instance.config.id ) ?? []
|
|
49
|
+
escalations.push( esc )
|
|
50
|
+
this._newEscalations.set( instance.config.id, escalations )
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Record a host's answer to an escalation (POLICY_REAFFERENCE P4). Applied at
|
|
55
|
+
* the next tick boundary so every simulation-state write stays on the boundary:
|
|
56
|
+
* approve dispatches the held invocation to the world; deny refuses it. A
|
|
57
|
+
* no-op if the intent id is not (or no longer) an active escalation.
|
|
58
|
+
*/
|
|
59
|
+
resolve( instance: WillInstance, intentId: string, approved: boolean ): void {
|
|
60
|
+
const queue = this._pendingResolutions.get( instance.config.id ) ?? []
|
|
61
|
+
queue.push({ intentId, approved })
|
|
62
|
+
this._pendingResolutions.set( instance.config.id, queue )
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Apply host answers to active escalations (POLICY_REAFFERENCE P4). */
|
|
66
|
+
applyResolutions( instance: WillInstance ): void {
|
|
67
|
+
const queue = this._pendingResolutions.get( instance.config.id )
|
|
68
|
+
if( !queue || queue.length === 0 ) return
|
|
69
|
+
this._pendingResolutions.set( instance.config.id, [] )
|
|
70
|
+
|
|
71
|
+
const active = this._activeEscalations.get( instance.config.id )
|
|
72
|
+
for( const { intentId, approved } of queue ){
|
|
73
|
+
const esc = active?.get( intentId )
|
|
74
|
+
if( !esc ) continue // unknown / already resolved — ignore
|
|
75
|
+
active!.delete( intentId )
|
|
76
|
+
this._clearEscalated( instance, intentId ) // release the executor's hold
|
|
77
|
+
if( approved ){
|
|
78
|
+
this._deps.buffer( instance, esc.payload ) // dispatch the held invocation now
|
|
79
|
+
logger.info(`[policy] escalation APPROVED → dispatching "${esc.schema}" intent "${intentId}"`)
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this._deps.queueRefusal( instance, esc.intentId, esc.schema, esc.reasonCode, 'class')
|
|
83
|
+
logger.info(`[policy] escalation DENIED → refusing "${esc.schema}" intent "${intentId}"`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Degrade escalations no one answered in time into light refusals (P4).
|
|
90
|
+
*
|
|
91
|
+
* Finality 'parameter' is chosen for its BEHAVIOUR, not its name: silence is
|
|
92
|
+
* not literally an argument problem, but the light-dent-with-recovery it
|
|
93
|
+
* produces is exactly right — a Will whose asks go unanswered should ask
|
|
94
|
+
* progressively less, and should resume asking if someone starts answering.
|
|
95
|
+
* 'class' would be a lie (nobody said never) and 'context' would teach
|
|
96
|
+
* nothing, leaving the mind to escalate forever into an empty room.
|
|
97
|
+
*/
|
|
98
|
+
expire( instance: WillInstance, tick: number ): void {
|
|
99
|
+
const active = this._activeEscalations.get( instance.config.id )
|
|
100
|
+
if( !active || active.size === 0 ) return
|
|
101
|
+
for( const [ intentId, esc ] of active ){
|
|
102
|
+
if( tick < esc.expiresAt ) continue
|
|
103
|
+
active.delete( intentId )
|
|
104
|
+
this._clearEscalated( instance, intentId )
|
|
105
|
+
this._deps.queueRefusal( instance, esc.intentId, esc.schema, 'ESCALATION_EXPIRED', 'parameter')
|
|
106
|
+
logger.info(`[policy] escalation EXPIRED → refusing "${esc.schema}" intent "${intentId}"`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Raise each newly-escalated intent (POLICY_REAFFERENCE P4): mark it held in
|
|
111
|
+
* simulation state, voice the ask ONCE, and move it to the resolvable set. */
|
|
112
|
+
applyNew( instance: WillInstance, tick: number ): void {
|
|
113
|
+
const pending = this._newEscalations.get( instance.config.id )
|
|
114
|
+
if( !pending || pending.length === 0 ) return
|
|
115
|
+
this._newEscalations.set( instance.config.id, [] )
|
|
116
|
+
|
|
117
|
+
const active = this._activeEscalations.get( instance.config.id ) ?? new Map<string, Escalation>()
|
|
118
|
+
for( const esc of pending ){
|
|
119
|
+
esc.expiresAt = tick + ESCALATION_TTL_TICKS
|
|
120
|
+
this._markEscalated( instance, esc.intentId, esc.expiresAt )
|
|
121
|
+
this._voiceEscalation( instance, esc )
|
|
122
|
+
active.set( esc.intentId, esc )
|
|
123
|
+
}
|
|
124
|
+
this._activeEscalations.set( instance.config.id, active )
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Mark the awaiting intent held: the executor stops timing it out (P4). */
|
|
128
|
+
private _markEscalated( instance: WillInstance, intentId: string, expiresAt: number ): void {
|
|
129
|
+
const intent = instance.simulation.stateManager.snapshot().entities.get( intentId )
|
|
130
|
+
if( !intent || intent.type !== 'agency.intent') return
|
|
131
|
+
instance.simulation.stateManager.setEntity({
|
|
132
|
+
id: intent.id,
|
|
133
|
+
type: intent.type,
|
|
134
|
+
metadata: { ...( intent.metadata ?? {} ), escalated: true, escalationExpiresAt: expiresAt },
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Release the hold so the executor resumes normal timeout for this intent. */
|
|
139
|
+
private _clearEscalated( instance: WillInstance, intentId: string ): void {
|
|
140
|
+
const intent = instance.simulation.stateManager.snapshot().entities.get( intentId )
|
|
141
|
+
if( !intent || intent.type !== 'agency.intent') return
|
|
142
|
+
const meta = { ...( intent.metadata ?? {} ) } as Record<string, unknown>
|
|
143
|
+
delete meta['escalated']; delete meta['escalationExpiresAt']
|
|
144
|
+
instance.simulation.stateManager.setEntity({ id: intent.id, type: intent.type, metadata: meta })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Voice the escalation as a first-person broadcast ask — once, at raise time.
|
|
149
|
+
*
|
|
150
|
+
* THIS DOES NOT BELONG HERE, and the P1a note says so: speech is the outbox's
|
|
151
|
+
* job, and an escalation should *ask for* an utterance rather than compose
|
|
152
|
+
* one. `escalationAsk` below is a string template standing in for a facet
|
|
153
|
+
* that would say it in the Will's own voice. Left in place by this cut, which
|
|
154
|
+
* is a pure move — relocating it is a behaviour question about who authors
|
|
155
|
+
* the words, not a question about where the file boundary goes.
|
|
156
|
+
*/
|
|
157
|
+
private _voiceEscalation( instance: WillInstance, esc: Escalation ): void {
|
|
158
|
+
// The SIMULATION clock, not the lifecycle's `tick`.
|
|
159
|
+
//
|
|
160
|
+
// Two counters live here and they are thousands apart. `applyPolicyOutcomes`
|
|
161
|
+
// passes `instance.tickCount` — a process-local counter that starts at 0 on
|
|
162
|
+
// every boot — and the whole escalation lifecycle is self-consistent in that
|
|
163
|
+
// space, so nothing else notices. `conversation.sent` is read in SIM-clock
|
|
164
|
+
// space by `readSpokenTurns` and `spokenAtByEntity`, where the state manager
|
|
165
|
+
// stamps `updatedAtTick` from the simulation clock.
|
|
166
|
+
//
|
|
167
|
+
// Written with the process counter, the ask landed ~17,000 ticks in the past
|
|
168
|
+
// on a live Will: it still existed, but `readSpokenTurns` sorts oldest-first
|
|
169
|
+
// and `## What I've Said Lately` keeps only the newest few — so the record
|
|
170
|
+
// was dropped from the one section it was added to appear in. Found by a live
|
|
171
|
+
// run, not by the suite: both halves were internally consistent.
|
|
172
|
+
const tick = instance.simulation.clock.currentTick
|
|
173
|
+
const content = escalationAsk( esc.schema, esc.reasonCode )
|
|
174
|
+
try {
|
|
175
|
+
instance.cognition.outboxWriter.enqueue({
|
|
176
|
+
targetEntityId: '*',
|
|
177
|
+
content,
|
|
178
|
+
effectorName: 'broadcast',
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
catch( err ){
|
|
182
|
+
logger.warn(`[policy] escalation voice failed for "${esc.schema}": ${errMsg( err )}`)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Words that left the mind have to exist in its record of having said them.
|
|
187
|
+
//
|
|
188
|
+
// These did not. The ask goes out through the outbox directly — no intent,
|
|
189
|
+
// no `_deliver` — so nothing wrote the `conversation.sent` that
|
|
190
|
+
// `## What I've Said Lately` is built from, and the one thing she could not
|
|
191
|
+
// see was the one thing she was being asked about. Live: she broadcast
|
|
192
|
+
// "I want to discord_unban_member … May I go ahead?", was asked who she
|
|
193
|
+
// meant, and answered "I didn't send that. I've never asked to unban
|
|
194
|
+
// anyone" — seven times across four minutes, correctly, because from the
|
|
195
|
+
// inside she never had.
|
|
196
|
+
//
|
|
197
|
+
// Recorded AFTER the enqueue and skipped when it throws: a record of speech
|
|
198
|
+
// that never left is the same fault facing the other way.
|
|
199
|
+
try {
|
|
200
|
+
instance.simulation.stateManager.setEntity({
|
|
201
|
+
// Keyed by schema as well as tick — two intents can escalate on one
|
|
202
|
+
// tick, and a shared id would leave the mind remembering one ask.
|
|
203
|
+
id: `conv-sent-escalation-${ esc.schema }-${ tick }`,
|
|
204
|
+
type: SENT_TYPE,
|
|
205
|
+
metadata: {
|
|
206
|
+
targetEntityId: '*',
|
|
207
|
+
// She said it to the room. Naming it the way she would say it keeps
|
|
208
|
+
// the line readable next to the people in the same list.
|
|
209
|
+
targetEntityName: 'everyone here',
|
|
210
|
+
messageCount: 1,
|
|
211
|
+
preview: content.slice( 0, 100 ),
|
|
212
|
+
effectorName: 'broadcast',
|
|
213
|
+
tick,
|
|
214
|
+
delivered: false,
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
catch( err ){ logger.warn(`[policy] escalation record failed for "${esc.schema}": ${errMsg( err )}`) }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** First-person ask for an escalated action, carrying the reason's MEANING (P4).
|
|
223
|
+
* Kept template-simple here; the facet-authored version is a later refinement. */
|
|
224
|
+
function escalationAsk( schema: string, reasonCode: string ): string {
|
|
225
|
+
const meaning = ESCALATION_MEANINGS[ reasonCode ] ?? 'I need your approval before I can do this'
|
|
226
|
+
return `I want to ${ schema }, but ${ meaning }. May I go ahead?`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** reasonCode → human meaning. Unknown codes fall back to a generic phrase. */
|
|
230
|
+
const ESCALATION_MEANINGS: Record<string, string> = {
|
|
231
|
+
APPROVAL_REQUIRED: 'I need your approval before I can on my own',
|
|
232
|
+
WRITE_REQUIRES_APPROVAL: "it writes to the world and I shouldn't on my own",
|
|
233
|
+
PAYMENT_REQUIRES_APPROVAL: 'it moves money and I must not do that unattended',
|
|
234
|
+
DEPLOY_REQUIRES_APPROVAL: 'it ships something and needs a human to sign off',
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function errMsg( err: unknown ): string {
|
|
238
|
+
return err instanceof Error ? err.message : String( err )
|
|
239
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/stem/tracts/effector/policy.enforcement.ts
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// SIGNAL_BOUNDARY P1a — the policy seam, extracted verbatim from
|
|
6
|
+
// `effector.controller.ts`. Arbiter, verdicts, refusals.
|
|
7
|
+
//
|
|
8
|
+
// This is the PEP: the Policy Enforcement Point that consults the arbiter (the
|
|
9
|
+
// PDP) before an invocation reaches the world, records the verdict on the tape,
|
|
10
|
+
// and enforces it. A refusal does not raise a dialog — it is reconciled as a
|
|
11
|
+
// failure ack, so the mind meets *world resistance* and learns from it
|
|
12
|
+
// (POLICY_REAFFERENCE P1/P2).
|
|
13
|
+
|
|
14
|
+
import { logger } from '#core/logger'
|
|
15
|
+
import { NULL_ARBITER, isNullArbiter, finalityOf } from '#stem/policy/arbiter'
|
|
16
|
+
import type {
|
|
17
|
+
PolicyArbiter, PolicyInvocation, Verdict, DenialFinality,
|
|
18
|
+
} from '#stem/policy/arbiter'
|
|
19
|
+
import {
|
|
20
|
+
getVerdictRecorder, getVerdictSource, type PolicyVerdictRecord,
|
|
21
|
+
} from '#stem/policy/verdict.recorder'
|
|
22
|
+
import type { WillInstance } from '#stem/index'
|
|
23
|
+
import type { EffectorOps, Escalation, PendingRefusal } from './types'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The verdict a fault produces (POLICY_REAFFERENCE P5, conformance S9).
|
|
27
|
+
*
|
|
28
|
+
* An arbiter that throws or rejects has always failed CLOSED — the effect is
|
|
29
|
+
* withheld — but it used to withhold *silently*, queueing no refusal. The held
|
|
30
|
+
* intent then expired at the executor's AWAIT_TIMEOUT and reconciled as a plain
|
|
31
|
+
* failure, landing on COMPETENCE: a PDP outage taught the mind it was unskilled
|
|
32
|
+
* at something it is perfectly capable of. So a fault now yields a real verdict:
|
|
33
|
+
*
|
|
34
|
+
* • 'deny' — still fail-closed, unchanged. The effect never reaches the world.
|
|
35
|
+
* • 'context' — but it teaches NOTHING. The arbiter being unreachable is not a
|
|
36
|
+
* fact about the ability, so nothing about the ability may move.
|
|
37
|
+
*
|
|
38
|
+
* It goes through `_recordAndApply` rather than straight to the refusal queue so
|
|
39
|
+
* the fault lands on the VERDICT TAPE too. That closes a replay hole: an
|
|
40
|
+
* unrecorded fault left the source with nothing to re-feed, and a source miss
|
|
41
|
+
* reproduces a buffered ALLOW — so a live run that withheld the effect would
|
|
42
|
+
* have replayed as one that dispatched it.
|
|
43
|
+
*/
|
|
44
|
+
const ARBITER_FAULT_VERDICT: Readonly<Verdict> = Object.freeze({
|
|
45
|
+
decision: 'deny' as const,
|
|
46
|
+
reasonCode: 'ARBITER_UNAVAILABLE',
|
|
47
|
+
finality: 'context' as const,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** What enforcement needs from the seams either side of it. */
|
|
51
|
+
export interface PolicyDeps extends EffectorOps {
|
|
52
|
+
/** Hand an escalate verdict to the escalation lifecycle. */
|
|
53
|
+
raiseEscalation( instance: WillInstance, esc: Escalation ): void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class PolicyEnforcement {
|
|
57
|
+
/** The Policy Decision Point consulted before an invocation reaches the world.
|
|
58
|
+
* Defaults to the no-op arbiter, so an unconfigured Will is byte-identical. */
|
|
59
|
+
private _arbiter: PolicyArbiter = NULL_ARBITER
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Denials queued during a step's flush, drained at the NEXT tick boundary
|
|
63
|
+
* (POLICY_REAFFERENCE P1). Keyed by willId — harness state, exactly like
|
|
64
|
+
* `pendingEffectorInvocations`; never simulation state, so it does not touch
|
|
65
|
+
* `simulation.step` determinism and is regenerated on any re-execution.
|
|
66
|
+
*/
|
|
67
|
+
private _pendingRefusals = new Map<string, PendingRefusal[]>()
|
|
68
|
+
|
|
69
|
+
constructor( private readonly _deps: PolicyDeps ){}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Install a Policy Decision Point (POLICY_REAFFERENCE P0). Passing null
|
|
73
|
+
* restores the no-op default. The arbiter sees only the proposed act — never
|
|
74
|
+
* simulation state — and its verdict decides whether the invocation is
|
|
75
|
+
* handed to the host at all.
|
|
76
|
+
*/
|
|
77
|
+
setArbiter( arbiter: PolicyArbiter | null ): void {
|
|
78
|
+
this._arbiter = arbiter ?? NULL_ARBITER
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Consult the PDP for this invocation and enforce the answer. Falls through to
|
|
83
|
+
* a plain buffer when no policy is configured — the byte-identical fast path.
|
|
84
|
+
*
|
|
85
|
+
* `toPolicyInvocation` is supplied by the caller so the projection onto the
|
|
86
|
+
* policy boundary stays with the translation seam that owns the payload shape.
|
|
87
|
+
*/
|
|
88
|
+
evaluate(
|
|
89
|
+
instance: WillInstance,
|
|
90
|
+
payload: Record<string, unknown>,
|
|
91
|
+
project: ( instance: WillInstance, payload: Record<string, unknown> ) => PolicyInvocation,
|
|
92
|
+
): void {
|
|
93
|
+
const willId = instance.config.id
|
|
94
|
+
|
|
95
|
+
// Replay: a registered source re-feeds the recorded verdict instead of
|
|
96
|
+
// re-consulting a live (or absent) PDP — the arbiter is an external oracle,
|
|
97
|
+
// exactly like the LLM. Checked FIRST so replay never re-enters the arbiter.
|
|
98
|
+
const source = getVerdictSource( willId )
|
|
99
|
+
if( source ){
|
|
100
|
+
const invocation = project( instance, payload )
|
|
101
|
+
const record = source.verdictFor( invocation.tick, invocation.intentId )
|
|
102
|
+
// A miss means the live run had no verdict here (null arbiter at record
|
|
103
|
+
// time) — the invocation was simply buffered, so reproduce that.
|
|
104
|
+
if( record ) this._applyVerdict( instance, payload, invocation, recordToVerdict( record ) )
|
|
105
|
+
else this._deps.buffer( instance, payload )
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Fast path: no policy configured ⇒ the seam does not exist. No allocation,
|
|
110
|
+
// no branch beyond this one — the byte-identical guarantee.
|
|
111
|
+
if( isNullArbiter( this._arbiter ) ){
|
|
112
|
+
this._deps.buffer( instance, payload )
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const invocation = project( instance, payload )
|
|
117
|
+
let verdict: Verdict | Promise<Verdict>
|
|
118
|
+
|
|
119
|
+
// An arbiter that throws must never become an implicit allow.
|
|
120
|
+
try { verdict = this._arbiter.evaluate( invocation ) }
|
|
121
|
+
catch( err ){
|
|
122
|
+
logger.error(`[policy] arbiter "${this._arbiter.name}" threw for "${invocation.schema}" — failing closed:`, err )
|
|
123
|
+
this._recordAndApply( instance, payload, invocation, ARBITER_FAULT_VERDICT )
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if( verdict instanceof Promise ){
|
|
128
|
+
// An external PDP resolves out of tick. Safe by construction: the executor
|
|
129
|
+
// holds the intent 'awaiting' for AWAIT_TIMEOUT (15 ticks), and the refusal
|
|
130
|
+
// queue drains each tick, so a verdict landing a few ticks late still lands.
|
|
131
|
+
void verdict.then(
|
|
132
|
+
v => this._recordAndApply( instance, payload, invocation, v ),
|
|
133
|
+
err => {
|
|
134
|
+
logger.error(`[policy] arbiter "${this._arbiter.name}" rejected for "${invocation.schema}" — failing closed:`, err )
|
|
135
|
+
this._recordAndApply( instance, payload, invocation, ARBITER_FAULT_VERDICT )
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this._recordAndApply( instance, payload, invocation, verdict )
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Capture the verdict on the tape (if a recorder is attached), then enforce it. */
|
|
145
|
+
private _recordAndApply(
|
|
146
|
+
instance: WillInstance,
|
|
147
|
+
payload: Record<string, unknown>,
|
|
148
|
+
invocation: PolicyInvocation,
|
|
149
|
+
verdict: Verdict,
|
|
150
|
+
): void {
|
|
151
|
+
const sink = getVerdictRecorder( instance.config.id )
|
|
152
|
+
sink?.recordVerdict({
|
|
153
|
+
tick: invocation.tick,
|
|
154
|
+
willId: instance.config.id,
|
|
155
|
+
intentId: invocation.intentId,
|
|
156
|
+
schema: invocation.schema,
|
|
157
|
+
arbiter: this._arbiter.name,
|
|
158
|
+
decision: verdict.decision,
|
|
159
|
+
...( verdict.reasonCode ? { reasonCode: verdict.reasonCode } : {} ),
|
|
160
|
+
...( verdict.finality ? { finality: verdict.finality } : {} ),
|
|
161
|
+
...( verdict.counterfactual ? { counterfactual: verdict.counterfactual } : {} ),
|
|
162
|
+
timestamp: Date.now(),
|
|
163
|
+
})
|
|
164
|
+
this._applyVerdict( instance, payload, invocation, verdict )
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Enforce a verdict (POLICY_REAFFERENCE P1). THE ROUTER, and the one method
|
|
169
|
+
* that reaches across both seams by design:
|
|
170
|
+
*
|
|
171
|
+
* • allow → hand the invocation to the world.
|
|
172
|
+
* • deny → queue a refusal ack, applied at the next tick boundary via
|
|
173
|
+
* `confirmExecution` — the same lifecycle as a host rejection,
|
|
174
|
+
* so the mind meets *world resistance*, not a permission dialog.
|
|
175
|
+
* • escalate → raise a held escalation (POLICY_REAFFERENCE P4): the intent is
|
|
176
|
+
* held (the executor stops timing it out), the Will voices a
|
|
177
|
+
* first-person ask once, and a host resolution later approves
|
|
178
|
+
* (dispatch) or denies (refuse). Unresolved, it degrades to a
|
|
179
|
+
* refusal at ESCALATION_TTL_TICKS.
|
|
180
|
+
*
|
|
181
|
+
* P1's refusal reconciles as a plain FAILURE — safe, but the wrong learning
|
|
182
|
+
* signal (forbidden ≠ unskilled). P2 routes it to affordance AVAILABILITY
|
|
183
|
+
* instead of competence.
|
|
184
|
+
*/
|
|
185
|
+
private _applyVerdict(
|
|
186
|
+
instance: WillInstance,
|
|
187
|
+
payload: Record<string, unknown>,
|
|
188
|
+
invocation: PolicyInvocation,
|
|
189
|
+
verdict: Verdict,
|
|
190
|
+
): void {
|
|
191
|
+
if( verdict.decision === 'allow'){
|
|
192
|
+
this._deps.buffer( instance, payload )
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const cf = verdict.counterfactual
|
|
197
|
+
logger.info(
|
|
198
|
+
`[policy] ${verdict.decision.toUpperCase()} "${invocation.schema}" intent "${invocation.intentId}"` +
|
|
199
|
+
` — ${verdict.reasonCode ?? 'no reason code'}` +
|
|
200
|
+
( verdict.finality ? ` (${verdict.finality})` : '') +
|
|
201
|
+
( cf ? ` [${cf.field}: requested ${JSON.stringify( cf.requested )}, allowed ${JSON.stringify( cf.allowed )}]` : ''),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if( verdict.decision === 'deny'){
|
|
205
|
+
const queue = this._pendingRefusals.get( instance.config.id ) ?? []
|
|
206
|
+
queue.push({
|
|
207
|
+
intentId: invocation.intentId,
|
|
208
|
+
schema: invocation.schema,
|
|
209
|
+
reasonCode: verdict.reasonCode ?? 'POLICY_DENIED',
|
|
210
|
+
finality: finalityOf( verdict ),
|
|
211
|
+
...( verdict.counterfactual ? { counterfactual: verdict.counterfactual } : {} ),
|
|
212
|
+
})
|
|
213
|
+
this._pendingRefusals.set( instance.config.id, queue )
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 'escalate' — raise a held escalation, applied (marked + voiced) at the boundary.
|
|
218
|
+
this._deps.raiseEscalation( instance, {
|
|
219
|
+
intentId: invocation.intentId,
|
|
220
|
+
schema: invocation.schema,
|
|
221
|
+
reasonCode: verdict.reasonCode ?? 'APPROVAL_REQUIRED',
|
|
222
|
+
payload,
|
|
223
|
+
expiresAt: 0, // stamped when applied (we don't have the current tick here)
|
|
224
|
+
} )
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Push a refusal onto the queue drained by `applyRefusals` this same tick. */
|
|
228
|
+
queueRefusal(
|
|
229
|
+
instance: WillInstance, intentId: string, schema: string, reasonCode: string, finality: DenialFinality,
|
|
230
|
+
): void {
|
|
231
|
+
const queue = this._pendingRefusals.get( instance.config.id ) ?? []
|
|
232
|
+
queue.push({ intentId, schema, reasonCode, finality })
|
|
233
|
+
this._pendingRefusals.set( instance.config.id, queue )
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Drain queued refusals into failure acks (POLICY_REAFFERENCE P1). */
|
|
237
|
+
applyRefusals( instance: WillInstance ): void {
|
|
238
|
+
const queue = this._pendingRefusals.get( instance.config.id )
|
|
239
|
+
if( !queue || queue.length === 0 ) return
|
|
240
|
+
this._pendingRefusals.set( instance.config.id, [] )
|
|
241
|
+
|
|
242
|
+
for( const refusal of queue )
|
|
243
|
+
this._deps.confirmExecution( instance, refusal.intentId, {
|
|
244
|
+
success: false,
|
|
245
|
+
refused: true,
|
|
246
|
+
finality: refusal.finality,
|
|
247
|
+
...( refusal.counterfactual ? { counterfactual: refusal.counterfactual } : {} ),
|
|
248
|
+
description: `refused by policy: ${refusal.reasonCode} (${refusal.finality})`,
|
|
249
|
+
} )
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Reconstruct an enforceable Verdict from a recorded verdict (replay path). */
|
|
254
|
+
function recordToVerdict( record: PolicyVerdictRecord ): Verdict {
|
|
255
|
+
return {
|
|
256
|
+
decision: record.decision,
|
|
257
|
+
...( record.reasonCode ? { reasonCode: record.reasonCode } : {} ),
|
|
258
|
+
...( record.finality ? { finality: record.finality } : {} ),
|
|
259
|
+
...( record.counterfactual ? { counterfactual: record.counterfactual } : {} ),
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/stem/tracts/effector/types.ts — the efference crossing, shared shapes
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// SIGNAL_BOUNDARY P1a. `effector.controller.ts` had seven jobs; these are the
|
|
6
|
+
// shapes its three real seams pass between them:
|
|
7
|
+
//
|
|
8
|
+
// policy enforcement arbiter · verdicts · refusals
|
|
9
|
+
// escalation lifecycle hold · resolve · expire
|
|
10
|
+
// agency↔host buffer · drain · ack (the controller itself)
|
|
11
|
+
//
|
|
12
|
+
// The seams were verified before the cut, not assumed: no method touched both
|
|
13
|
+
// the policy fields (`_arbiter`, `_pendingRefusals`) and the escalation fields
|
|
14
|
+
// (`_newEscalations`, `_activeEscalations`, `_pendingResolutions`) except
|
|
15
|
+
// `_applyVerdict`, which is the ROUTER — deny goes one way, escalate the other.
|
|
16
|
+
// A router across a seam is the seam working, not a tangle.
|
|
17
|
+
|
|
18
|
+
import type { DenialFinality, PolicyCounterfactual } from '#stem/policy/arbiter'
|
|
19
|
+
import type { WillInstance } from '#stem/index'
|
|
20
|
+
|
|
21
|
+
/** A denial awaiting application as a refusal ack at the next tick boundary. */
|
|
22
|
+
export interface PendingRefusal {
|
|
23
|
+
intentId: string
|
|
24
|
+
schema: string
|
|
25
|
+
reasonCode: string
|
|
26
|
+
finality: DenialFinality
|
|
27
|
+
/** ENVELOPE_NARROWING P0 — what WOULD have been allowed, carried through to
|
|
28
|
+
* the outcome the mind learns from. Absent on refusals that have no bound to
|
|
29
|
+
* report (a flat ban, a fault, an unanswered escalation). */
|
|
30
|
+
counterfactual?: PolicyCounterfactual
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An escalation the Will has raised: the intent is held, the ask is voiced once,
|
|
34
|
+
* and the original payload is kept so an approval can dispatch it to the world. */
|
|
35
|
+
export interface Escalation {
|
|
36
|
+
intentId: string
|
|
37
|
+
schema: string
|
|
38
|
+
reasonCode: string
|
|
39
|
+
/** The withheld invocation payload — replayed to the host on approval. */
|
|
40
|
+
payload: Record<string, unknown>
|
|
41
|
+
expiresAt: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A host's answer to an escalation, applied at the next tick boundary. */
|
|
45
|
+
export interface PendingResolution {
|
|
46
|
+
intentId: string
|
|
47
|
+
approved: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What a host says came back. */
|
|
51
|
+
export interface EffectorAck {
|
|
52
|
+
success: boolean
|
|
53
|
+
/**
|
|
54
|
+
* How the act WENT. The fate of the attempt — landed, refused, failed — which
|
|
55
|
+
* is what the mind learns competence from. Not what the act revealed.
|
|
56
|
+
*/
|
|
57
|
+
description: string
|
|
58
|
+
/**
|
|
59
|
+
* What the act REVEALED: new information about the world, as distinct from how
|
|
60
|
+
* the act went (SIGNAL_BOUNDARY P2).
|
|
61
|
+
*
|
|
62
|
+
* Present ⇒ this ack is ALSO afference. It arrives as a reafferent percept
|
|
63
|
+
* carrying `sourceIntentId`, so the mind perceives it, remembers it and can
|
|
64
|
+
* recall it — rather than only learning from it. `lookup`, `list_warnings`,
|
|
65
|
+
* `snapshot` carry one; `kick`, `warn`, a refusal do not.
|
|
66
|
+
*
|
|
67
|
+
* WHY THE HOST DECIDES, AND WHY THIS IS NOT INFERRED FROM `description`.
|
|
68
|
+
* An ack is not *always* new world information, and nothing inside the mind
|
|
69
|
+
* can tell "the kick landed" from "there are 47 people here" — both are
|
|
70
|
+
* strings that came back from an act. Only the host knows which it wrote.
|
|
71
|
+
* Same contract as `provenance`: asserted at the boundary, never guessed.
|
|
72
|
+
*
|
|
73
|
+
* Forcing every ack through the sense door would be the over-unification this
|
|
74
|
+
* epoch warns about — a mind perceiving "the kick landed" as a fresh event in
|
|
75
|
+
* the world, every time it kicks.
|
|
76
|
+
*
|
|
77
|
+
* ANY SHAPE, AND CARRIED WHOLE. A host with a member record, a channel
|
|
78
|
+
* snapshot, a list of warnings sends the record — not a paragraph about it.
|
|
79
|
+
* Making a host flatten its own data to prose is a quieter kind of cutting:
|
|
80
|
+
* it destroys the structure rather than the tail. Nothing here is truncated
|
|
81
|
+
* at any point on the way in; what the host sent is what the mind gets.
|
|
82
|
+
*/
|
|
83
|
+
observation?: unknown
|
|
84
|
+
metrics?: Record<string, number>
|
|
85
|
+
/** POLICY_REAFFERENCE P2 — set when the ack is a policy refusal, so the
|
|
86
|
+
* ReafferenceEngine routes it to availability rather than competence. */
|
|
87
|
+
refused?: boolean
|
|
88
|
+
finality?: DenialFinality
|
|
89
|
+
/** ENVELOPE_NARROWING P0 — the bound that was exceeded, if the arbiter said. */
|
|
90
|
+
counterfactual?: PolicyCounterfactual
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The translation seam, as the other two see it.
|
|
95
|
+
*
|
|
96
|
+
* Both collaborators need to put an invocation on the wire, and policy also
|
|
97
|
+
* needs to reconcile a refusal as an ack. Injected as plain functions rather
|
|
98
|
+
* than a controller reference: it keeps the dependency one-way at the type
|
|
99
|
+
* level, and it is what lets the controller wire a cycle (policy raises an
|
|
100
|
+
* escalation, an escalation queues a refusal) without either class importing
|
|
101
|
+
* the other.
|
|
102
|
+
*/
|
|
103
|
+
export interface EffectorOps {
|
|
104
|
+
buffer( instance: WillInstance, payload: Record<string, unknown> ): void
|
|
105
|
+
confirmExecution( instance: WillInstance, invocationId: string, result: EffectorAck ): void
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** How long an escalated intent is held awaiting a resolution before it degrades
|
|
109
|
+
* to a refusal — 2× the host-ack timeout, so a human has real time to answer. */
|
|
110
|
+
export const ESCALATION_TTL_TICKS = 30
|