@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.
@@ -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
+ }