@mindot/will 0.6.0 → 0.7.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/dist/channels/discord.d.ts +1 -1
- package/dist/channels/whatsapp.d.ts +1 -1
- package/dist/cli.js +434 -11
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +434 -11
- package/dist/index.js.map +1 -1
- package/dist/mcp/effectors.d.ts +1 -1
- package/dist/{will-Bikuk4s2.d.ts → will-DAW0l-lY.d.ts} +42 -4
- package/package.json +1 -1
- package/src/cognition/agency/engines/action.selector.ts +41 -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 +30 -5
- package/src/cognition/agency/reconcile.learning.ts +9 -0
- package/src/cognition/agency/schemas/repertoire.ts +107 -7
- package/src/cognition/agency/selection.scoring.ts +7 -1
- package/src/cognition/agency/types.ts +9 -0
- package/src/stem/index.ts +15 -0
- package/src/stem/policy/arbiter.ts +136 -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 +379 -0
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
* Is this denial a property of the ACTION CLASS (never permitted under the
|
|
37
|
+
* active policy) or of THIS INSTANCE (wrong target, exceeded bound)?
|
|
38
|
+
*
|
|
39
|
+
* This is the distinction that makes a refusal learnable rather than a wall to
|
|
40
|
+
* re-probe forever, and it is the field we are proposing upstream to HELM. A
|
|
41
|
+
* 'class' denial should suppress the affordance; an 'instance' denial should
|
|
42
|
+
* only narrow the parameter envelope the Will reaches for.
|
|
43
|
+
*/
|
|
44
|
+
export type DenialFinality = 'class' | 'instance'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The nearest allowed envelope — what WOULD have been permitted. Structured so
|
|
48
|
+
* a learner can consume it; `field` names the constraint that bit.
|
|
49
|
+
*
|
|
50
|
+
* Every denial branch of a policy evaluator already computes this and usually
|
|
51
|
+
* discards it. We keep it.
|
|
52
|
+
*/
|
|
53
|
+
export interface PolicyCounterfactual {
|
|
54
|
+
/** The constrained field, e.g. 'ttl_days', 'target', 'amount'. */
|
|
55
|
+
field: string
|
|
56
|
+
/** What the Will asked for. */
|
|
57
|
+
requested?: unknown
|
|
58
|
+
/** The bound or permitted set, e.g. 30, or [ 'a', 'b' ]. */
|
|
59
|
+
allowed?: unknown
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A boundary decision about one proposed invocation. */
|
|
63
|
+
export interface Verdict {
|
|
64
|
+
decision: PolicyDecision
|
|
65
|
+
/** Stable machine-readable code, e.g. 'TARGET_NOT_ALLOWED'. Never prose. */
|
|
66
|
+
reasonCode?: string
|
|
67
|
+
/** Meaningful on 'deny' only. Absent ⇒ treat as 'instance' (the safe default:
|
|
68
|
+
* it narrows rather than suppresses, so a mis-tagged denial cannot silently
|
|
69
|
+
* delete an ability from the Will's reach). */
|
|
70
|
+
finality?: DenialFinality
|
|
71
|
+
counterfactual?: PolicyCounterfactual
|
|
72
|
+
/** Free-text for logs and host UX. NEVER parsed by cognition. */
|
|
73
|
+
detail?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The proposed effect, as the stem sees it. Mirrors the `agency.invocation`
|
|
78
|
+
* bus payload the MotorSchemaExecutor emits — no cognitive internals cross
|
|
79
|
+
* this boundary, only the act itself.
|
|
80
|
+
*/
|
|
81
|
+
export interface PolicyInvocation {
|
|
82
|
+
willId: string
|
|
83
|
+
/** The awaiting `agency.intent` id — the correlation handle, end to end. */
|
|
84
|
+
intentId: string
|
|
85
|
+
/** The motor schema id, i.e. the ability being enacted. */
|
|
86
|
+
schema: string
|
|
87
|
+
parameters: Record<string, unknown>
|
|
88
|
+
targetEntityId?: string
|
|
89
|
+
/** The ability's declared meaning, as given by the host at wiring time. */
|
|
90
|
+
description?: string
|
|
91
|
+
tick: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A Policy Decision Point. Implementations must be PURE with respect to the
|
|
96
|
+
* Will: an arbiter may read its own policy and the invocation, and nothing
|
|
97
|
+
* else. It must not reach into simulation state.
|
|
98
|
+
*
|
|
99
|
+
* DETERMINISM CONTRACT: an arbiter is an external oracle, exactly like the LLM.
|
|
100
|
+
* Its verdicts are recorded on the tape and replayed back — replay never
|
|
101
|
+
* re-consults an arbiter (see P1). Implementations therefore need not be
|
|
102
|
+
* deterministic themselves, but MUST be free of side effects on the Will.
|
|
103
|
+
*/
|
|
104
|
+
export interface PolicyArbiter {
|
|
105
|
+
/** Stable identifier, recorded alongside the verdict for audit. */
|
|
106
|
+
readonly name: string
|
|
107
|
+
evaluate( invocation: PolicyInvocation ): Verdict | Promise<Verdict>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The single allow verdict, frozen and shared — the null arbiter allocates nothing. */
|
|
111
|
+
const ALLOW: Readonly<Verdict> = Object.freeze({ decision: 'allow' as const })
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The default. Allows everything, allocates nothing, logs nothing.
|
|
115
|
+
*
|
|
116
|
+
* A Will running this must be byte-identical to one built before the policy
|
|
117
|
+
* seam existed — that property is asserted by test, and it is what lets this
|
|
118
|
+
* ship dark.
|
|
119
|
+
*/
|
|
120
|
+
export const NULL_ARBITER: PolicyArbiter = {
|
|
121
|
+
name: 'null',
|
|
122
|
+
evaluate(): Verdict { return ALLOW },
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True when the arbiter is the no-op default (used to skip the seam entirely). */
|
|
126
|
+
export function isNullArbiter( arbiter: PolicyArbiter | null | undefined ): boolean {
|
|
127
|
+
return !arbiter || arbiter === NULL_ARBITER
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Normalize a denial's finality. Absent ⇒ 'instance', the conservative reading:
|
|
132
|
+
* an unlabelled refusal narrows the envelope but never removes the ability.
|
|
133
|
+
*/
|
|
134
|
+
export function finalityOf( verdict: Verdict ): DenialFinality {
|
|
135
|
+
return verdict.finality === 'class' ? 'class' : 'instance'
|
|
136
|
+
}
|
|
@@ -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 'instance',
|
|
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 ?? 'instance',
|
|
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
|