@zhin.js/harness 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +8 -2
- package/lib/authority.d.ts +37 -0
- package/lib/authority.js +161 -0
- package/lib/budget.d.ts +48 -0
- package/lib/budget.js +154 -0
- package/lib/contracts.d.ts +643 -0
- package/lib/contracts.js +86 -0
- package/lib/guard.d.ts +25 -0
- package/lib/guard.js +213 -0
- package/lib/harness.d.ts +197 -0
- package/lib/harness.js +731 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +8 -0
- package/lib/journal.d.ts +86 -0
- package/lib/journal.js +712 -0
- package/lib/mailbox.d.ts +6 -0
- package/lib/mailbox.js +422 -0
- package/lib/policies/admission.d.ts +48 -0
- package/lib/policies/admission.js +118 -0
- package/lib/policies/groups.d.ts +47 -0
- package/lib/policies/groups.js +91 -0
- package/lib/policies/index.d.ts +5 -0
- package/lib/policies/index.js +5 -0
- package/lib/policies/live.d.ts +28 -0
- package/lib/policies/live.js +44 -0
- package/lib/policies/markers.d.ts +23 -0
- package/lib/policies/markers.js +42 -0
- package/lib/policies/merge.d.ts +49 -0
- package/lib/policies/merge.js +101 -0
- package/package.json +39 -3
- package/src/authority.ts +194 -0
- package/src/budget.ts +181 -0
- package/src/contracts.ts +762 -0
- package/src/guard.ts +305 -0
- package/src/harness.ts +975 -0
- package/src/index.ts +8 -0
- package/src/journal.ts +833 -0
- package/src/mailbox.ts +467 -0
- package/src/policies/admission.ts +164 -0
- package/src/policies/groups.ts +137 -0
- package/src/policies/index.ts +5 -0
- package/src/policies/live.ts +68 -0
- package/src/policies/markers.ts +58 -0
- package/src/policies/merge.ts +121 -0
package/src/guard.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR 0048 §3.4 Guard Kernel — mail admission (H1).
|
|
3
|
+
*
|
|
4
|
+
* `admitMail` is pure: it reads the mail, the engagement graph and the
|
|
5
|
+
* context, and returns the first denial in a fixed order (or allow). It never
|
|
6
|
+
* looks at prompt text except to normalize content for duplicate detection.
|
|
7
|
+
*
|
|
8
|
+
* Order (first failure wins):
|
|
9
|
+
* 1. engagement known → `unknown_engagement`
|
|
10
|
+
* 2. causation (invariant 3) → `missing_causation`
|
|
11
|
+
* 3. authority (invariant 1) → `authority_not_attenuated` / `missing_causation` (forged result / signal)
|
|
12
|
+
* 4. contacts and acceptance → `contact_not_allowed` / `not_accepted_by_target`
|
|
13
|
+
* 5. depth (invariant 5) → `depth_exceeded` (escalates)
|
|
14
|
+
* 6. fanout → `fanout_exceeded` (escalates)
|
|
15
|
+
* 7. pair round trips → `pair_round_trips_exceeded` (escalates)
|
|
16
|
+
* 8. duplicates → `duplicate_mail` (escalates)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
scopeHas,
|
|
21
|
+
type ActorId,
|
|
22
|
+
type Authority,
|
|
23
|
+
type Engagement,
|
|
24
|
+
type EngagementGraphView,
|
|
25
|
+
type GuardContext,
|
|
26
|
+
type GuardDecision,
|
|
27
|
+
type GuardDenyCode,
|
|
28
|
+
type GuardKernel,
|
|
29
|
+
type Mail,
|
|
30
|
+
type MailId,
|
|
31
|
+
type MailSender,
|
|
32
|
+
} from './contracts.js';
|
|
33
|
+
|
|
34
|
+
export interface GuardKernelOptions {
|
|
35
|
+
/** `true` when `child` grants nothing beyond `parent` (supplied by the authority module). */
|
|
36
|
+
isAttenuationOf(child: Authority, parent: Authority): boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ALLOW: GuardDecision = Object.freeze({ allow: true });
|
|
40
|
+
|
|
41
|
+
function deny(code: GuardDenyCode, reason: string, escalate = false): GuardDecision {
|
|
42
|
+
return Object.freeze({ allow: false, code, reason, escalate });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Builds a read-only, id-indexed view over an engagement's admitted mails. */
|
|
46
|
+
export function createEngagementGraph(engagement: Engagement, mails: readonly Mail[]): EngagementGraphView {
|
|
47
|
+
const list = Object.freeze([...mails]);
|
|
48
|
+
const byId = new Map<MailId, Mail>();
|
|
49
|
+
for (const mail of list) {
|
|
50
|
+
if (!byId.has(mail.id)) byId.set(mail.id, mail);
|
|
51
|
+
}
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
engagement,
|
|
54
|
+
mails: list,
|
|
55
|
+
get: (id: MailId) => byId.get(id),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Agent definition name of an actor id (`<agent>` or `<agent>#<instance>`). */
|
|
60
|
+
function agentOf(actor: ActorId): string {
|
|
61
|
+
const hash = actor.indexOf('#');
|
|
62
|
+
return hash === -1 ? actor : actor.slice(0, hash);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function describeSender(from: MailSender): string {
|
|
66
|
+
switch (from.kind) {
|
|
67
|
+
case 'actor':
|
|
68
|
+
return `actor ${from.actor}`;
|
|
69
|
+
case 'human':
|
|
70
|
+
return `human ${from.principal.id}`;
|
|
71
|
+
case 'system':
|
|
72
|
+
return `system ${from.source}`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function senderKey(from: MailSender): string {
|
|
77
|
+
switch (from.kind) {
|
|
78
|
+
case 'actor':
|
|
79
|
+
return `actor:${from.actor}`;
|
|
80
|
+
case 'human':
|
|
81
|
+
return `human:${from.principal.id}`;
|
|
82
|
+
case 'system':
|
|
83
|
+
return `system:${from.source}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isAskOrDelegate(mail: Mail): boolean {
|
|
88
|
+
return mail.kind === 'ask' || mail.kind === 'delegate';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isHop(mail: Mail): boolean {
|
|
92
|
+
return mail.from.kind === 'actor' && isAskOrDelegate(mail);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalize(text: string): string {
|
|
96
|
+
return text.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Text compared for duplicate detection; `undefined` for kinds that are not checked. */
|
|
100
|
+
function contentOf(mail: Mail): string | undefined {
|
|
101
|
+
switch (mail.body.kind) {
|
|
102
|
+
case 'ask':
|
|
103
|
+
return normalize(mail.body.question);
|
|
104
|
+
case 'delegate':
|
|
105
|
+
return normalize(mail.body.brief);
|
|
106
|
+
case 'tell':
|
|
107
|
+
return normalize(mail.body.content);
|
|
108
|
+
default:
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Number of actor-sent ask/delegate hops from `mail` back to the root, including `mail`. */
|
|
114
|
+
function depthOf(mail: Mail, graph: EngagementGraphView): number {
|
|
115
|
+
let depth = 0;
|
|
116
|
+
const seen = new Set<MailId>();
|
|
117
|
+
let current: Mail | undefined = mail;
|
|
118
|
+
while (current && !seen.has(current.id)) {
|
|
119
|
+
seen.add(current.id);
|
|
120
|
+
if (isHop(current)) depth += 1;
|
|
121
|
+
current = current.causationId === undefined ? undefined : graph.get(current.causationId);
|
|
122
|
+
}
|
|
123
|
+
return depth;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function checkResult(mail: Mail, sender: ActorId, graph: EngagementGraphView): GuardDecision | undefined {
|
|
127
|
+
if (mail.body.kind !== 'result') return undefined;
|
|
128
|
+
const original = graph.get(mail.body.inReplyTo);
|
|
129
|
+
if (!original) {
|
|
130
|
+
return deny('missing_causation', `Result ${mail.id} from actor ${sender} replies to unknown mail ${mail.body.inReplyTo}`);
|
|
131
|
+
}
|
|
132
|
+
if (!isAskOrDelegate(original)) {
|
|
133
|
+
return deny(
|
|
134
|
+
'missing_causation',
|
|
135
|
+
`Result ${mail.id} from actor ${sender} replies to ${original.kind} mail ${original.id}, not an ask or delegate`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (original.to !== sender) {
|
|
139
|
+
return deny(
|
|
140
|
+
'missing_causation',
|
|
141
|
+
`Result ${mail.id} from actor ${sender} replies to mail ${original.id} that was addressed to ${original.to}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const rootActor = graph.get(graph.engagement.rootMailId)?.to;
|
|
145
|
+
const sentByTarget =
|
|
146
|
+
original.from.kind === 'actor' ? original.from.actor === mail.to : mail.to === rootActor;
|
|
147
|
+
if (!sentByTarget) {
|
|
148
|
+
return deny(
|
|
149
|
+
'missing_causation',
|
|
150
|
+
`Result ${mail.id} from actor ${sender} is addressed to ${mail.to}, but mail ${original.id} was sent by ${describeSender(original.from)}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createGuardKernel(options: GuardKernelOptions): GuardKernel {
|
|
157
|
+
const { isAttenuationOf } = options;
|
|
158
|
+
|
|
159
|
+
function admitMail(mail: Mail, ctx: GuardContext): GuardDecision {
|
|
160
|
+
const { graph, limits, targetCeiling, senderAuthority } = ctx;
|
|
161
|
+
const who = describeSender(mail.from);
|
|
162
|
+
|
|
163
|
+
// 1. Engagement
|
|
164
|
+
if (!graph) {
|
|
165
|
+
return deny('unknown_engagement', `Mail ${mail.id} from ${who} names unknown engagement ${mail.engagementId}`);
|
|
166
|
+
}
|
|
167
|
+
if (mail.engagementId !== graph.engagement.id) {
|
|
168
|
+
return deny(
|
|
169
|
+
'unknown_engagement',
|
|
170
|
+
`Mail ${mail.id} from ${who} names engagement ${mail.engagementId}, but was routed into ${graph.engagement.id}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 2. Causation (invariant 3)
|
|
175
|
+
if (mail.causationId === undefined) {
|
|
176
|
+
if (mail.id !== graph.engagement.rootMailId) {
|
|
177
|
+
return deny(
|
|
178
|
+
'missing_causation',
|
|
179
|
+
`Mail ${mail.id} from ${who} to ${mail.to} has no causation and is not the root mail of engagement ${graph.engagement.id}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
} else if (!graph.get(mail.causationId)) {
|
|
183
|
+
return deny(
|
|
184
|
+
'missing_causation',
|
|
185
|
+
`Mail ${mail.id} from ${who} to ${mail.to} cites unknown causation ${mail.causationId}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 3. Authority (invariant 1); results and signals are checked structurally instead
|
|
190
|
+
if (mail.from.kind === 'actor') {
|
|
191
|
+
const sender = mail.from.actor;
|
|
192
|
+
if (mail.body.kind === 'result') {
|
|
193
|
+
const forged = checkResult(mail, sender, graph);
|
|
194
|
+
if (forged) return forged;
|
|
195
|
+
} else if (mail.body.kind === 'signal') {
|
|
196
|
+
if (!graph.get(mail.body.target)) {
|
|
197
|
+
return deny(
|
|
198
|
+
'missing_causation',
|
|
199
|
+
`Signal ${mail.id} from actor ${sender} targets unknown mail ${mail.body.target}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
} else if (
|
|
203
|
+
!senderAuthority
|
|
204
|
+
|| !isAttenuationOf(mail.authority, senderAuthority)
|
|
205
|
+
|| mail.authority.chain.at(-1) !== sender
|
|
206
|
+
) {
|
|
207
|
+
return deny(
|
|
208
|
+
'authority_not_attenuated',
|
|
209
|
+
senderAuthority
|
|
210
|
+
? `Mail ${mail.id} from actor ${sender} to ${mail.to} carries authority wider than the sender's own`
|
|
211
|
+
: `Mail ${mail.id} from actor ${sender} to ${mail.to} has no sender authority to attenuate from`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 4. Contacts and target acceptance
|
|
217
|
+
const targetAgent = agentOf(mail.to);
|
|
218
|
+
const contactKind = mail.kind === 'ask' || mail.kind === 'delegate' || mail.kind === 'tell';
|
|
219
|
+
if (mail.from.kind === 'actor' && contactKind) {
|
|
220
|
+
// senderAuthority is present here: step 3 denied otherwise.
|
|
221
|
+
if (!senderAuthority || !scopeHas(senderAuthority.contacts, targetAgent)) {
|
|
222
|
+
return deny(
|
|
223
|
+
'contact_not_allowed',
|
|
224
|
+
`Actor ${mail.from.actor} may not contact agent ${targetAgent} (not in its contacts)`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const fromAgent = agentOf(mail.from.actor);
|
|
228
|
+
if (!scopeHas(targetCeiling.acceptsFrom, fromAgent)) {
|
|
229
|
+
return deny(
|
|
230
|
+
'not_accepted_by_target',
|
|
231
|
+
`Agent ${targetAgent} does not accept ${mail.kind} mail from agent ${fromAgent}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
} else if (mail.from.kind === 'human' && mail.kind === 'human') {
|
|
235
|
+
if (!scopeHas(targetCeiling.acceptsFrom, 'human')) {
|
|
236
|
+
return deny(
|
|
237
|
+
'not_accepted_by_target',
|
|
238
|
+
`Agent ${targetAgent} does not accept mail from humans (sender ${mail.from.principal.id})`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 5–7 apply to ask / delegate only
|
|
244
|
+
if (isAskOrDelegate(mail)) {
|
|
245
|
+
// 5. Depth (invariant 5)
|
|
246
|
+
const depth = depthOf(mail, graph);
|
|
247
|
+
if (depth > limits.maxDepth) {
|
|
248
|
+
return deny(
|
|
249
|
+
'depth_exceeded',
|
|
250
|
+
`${mail.kind} from ${who} to ${mail.to} would reach delegation depth ${depth}, above the limit of ${limits.maxDepth}`,
|
|
251
|
+
true,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 6. Fanout
|
|
256
|
+
const fanout = graph.mails.filter(isAskOrDelegate).length + 1;
|
|
257
|
+
if (fanout > limits.maxFanout) {
|
|
258
|
+
return deny(
|
|
259
|
+
'fanout_exceeded',
|
|
260
|
+
`${mail.kind} from ${who} to ${mail.to} would be ask/delegate #${fanout} in engagement ${graph.engagement.id}, above the limit of ${limits.maxFanout}`,
|
|
261
|
+
true,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 7. Pair round trips
|
|
266
|
+
if (mail.from.kind === 'actor') {
|
|
267
|
+
const a = mail.from.actor;
|
|
268
|
+
const b = mail.to;
|
|
269
|
+
const exchanged = graph.mails.filter(
|
|
270
|
+
(m) =>
|
|
271
|
+
isAskOrDelegate(m) &&
|
|
272
|
+
m.from.kind === 'actor' &&
|
|
273
|
+
((m.from.actor === a && m.to === b) || (m.from.actor === b && m.to === a)),
|
|
274
|
+
).length;
|
|
275
|
+
if (exchanged + 1 > limits.maxPairRoundTrips) {
|
|
276
|
+
return deny(
|
|
277
|
+
'pair_round_trips_exceeded',
|
|
278
|
+
`Actors ${a} and ${b} would exchange ask/delegate #${exchanged + 1}, above the limit of ${limits.maxPairRoundTrips}`,
|
|
279
|
+
true,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 8. Duplicates
|
|
286
|
+
const content = contentOf(mail);
|
|
287
|
+
if (content !== undefined) {
|
|
288
|
+
const key = senderKey(mail.from);
|
|
289
|
+
const sent = graph.mails.filter(
|
|
290
|
+
(m) => m.kind === mail.kind && m.to === mail.to && senderKey(m.from) === key && contentOf(m) === content,
|
|
291
|
+
).length;
|
|
292
|
+
if (sent + 1 > limits.maxDuplicates) {
|
|
293
|
+
return deny(
|
|
294
|
+
'duplicate_mail',
|
|
295
|
+
`${who} would send the same ${mail.kind} to ${mail.to} ${sent + 1} times, above the limit of ${limits.maxDuplicates}`,
|
|
296
|
+
true,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return ALLOW;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return Object.freeze({ admitMail });
|
|
305
|
+
}
|