@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
@@ -23,10 +23,103 @@
23
23
 
24
24
  import { logger } from '#core/logger'
25
25
  import { reconcileInvocation } from '#agency/reconcile.learning'
26
+ import { NULL_ARBITER, isNullArbiter } from '#stem/policy/arbiter'
27
+ import type { PolicyArbiter, PolicyInvocation, Verdict, DenialFinality, PolicyCounterfactual } from '#stem/policy/arbiter'
28
+ import { finalityOf, asFinality } from '#stem/policy/arbiter'
29
+ import {
30
+ getVerdictRecorder, getVerdictSource, type PolicyVerdictRecord,
31
+ } from '#stem/policy/verdict.recorder'
26
32
  import type { effectorInvocation } from '#types'
27
33
  import type { WillInstance } from '#stem/index'
28
34
 
35
+ /** A denial awaiting application as a refusal ack at the next tick boundary. */
36
+ interface PendingRefusal {
37
+ intentId: string
38
+ schema: string
39
+ reasonCode: string
40
+ finality: DenialFinality
41
+ /** ENVELOPE_NARROWING P0 — what WOULD have been allowed, carried through to
42
+ * the outcome the mind learns from. Absent on refusals that have no bound to
43
+ * report (a flat ban, a fault, an unanswered escalation). */
44
+ counterfactual?: PolicyCounterfactual
45
+ }
46
+
47
+ /**
48
+ * The verdict a fault produces (POLICY_REAFFERENCE P5, conformance S9).
49
+ *
50
+ * An arbiter that throws or rejects has always failed CLOSED — the effect is
51
+ * withheld — but it used to withhold *silently*, queueing no refusal. The held
52
+ * intent then expired at the executor's AWAIT_TIMEOUT and reconciled as a plain
53
+ * failure, landing on COMPETENCE: a PDP outage taught the mind it was unskilled
54
+ * at something it is perfectly capable of. So a fault now yields a real verdict:
55
+ *
56
+ * • 'deny' — still fail-closed, unchanged. The effect never reaches the world.
57
+ * • 'context' — but it teaches NOTHING. The arbiter being unreachable is not a
58
+ * fact about the ability, so nothing about the ability may move.
59
+ *
60
+ * It goes through `_recordAndApply` rather than straight to the refusal queue so
61
+ * the fault lands on the VERDICT TAPE too. That closes a replay hole: an
62
+ * unrecorded fault left the source with nothing to re-feed, and a source miss
63
+ * reproduces a buffered ALLOW — so a live run that withheld the effect would
64
+ * have replayed as one that dispatched it.
65
+ */
66
+ const ARBITER_FAULT_VERDICT: Readonly<Verdict> = Object.freeze({
67
+ decision: 'deny' as const,
68
+ reasonCode: 'ARBITER_UNAVAILABLE',
69
+ finality: 'context' as const,
70
+ })
71
+
72
+ /** How long an escalated intent is held awaiting a resolution before it degrades
73
+ * to a refusal — 2× the host-ack timeout, so a human has real time to answer. */
74
+ const ESCALATION_TTL_TICKS = 30
75
+
76
+ /** An escalation the Will has raised: the intent is held, the ask is voiced once,
77
+ * and the original payload is kept so an approval can dispatch it to the world. */
78
+ interface Escalation {
79
+ intentId: string
80
+ schema: string
81
+ reasonCode: string
82
+ /** The withheld invocation payload — replayed to the host on approval. */
83
+ payload: Record<string, unknown>
84
+ expiresAt: number
85
+ }
86
+
87
+ /** A host's answer to an escalation, applied at the next tick boundary. */
88
+ interface PendingResolution {
89
+ intentId: string
90
+ approved: boolean
91
+ }
92
+
29
93
  export class effectorController {
94
+ /** The Policy Decision Point consulted before an invocation reaches the world.
95
+ * Defaults to the no-op arbiter, so an unconfigured Will is byte-identical. */
96
+ private _arbiter: PolicyArbiter = NULL_ARBITER
97
+
98
+ /**
99
+ * Denials queued during a step's flush, drained at the NEXT tick boundary
100
+ * (POLICY_REAFFERENCE P1). Keyed by willId — harness state, exactly like
101
+ * `pendingEffectorInvocations`; never simulation state, so it does not touch
102
+ * `simulation.step` determinism and is regenerated on any re-execution.
103
+ */
104
+ private _pendingRefusals = new Map<string, PendingRefusal[]>()
105
+
106
+ /** Escalations awaiting their first application (mark intent + voice the ask). */
107
+ private _newEscalations = new Map<string, Escalation[]>()
108
+ /** Escalations currently held, keyed by intent id — the resolvable set. */
109
+ private _activeEscalations = new Map<string, Map<string, Escalation>>()
110
+ /** Host answers awaiting application at the next tick boundary. */
111
+ private _pendingResolutions = new Map<string, PendingResolution[]>()
112
+
113
+ /**
114
+ * Install a Policy Decision Point (POLICY_REAFFERENCE P0). Passing null
115
+ * restores the no-op default. The arbiter sees only the proposed act — never
116
+ * simulation state — and its verdict decides whether the invocation is
117
+ * handed to the host at all.
118
+ */
119
+ setArbiter( arbiter: PolicyArbiter | null ): void {
120
+ this._arbiter = arbiter ?? NULL_ARBITER
121
+ }
122
+
30
123
  /**
31
124
  * Update the set of allowed communication effectors at runtime via AccessGrants
32
125
  * (the permission / sense gate the senses + reply path read).
@@ -43,6 +136,288 @@ export class effectorController {
43
136
  * echoes it on its result-ack, and `confirmExecution` uses it to find the intent.
44
137
  */
45
138
  bufferInvocation( instance: WillInstance, payload: Record<string, unknown> ): void {
139
+ const willId = instance.config.id
140
+
141
+ // Replay: a registered source re-feeds the recorded verdict instead of
142
+ // re-consulting a live (or absent) PDP — the arbiter is an external oracle,
143
+ // exactly like the LLM. Checked FIRST so replay never re-enters the arbiter.
144
+ const source = getVerdictSource( willId )
145
+ if( source ){
146
+ const invocation = toPolicyInvocation( instance, payload )
147
+ const record = source.verdictFor( invocation.tick, invocation.intentId )
148
+ // A miss means the live run had no verdict here (null arbiter at record
149
+ // time) — the invocation was simply buffered, so reproduce that.
150
+ if( record ) this._applyVerdict( instance, payload, invocation, recordToVerdict( record ) )
151
+ else this._buffer( instance, payload )
152
+ return
153
+ }
154
+
155
+ // Fast path: no policy configured ⇒ the seam does not exist. No allocation,
156
+ // no branch beyond this one — the byte-identical guarantee.
157
+ if( isNullArbiter( this._arbiter ) ){
158
+ this._buffer( instance, payload )
159
+ return
160
+ }
161
+
162
+ const invocation = toPolicyInvocation( instance, payload )
163
+ let verdict: Verdict | Promise<Verdict>
164
+
165
+ // An arbiter that throws must never become an implicit allow.
166
+ try { verdict = this._arbiter.evaluate( invocation ) }
167
+ catch( err ){
168
+ logger.error(`[policy] arbiter "${this._arbiter.name}" threw for "${invocation.schema}" — failing closed:`, err )
169
+ this._recordAndApply( instance, payload, invocation, ARBITER_FAULT_VERDICT )
170
+ return
171
+ }
172
+
173
+ if( verdict instanceof Promise ){
174
+ // An external PDP resolves out of tick. Safe by construction: the executor
175
+ // holds the intent 'awaiting' for AWAIT_TIMEOUT (15 ticks), and the refusal
176
+ // queue drains each tick, so a verdict landing a few ticks late still lands.
177
+ void verdict.then(
178
+ v => this._recordAndApply( instance, payload, invocation, v ),
179
+ err => {
180
+ logger.error(`[policy] arbiter "${this._arbiter.name}" rejected for "${invocation.schema}" — failing closed:`, err )
181
+ this._recordAndApply( instance, payload, invocation, ARBITER_FAULT_VERDICT )
182
+ },
183
+ )
184
+ return
185
+ }
186
+
187
+ this._recordAndApply( instance, payload, invocation, verdict )
188
+ }
189
+
190
+ /** Capture the verdict on the tape (if a recorder is attached), then enforce it. */
191
+ private _recordAndApply(
192
+ instance: WillInstance,
193
+ payload: Record<string, unknown>,
194
+ invocation: PolicyInvocation,
195
+ verdict: Verdict,
196
+ ): void {
197
+ const sink = getVerdictRecorder( instance.config.id )
198
+ sink?.recordVerdict({
199
+ tick: invocation.tick,
200
+ willId: instance.config.id,
201
+ intentId: invocation.intentId,
202
+ schema: invocation.schema,
203
+ arbiter: this._arbiter.name,
204
+ decision: verdict.decision,
205
+ ...( verdict.reasonCode ? { reasonCode: verdict.reasonCode } : {} ),
206
+ ...( verdict.finality ? { finality: verdict.finality } : {} ),
207
+ ...( verdict.counterfactual ? { counterfactual: verdict.counterfactual } : {} ),
208
+ timestamp: Date.now(),
209
+ })
210
+ this._applyVerdict( instance, payload, invocation, verdict )
211
+ }
212
+
213
+ /**
214
+ * Enforce a verdict (POLICY_REAFFERENCE P1).
215
+ *
216
+ * • allow → hand the invocation to the world.
217
+ * • deny → queue a refusal ack, applied at the next tick boundary via
218
+ * `confirmExecution` — the same lifecycle as a host rejection,
219
+ * so the mind meets *world resistance*, not a permission dialog.
220
+ * • escalate → raise a held escalation (POLICY_REAFFERENCE P4): the intent is
221
+ * held (the executor stops timing it out), the Will voices a
222
+ * first-person ask once, and a host resolution later approves
223
+ * (dispatch) or denies (refuse). Unresolved, it degrades to a
224
+ * refusal at ESCALATION_TTL_TICKS.
225
+ *
226
+ * P1's refusal reconciles as a plain FAILURE — safe, but the wrong learning
227
+ * signal (forbidden ≠ unskilled). P2 routes it to affordance AVAILABILITY
228
+ * instead of competence.
229
+ */
230
+ private _applyVerdict(
231
+ instance: WillInstance,
232
+ payload: Record<string, unknown>,
233
+ invocation: PolicyInvocation,
234
+ verdict: Verdict,
235
+ ): void {
236
+ if( verdict.decision === 'allow'){
237
+ this._buffer( instance, payload )
238
+ return
239
+ }
240
+
241
+ const cf = verdict.counterfactual
242
+ logger.info(
243
+ `[policy] ${verdict.decision.toUpperCase()} "${invocation.schema}" intent "${invocation.intentId}"` +
244
+ ` — ${verdict.reasonCode ?? 'no reason code'}` +
245
+ ( verdict.finality ? ` (${verdict.finality})` : '') +
246
+ ( cf ? ` [${cf.field}: requested ${JSON.stringify( cf.requested )}, allowed ${JSON.stringify( cf.allowed )}]` : ''),
247
+ )
248
+
249
+ if( verdict.decision === 'deny'){
250
+ const queue = this._pendingRefusals.get( instance.config.id ) ?? []
251
+ queue.push({
252
+ intentId: invocation.intentId,
253
+ schema: invocation.schema,
254
+ reasonCode: verdict.reasonCode ?? 'POLICY_DENIED',
255
+ finality: finalityOf( verdict ),
256
+ ...( verdict.counterfactual ? { counterfactual: verdict.counterfactual } : {} ),
257
+ })
258
+ this._pendingRefusals.set( instance.config.id, queue )
259
+ return
260
+ }
261
+
262
+ // 'escalate' — raise a held escalation, applied (marked + voiced) at the boundary.
263
+ const escalations = this._newEscalations.get( instance.config.id ) ?? []
264
+ escalations.push({
265
+ intentId: invocation.intentId,
266
+ schema: invocation.schema,
267
+ reasonCode: verdict.reasonCode ?? 'APPROVAL_REQUIRED',
268
+ payload,
269
+ expiresAt: 0, // stamped when applied (we don't have the current tick here)
270
+ })
271
+ this._newEscalations.set( instance.config.id, escalations )
272
+ }
273
+
274
+ /**
275
+ * Record a host's answer to an escalation (POLICY_REAFFERENCE P4). Applied at
276
+ * the next tick boundary so every simulation-state write stays on the boundary:
277
+ * approve dispatches the held invocation to the world; deny refuses it. A
278
+ * no-op if the intent id is not (or no longer) an active escalation.
279
+ */
280
+ resolveEscalation( instance: WillInstance, intentId: string, approved: boolean ): void {
281
+ const queue = this._pendingResolutions.get( instance.config.id ) ?? []
282
+ queue.push({ intentId, approved })
283
+ this._pendingResolutions.set( instance.config.id, queue )
284
+ }
285
+
286
+ /**
287
+ * Apply queued policy refusals as failure acks (POLICY_REAFFERENCE P1).
288
+ * Called by the tick loop at the same boundary as inbound acks — BEFORE the
289
+ * step, stamped to this tick — so a denial reconciled here is the exact
290
+ * lifecycle of a host rejection that arrived between ticks.
291
+ */
292
+ applyPolicyOutcomes( instance: WillInstance ): void {
293
+ const tick = instance.tickCount
294
+ this._applyResolutions( instance ) // host answers land first
295
+ this._expireEscalations( instance, tick ) // then time out the unanswered
296
+ this._applyNewEscalations( instance, tick ) // then raise + voice the newest
297
+ this._applyRefusals( instance ) // then the plain denials
298
+ }
299
+
300
+ /** Drain queued refusals into failure acks (POLICY_REAFFERENCE P1). */
301
+ private _applyRefusals( instance: WillInstance ): void {
302
+ const queue = this._pendingRefusals.get( instance.config.id )
303
+ if( !queue || queue.length === 0 ) return
304
+ this._pendingRefusals.set( instance.config.id, [] )
305
+
306
+ for( const refusal of queue )
307
+ this.confirmExecution( instance, refusal.intentId, {
308
+ success: false,
309
+ refused: true,
310
+ finality: refusal.finality,
311
+ ...( refusal.counterfactual ? { counterfactual: refusal.counterfactual } : {} ),
312
+ description: `refused by policy: ${refusal.reasonCode} (${refusal.finality})`,
313
+ } )
314
+ }
315
+
316
+ /** Raise each newly-escalated intent (POLICY_REAFFERENCE P4): mark it held in
317
+ * simulation state, voice the ask ONCE, and move it to the resolvable set. */
318
+ private _applyNewEscalations( instance: WillInstance, tick: number ): void {
319
+ const pending = this._newEscalations.get( instance.config.id )
320
+ if( !pending || pending.length === 0 ) return
321
+ this._newEscalations.set( instance.config.id, [] )
322
+
323
+ const active = this._activeEscalations.get( instance.config.id ) ?? new Map<string, Escalation>()
324
+ for( const esc of pending ){
325
+ esc.expiresAt = tick + ESCALATION_TTL_TICKS
326
+ this._markEscalated( instance, esc.intentId, esc.expiresAt )
327
+ this._voiceEscalation( instance, esc )
328
+ active.set( esc.intentId, esc )
329
+ }
330
+ this._activeEscalations.set( instance.config.id, active )
331
+ }
332
+
333
+ /** Apply host answers to active escalations (POLICY_REAFFERENCE P4). */
334
+ private _applyResolutions( instance: WillInstance ): void {
335
+ const queue = this._pendingResolutions.get( instance.config.id )
336
+ if( !queue || queue.length === 0 ) return
337
+ this._pendingResolutions.set( instance.config.id, [] )
338
+
339
+ const active = this._activeEscalations.get( instance.config.id )
340
+ for( const { intentId, approved } of queue ){
341
+ const esc = active?.get( intentId )
342
+ if( !esc ) continue // unknown / already resolved — ignore
343
+ active!.delete( intentId )
344
+ this._clearEscalated( instance, intentId ) // release the executor's hold
345
+ if( approved ){
346
+ this._buffer( instance, esc.payload ) // dispatch the held invocation now
347
+ logger.info(`[policy] escalation APPROVED → dispatching "${esc.schema}" intent "${intentId}"`)
348
+ }
349
+ else {
350
+ this._queueRefusal( instance, esc.intentId, esc.schema, esc.reasonCode, 'class')
351
+ logger.info(`[policy] escalation DENIED → refusing "${esc.schema}" intent "${intentId}"`)
352
+ }
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Degrade escalations no one answered in time into light refusals (P4).
358
+ *
359
+ * Finality 'parameter' is chosen for its BEHAVIOUR, not its name: silence is
360
+ * not literally an argument problem, but the light-dent-with-recovery it
361
+ * produces is exactly right — a Will whose asks go unanswered should ask
362
+ * progressively less, and should resume asking if someone starts answering.
363
+ * 'class' would be a lie (nobody said never) and 'context' would teach
364
+ * nothing, leaving the mind to escalate forever into an empty room.
365
+ */
366
+ private _expireEscalations( instance: WillInstance, tick: number ): void {
367
+ const active = this._activeEscalations.get( instance.config.id )
368
+ if( !active || active.size === 0 ) return
369
+ for( const [ intentId, esc ] of active ){
370
+ if( tick < esc.expiresAt ) continue
371
+ active.delete( intentId )
372
+ this._clearEscalated( instance, intentId )
373
+ this._queueRefusal( instance, esc.intentId, esc.schema, 'ESCALATION_EXPIRED', 'parameter')
374
+ logger.info(`[policy] escalation EXPIRED → refusing "${esc.schema}" intent "${intentId}"`)
375
+ }
376
+ }
377
+
378
+ /** Push a refusal onto the queue drained by _applyRefusals this same tick. */
379
+ private _queueRefusal(
380
+ instance: WillInstance, intentId: string, schema: string, reasonCode: string, finality: DenialFinality,
381
+ ): void {
382
+ const queue = this._pendingRefusals.get( instance.config.id ) ?? []
383
+ queue.push({ intentId, schema, reasonCode, finality })
384
+ this._pendingRefusals.set( instance.config.id, queue )
385
+ }
386
+
387
+ /** Mark the awaiting intent held: the executor stops timing it out (P4). */
388
+ private _markEscalated( instance: WillInstance, intentId: string, expiresAt: number ): void {
389
+ const intent = instance.simulation.stateManager.snapshot().entities.get( intentId )
390
+ if( !intent || intent.type !== 'agency.intent') return
391
+ instance.simulation.stateManager.setEntity({
392
+ id: intent.id,
393
+ type: intent.type,
394
+ metadata: { ...( intent.metadata ?? {} ), escalated: true, escalationExpiresAt: expiresAt },
395
+ })
396
+ }
397
+
398
+ /** Release the hold so the executor resumes normal timeout for this intent. */
399
+ private _clearEscalated( instance: WillInstance, intentId: string ): void {
400
+ const intent = instance.simulation.stateManager.snapshot().entities.get( intentId )
401
+ if( !intent || intent.type !== 'agency.intent') return
402
+ const meta = { ...( intent.metadata ?? {} ) } as Record<string, unknown>
403
+ delete meta['escalated']; delete meta['escalationExpiresAt']
404
+ instance.simulation.stateManager.setEntity({ id: intent.id, type: intent.type, metadata: meta })
405
+ }
406
+
407
+ /** Voice the escalation as a first-person broadcast ask — once, at raise time. */
408
+ private _voiceEscalation( instance: WillInstance, esc: Escalation ): void {
409
+ try {
410
+ instance.cognition.outboxWriter.enqueue({
411
+ targetEntityId: '*',
412
+ content: escalationAsk( esc.schema, esc.reasonCode ),
413
+ effectorName: 'broadcast',
414
+ })
415
+ }
416
+ catch( err ){ logger.warn(`[policy] escalation voice failed for "${esc.schema}": ${errMsg( err )}`) }
417
+ }
418
+
419
+ /** Queue an approved invocation for the delivery layer. */
420
+ private _buffer( instance: WillInstance, payload: Record<string, unknown> ): void {
46
421
  const intentId = ( payload.intentId as string ) ?? ''
47
422
  instance.pendingEffectorInvocations.push({
48
423
  id: intentId,
@@ -86,6 +461,12 @@ export class effectorController {
86
461
  success: boolean
87
462
  description: string
88
463
  metrics?: Record<string, number>
464
+ /** POLICY_REAFFERENCE P2 — set when the ack is a policy refusal, so the
465
+ * ReafferenceEngine routes it to availability rather than competence. */
466
+ refused?: boolean
467
+ finality?: DenialFinality
468
+ /** ENVELOPE_NARROWING P0 — the bound that was exceeded, if the arbiter said. */
469
+ counterfactual?: PolicyCounterfactual
89
470
  },
90
471
  ): void {
91
472
  const tick = instance.tickCount
@@ -142,3 +523,48 @@ export class effectorController {
142
523
  function num( v: unknown, fallback: number ): number {
143
524
  return typeof v === 'number' && Number.isFinite( v ) ? v : fallback
144
525
  }
526
+
527
+ /** First-person ask for an escalated action, carrying the reason's MEANING (P4).
528
+ * Kept template-simple here; the facet-authored version is a later refinement. */
529
+ function escalationAsk( schema: string, reasonCode: string ): string {
530
+ const meaning = ESCALATION_MEANINGS[ reasonCode ] ?? 'I need your approval before I can do this'
531
+ return `I want to ${ schema }, but ${ meaning }. May I go ahead?`
532
+ }
533
+
534
+ /** reasonCode → human meaning. Unknown codes fall back to a generic phrase. */
535
+ const ESCALATION_MEANINGS: Record<string, string> = {
536
+ APPROVAL_REQUIRED: 'I need your approval before I can on my own',
537
+ WRITE_REQUIRES_APPROVAL: "it writes to the world and I shouldn't on my own",
538
+ PAYMENT_REQUIRES_APPROVAL: 'it moves money and I must not do that unattended',
539
+ DEPLOY_REQUIRES_APPROVAL: 'it ships something and needs a human to sign off',
540
+ }
541
+
542
+ function errMsg( err: unknown ): string {
543
+ return err instanceof Error ? err.message : String( err )
544
+ }
545
+
546
+ /** Reconstruct an enforceable Verdict from a recorded verdict (replay path). */
547
+ function recordToVerdict( record: PolicyVerdictRecord ): Verdict {
548
+ return {
549
+ decision: record.decision,
550
+ ...( record.reasonCode ? { reasonCode: record.reasonCode } : {} ),
551
+ ...( record.finality ? { finality: record.finality } : {} ),
552
+ ...( record.counterfactual ? { counterfactual: record.counterfactual } : {} ),
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Project the `agency.invocation` payload onto the policy boundary's view of a
558
+ * proposed act. Only the act crosses — no cognitive internals, no state handle.
559
+ */
560
+ function toPolicyInvocation( instance: WillInstance, payload: Record<string, unknown> ): PolicyInvocation {
561
+ return {
562
+ willId: instance.config.id,
563
+ intentId: ( payload.intentId as string ) ?? '',
564
+ schema: ( payload.schema as string ) ?? '',
565
+ parameters: ( payload.parameters as Record<string, unknown> ) ?? {},
566
+ ...( typeof payload.targetEntityId === 'string' ? { targetEntityId: payload.targetEntityId } : {} ),
567
+ ...( typeof payload.description === 'string' ? { description: payload.description } : {} ),
568
+ tick: ( payload.tick as number ) ?? 0,
569
+ }
570
+ }