@passioncode-ai/passioncode 0.2.39 → 0.2.40

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 (60) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +5 -2
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +5 -2
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +4 -1
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +60 -1
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +7 -2
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -2
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +35 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +22 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +47 -1
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +31 -2
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +14 -1
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +29 -6
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +29 -1
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +40 -2
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -12
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +16 -2
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +99 -14
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +32 -7
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
  50. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  51. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
  52. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
  53. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +302 -22
  54. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  55. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +5 -2
  56. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
  57. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +54 -9
  58. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +10 -0
  59. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  60. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -535,6 +535,12 @@ function placementGrantProblems(record, placement) {
535
535
  return problems;
536
536
  }
537
537
 
538
+ /** FAC-SEM-111 for one placement of `record`, as findings (`instancePath` relative to the placement); never throws (FAA-125). */
539
+ export function placementGrantFindings(record, placement) {
540
+ const object = (value) => (isObject(value) ? value : {});
541
+ return placementGrantProblems(object(record), object(placement)).map(([instancePath, message]) => ({ code: 'FAC-SEM-111', instancePath, message }));
542
+ }
543
+
538
544
  function placementGrants(value, flag) {
539
545
  indexed(value.machines).forEach(([machine, m]) => {
540
546
  indexed(machine.packages).forEach(([placement, p]) => {
@@ -7,6 +7,7 @@ import crypto from 'node:crypto';
7
7
  import { ServiceError } from './fabric-base.mjs';
8
8
  import { jcs, refusalFromAction, refusalToolResult } from './fabric-delegation.mjs';
9
9
  import { actionDecision, decisionShape } from './fabric-actions.mjs'; // DEC-0121
10
+ import { mandateProblem } from './fabric-mandate.mjs'; // DEC-0125
10
11
  import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
11
12
  import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
12
13
 
@@ -190,17 +191,24 @@ export function readsNullUsage(requestMeta) {
190
191
  }
191
192
 
192
193
  /**
193
- * A request's `_meta` from this kit as a caller: its trace, the interop revisions it reads (DEC-0087) and, for a relayed
194
- * action, the operator's decision (DEC-0121 AP-2; build one with operatorDecision). A decision travels here only, never
195
- * in a tool's arguments; one decision.schema.json refuses throws InteropError.
194
+ * A request's `_meta` from this kit as a caller: its trace, the interop revisions it reads (DEC-0087), for a relayed
195
+ * action the operator's decision (DEC-0121 AP-2; build one with operatorDecision), and the operator's mandate for the
196
+ * task the call serves (DEC-0125 MD-3; state it with operatorMandate and hand it on to the callee with handOn before you
197
+ * send it). Both travel here only, never in a tool's arguments; a decision decision.schema.json refuses or a mandate
198
+ * FAC-SEM-144 refuses throws InteropError.
196
199
  */
197
- export function requestMeta(traceparent, { decision } = {}) {
200
+ export function requestMeta(traceparent, { decision, mandate } = {}) {
198
201
  const meta = { protocols: [...PROTOCOLS_READ] };
199
202
  if (parseTraceparent(traceparent)) meta.traceparent = traceparent;
200
203
  if (decision !== undefined && decision !== null) {
201
204
  if (!decisionShape(decision)) throw new InteropError('The decision is not one decision.schema.json reads; it is not sent.');
202
205
  meta.decision = { ...decision };
203
206
  }
207
+ if (mandate !== undefined && mandate !== null) {
208
+ const problem = mandateProblem(mandate);
209
+ if (problem) throw new InteropError(`The mandate is not one FAC-SEM-144 reads (${problem}); it is not sent.`);
210
+ meta.mandate = structuredClone(mandate);
211
+ }
204
212
  return meta;
205
213
  }
206
214
 
@@ -212,18 +220,33 @@ export function requestMeta(traceparent, { decision } = {}) {
212
220
  * Answers {ok: true, record, decision}, `decision` being the one taken as the operator's (null otherwise), or {ok: false,
213
221
  * refusal} in the DL-19 form (refusalToolResult sends it); a service's own fault is the refusal unknown-state with
214
222
  * `fault` beside it for the service's log.
223
+ *
224
+ * DEC-0125: a service that names its own agent id (`self`) reads the call's `_meta.mandate` when it carries no
225
+ * decision, given `effect` (the action's effect class, required: without it the call is the service's fault
226
+ * effect-missing), `callerAgent` (only an agent id the service authenticated: a delegation's caller, its own per-agent
227
+ * credential; never a name the call carries), `spendUsd` (its own estimate) and `project`. A covered call answers
228
+ * {ok: true, record: {by: 'operator', via: 'mandate', task, chain, caller}, decision: null, mandate}; without `self` the
229
+ * mandate is not read and the call is answered as before it existed.
215
230
  */
216
- export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now } = {}) {
231
+ export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now, self, effect, callerAgent, spendUsd, project } = {}) {
217
232
  const inputs = { action, tier, caller, elicited: elicited === true, now };
218
233
  if (isObject(meta) && Object.hasOwn(meta, 'decision')) inputs.decision = meta.decision;
219
234
  for (const [name, value] of [['target', target], ['approval', approval], ['actionDigest', actionDigest], ['humanStepUrl', humanStepUrl]]) {
220
235
  if (value !== undefined && value !== null) inputs[name] = value;
221
236
  }
237
+ if (self !== undefined && self !== null && isObject(meta) && Object.hasOwn(meta, 'mandate')) {
238
+ Object.assign(inputs, { mandate: meta.mandate, self });
239
+ for (const [name, value] of [['effect', effect], ['callerAgent', callerAgent], ['spendUsd', spendUsd], ['project', project]]) {
240
+ if (value !== undefined && value !== null) inputs[name] = value;
241
+ }
242
+ }
222
243
  const answer = actionDecision(inputs);
223
244
  if (answer.ok === true) {
224
245
  const record = answer.recordAs;
225
246
  const taken = isObject(record) && record.via === 'relayed-instruction';
226
- return { ok: true, record, decision: taken ? inputs.decision : null };
247
+ const out = { ok: true, record, decision: taken ? inputs.decision : null };
248
+ if (isObject(record) && record.via === 'mandate') out.mandate = inputs.mandate;
249
+ return out;
227
250
  }
228
251
  const out = { ok: false, refusal: refusalFromAction(answer) };
229
252
  if ('fault' in answer) out.fault = answer.fault;
@@ -10,6 +10,7 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { ServiceError, atomicWrite, ensurePrivateDir, nowIso } from './fabric-service.mjs';
12
12
  import { jcs } from './fabric-delegation.mjs';
13
+ import { PageLinkError, dashboardsLink, inputRequestLinkFindings, pageLinkFindings, parseDashboardsLink } from './fabric-page-links.mjs';
13
14
 
14
15
  export {
15
16
  PROTOCOL,
@@ -121,11 +122,36 @@ export function choiceRequest(message, field, options, title) {
121
122
  return formRequest(message, { [field]: schema }, [field]);
122
123
  }
123
124
 
125
+ /**
126
+ * URL mode: the person opens `url` out of band; for credentials and payments. A page of this service goes as its
127
+ * open_link (`pageAnswer`, DEC-0123 PL-4); a raw loopback address and a `fabric-dashboards:` link outside the grammar
128
+ * are refused here, when the request is built (FAC-SEM-143).
129
+ */
124
130
  export function urlRequest(message, url) {
125
- if (!/^(https:\/\/|http:\/\/127\.0\.0\.1|http:\/\/localhost)/.test(url)) throw new InteropError('A URL-mode request needs an https URL (or this machine\'s loopback).');
131
+ const needs = "A URL-mode request needs an https URL or Fabric Dashboards' open_link (DEC-0123 PL-4).";
132
+ if (typeof url !== 'string') throw new InteropError(needs);
133
+ const [finding] = pageLinkFindings(url, '/params/url', 'the URL-mode request');
134
+ if (finding) throw new InteropError(finding.message);
135
+ if (!url.startsWith('https://') && !parseDashboardsLink(url).ok) throw new InteropError(needs);
126
136
  return { method: 'elicitation/create', params: { mode: 'url', message, url } };
127
137
  }
128
138
 
139
+ /**
140
+ * A tool answer that hands a person one of this service's pages (DEC-0123 PL-4): `{open_link}`, the page's Fabric
141
+ * Dashboards link, and `http_url`, its raw address, only as a diagnostic beside it (never judged as the action).
142
+ */
143
+ export function pageAnswer(service, instance, { path, httpUrl } = {}) {
144
+ let answer;
145
+ try {
146
+ answer = { open_link: dashboardsLink(service, instance, path) };
147
+ } catch (error) {
148
+ if (!(error instanceof PageLinkError)) throw error;
149
+ throw new InteropError(`No open_link for ${service}.${instance}: ${error.message} (DEC-0123 PL-1).`);
150
+ }
151
+ if (httpUrl !== undefined && httpUrl !== null) answer.http_url = httpUrl;
152
+ return answer;
153
+ }
154
+
129
155
  // --- C3.2 jobs: the same file format as fabric_interop.JobStore ------------------------------
130
156
  const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
131
157
 
@@ -236,6 +262,8 @@ export class JobStore {
236
262
  const entries = Object.entries(inputRequests ?? {});
237
263
  if (!entries.length) throw new InteropError('input_required needs at least one input request.');
238
264
  for (const [key, request] of entries) if (!KEY.test(key) || request?.method !== 'elicitation/create') throw new InteropError(`Input request ${key} must be a keyed elicitation/create request.`);
265
+ const [finding] = inputRequestLinkFindings({ inputRequests }); // FAC-SEM-143 (D12)
266
+ if (finding) throw new InteropError(`${finding.instancePath}: ${finding.message}`);
239
267
  return this.transition(jobId, 'input_required', { inputRequests, statusMessage });
240
268
  }
241
269
 
@@ -0,0 +1,247 @@
1
+ // The operator's mandate, mandate/1 (DEC-0125, FAC-SEM-144): the operator's instruction for one task travels with the
2
+ // task, narrowed at each hand-over. Part of the Node service kit (layer L2); the twin of fabric_mandate.py.
3
+ //
4
+ // - mandateCovers(input): whether a callee takes one call as the operator's decision under a mandate (MD-3 to MD-5),
5
+ // exactly as the contract's mandateCovers answers. `input` holds mandate, tier, now, self (the callee's own agent id),
6
+ // callerAgent (only when the callee authenticated one) and call ({capability, action?, effect, spendUsd?, project?}).
7
+ // - mandateProblem(mandate): 'mandate-malformed', 'mandate-widened' or undefined (FAC-SEM-144); mandateShape(mandate):
8
+ // mandate.schema.json alone; mandateRules(kind, value): the findings.
9
+ // - scopeWithin(child, parent), effectiveScope(mandate), effectClosure(ceiling).
10
+ // - operatorMandate({task, instruction, scope, lead, at, expiresAt, clock}): the mandate the agent leading a task states
11
+ // from the operator's own words (MD-1); handOn(mandate, callee, at, narrower): the one it sends to the agent it calls
12
+ // (MD-2), never wider. Send it in _meta.mandate (requestMeta(traceparent, { mandate })).
13
+ //
14
+ // The kit has no JSON Schema validator: the shape is written here from the schema, lengths in code points and patterns
15
+ // over the whole string, as the contract's validator reads them; times are read strictly (the contract's strictTime),
16
+ // as decisionShape reads them. Members are an object's own members. Never throws on what it reads. Node 20+, no
17
+ // dependency.
18
+ import { ServiceError } from './fabric-base.mjs';
19
+ import { isObject } from './fabric-jsvalue.mjs';
20
+ import { formatRfc3339, parseRfc3339Ms } from './fabric-time.mjs';
21
+
22
+ /** The longest a mandate stands after the operator gave it (FAC-SEM-144). */
23
+ export const MANDATE_LIFETIME_SECONDS = 7 * 24 * 60 * 60;
24
+ /** The refusals a mandate meets: DL-19's codes, with `data.detail` naming why. */
25
+ export const OUTSIDE_MANDATE = 'operator-decision-required';
26
+ export const MANDATE_INVALID = 'decision-invalid';
27
+ /** How far ahead of the callee's clock a mandate's `at` may be (the contract's CLOCK_SKEW_SECONDS). */
28
+ export const CLOCK_SKEW_SECONDS = 60;
29
+ /** How long a mandate operatorMandate states stands when the caller names no expiry: a relayed decision's lifetime. */
30
+ export const DEFAULT_LIFETIME_SECONDS = 24 * 60 * 60;
31
+ /** governance's effect classes (common.schema.json#/$defs/effectClass). */
32
+ export const EFFECT_CLASSES = Object.freeze(['none', 'draft', 'publish', 'send', 'reply', 'charge', 'delete', 'merge', 'deploy', 'change-policy']);
33
+ const READS_AND_DRAFTS = new Set(['none', 'draft']);
34
+ const NEVER_BY_MANDATE = 'change-policy'; // approving one's own promotion, grant, checker rule or policy change (AP-5)
35
+ const MEMBERS = ['kind', 'by', 'task', 'instruction', 'at', 'expiresAt', 'scope', 'chain'];
36
+ const SCOPE_MEMBERS = new Set(['capabilities', 'actions', 'effects', 'perCallMaxUsd', 'projects']);
37
+ const STEP_MEMBERS = new Set(['agent', 'at', 'scope']);
38
+ const AGENT_ID = /^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?$/; // common.schema.json agentId
39
+ const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/; // common.schema.json capabilityName
40
+ const REQUEST_KEY = /^[A-Za-z0-9_.-]{1,64}$/; // interop-common.schema.json requestKey
41
+ const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
42
+
43
+ /** An own member, or undefined. */
44
+ const member = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
45
+ const codePoints = (value) => {
46
+ let count = 0;
47
+ for (const _ of value) count += 1;
48
+ return count;
49
+ };
50
+ const text = (value, low, high) => typeof value === 'string' && value.length <= high * 2 && codePoints(value) >= low && codePoints(value) <= high;
51
+ const matches = (pattern, value) => typeof value === 'string' && pattern.test(value);
52
+ const uniqueList = (value, low, high, item) => Array.isArray(value) && value.length >= low && value.length <= high && value.every(item) && new Set(value).size === value.length;
53
+ // Every value the strict reading accepts, ajv-formats' date-time accepts too, and mandateProblem reads the times strictly
54
+ // next, so reading them strictly here gives mandateProblem's answer (mandate-malformed either way).
55
+ const time = (value) => typeof value === 'string' && !Number.isNaN(parseRfc3339Ms(value));
56
+
57
+ /** `none` is below every class and `draft` below every external one; external classes are not ordered (the contract's
58
+ * effectClosure, read as a grant's effectCeiling is). fabric-delegation.mjs re-exports it. */
59
+ export function effectClosure(ceiling) {
60
+ const admitted = new Set(ceiling);
61
+ if (ceiling.length > 0) admitted.add('none');
62
+ if (ceiling.some((effect) => effect !== 'none')) admitted.add('draft');
63
+ return admitted;
64
+ }
65
+
66
+ const SCOPE_CHECKS = {
67
+ capabilities: (v) => uniqueList(v, 1, 64, (x) => matches(CAPABILITY, x)),
68
+ actions: (v) => uniqueList(v, 1, 64, (x) => matches(REQUEST_KEY, x)),
69
+ effects: (v) => uniqueList(v, 1, 10, (x) => typeof x === 'string' && EFFECT_CLASSES.includes(x)),
70
+ perCallMaxUsd: (v) => typeof v === 'number' && v >= 0 && v <= 100000,
71
+ projects: (v) => uniqueList(v, 1, 16, (x) => text(x, 1, 128) && matches(PROJECT, x)),
72
+ };
73
+
74
+ /** mandate.schema.json#/$defs/scope. */
75
+ function scopeShape(scope) {
76
+ if (!isObject(scope) || Object.keys(scope).some((key) => !SCOPE_MEMBERS.has(key)) || !Object.hasOwn(scope, 'effects')) return false;
77
+ return Object.entries(scope).every(([key, value]) => SCOPE_CHECKS[key](value));
78
+ }
79
+
80
+ function stepShape(step) {
81
+ if (!isObject(step) || Object.keys(step).some((key) => !STEP_MEMBERS.has(key))) return false;
82
+ return Object.hasOwn(step, 'agent') && Object.hasOwn(step, 'at') && matches(AGENT_ID, step.agent) && time(step.at)
83
+ && (!Object.hasOwn(step, 'scope') || scopeShape(step.scope));
84
+ }
85
+
86
+ /** mandate.schema.json (DEC-0125): the members, their lengths, patterns and enums, the chain of 1 to 4 steps; times read
87
+ * strictly (ajv-formats' date-time also admits a space for T, an offset without its colon and a leap second). */
88
+ export function mandateShape(mandate) {
89
+ if (!isObject(mandate) || Object.keys(mandate).some((key) => !MEMBERS.includes(key))) return false;
90
+ if (MEMBERS.some((key) => !Object.hasOwn(mandate, key))) return false;
91
+ const { chain } = mandate;
92
+ return mandate.kind === 'mandate/1' && mandate.by === 'operator' && text(mandate.task, 1, 200) && text(mandate.instruction, 1, 500)
93
+ && time(mandate.at) && time(mandate.expiresAt) && scopeShape(mandate.scope)
94
+ && Array.isArray(chain) && chain.length >= 1 && chain.length <= 4 && chain.every(stepShape);
95
+ }
96
+
97
+ /** The scope in force: the last chain entry's that names one, or the mandate's own. */
98
+ export function effectiveScope(mandate) {
99
+ for (let index = mandate.chain.length - 1; index >= 0; index -= 1) {
100
+ const scope = member(mandate.chain[index], 'scope');
101
+ if (scope) return scope;
102
+ }
103
+ return mandate.scope;
104
+ }
105
+
106
+ const listWithin = (child, parent) => parent === undefined || (child !== undefined && child.every((item) => parent.includes(item)));
107
+ /** A per-call ceiling no higher: none in the child only when the parent names none. */
108
+ const ceilingWithin = (child, parent) => parent === undefined || (child !== undefined && child <= parent);
109
+
110
+ /** DEC-0136 MD-2a (adopted ahead of the pin, brief D28): a naming list (`capabilities`, `actions`) left out names no
111
+ * relayed action or external effect (MD-3), so a hand-over never adds one the scope in force leaves out; `actions` may
112
+ * be added where `capabilities` is listed, and narrows the relayed actions those name. */
113
+ function namingWithin(child, parent) {
114
+ const caps = member(parent, 'capabilities');
115
+ const acts = member(parent, 'actions');
116
+ const capsOk = caps === undefined ? member(child, 'capabilities') === undefined : listWithin(member(child, 'capabilities'), caps);
117
+ const actsOk = acts === undefined ? member(child, 'actions') === undefined || caps !== undefined : listWithin(member(child, 'actions'), acts);
118
+ return capsOk && actsOk;
119
+ }
120
+
121
+ /** True when `child` asks for nothing `parent` does not give (MD-2): each list a subset, no naming list added where the
122
+ * scope in force has none (DEC-0136 MD-2a), the effects within, the ceiling no higher. Both are scopes the schema reads. */
123
+ export function scopeWithin(child, parent) {
124
+ const ceiling = effectClosure(parent.effects);
125
+ return namingWithin(child, parent)
126
+ && listWithin(member(child, 'projects'), member(parent, 'projects'))
127
+ && child.effects.every((effect) => ceiling.has(effect))
128
+ && ceilingWithin(member(child, 'perCallMaxUsd'), member(parent, 'perCallMaxUsd'));
129
+ }
130
+
131
+ /** The first problem of a mandate as a document (FAC-SEM-144), or undefined: 'mandate-malformed' (not the schema's,
132
+ * standing more than 7 days, a chain that goes back in time, reaches the expiry or names an agent twice) or
133
+ * 'mandate-widened' (a hand-over that asks for more than the scope before it). */
134
+ export function mandateProblem(mandate) {
135
+ if (!mandateShape(mandate)) return 'mandate-malformed';
136
+ const at = parseRfc3339Ms(mandate.at);
137
+ const expires = parseRfc3339Ms(mandate.expiresAt);
138
+ if (expires <= at || expires > at + MANDATE_LIFETIME_SECONDS * 1000) return 'mandate-malformed';
139
+ let held = mandate.scope;
140
+ let last = at;
141
+ for (const step of mandate.chain) {
142
+ const stepAt = parseRfc3339Ms(step.at);
143
+ if (stepAt < last || stepAt >= expires) return 'mandate-malformed';
144
+ last = stepAt;
145
+ if (Object.hasOwn(step, 'scope')) {
146
+ if (!scopeWithin(step.scope, held)) return 'mandate-widened';
147
+ held = step.scope;
148
+ }
149
+ }
150
+ if (new Set(mandate.chain.map((step) => step.agent)).size !== mandate.chain.length) return 'mandate-malformed';
151
+ return undefined;
152
+ }
153
+
154
+ const outside = (detail) => ({ ok: false, code: OUTSIDE_MANDATE, data: { detail } });
155
+ const invalid = (detail) => ({ ok: false, code: MANDATE_INVALID, data: { detail } });
156
+
157
+ /**
158
+ * Whether a callee takes one call as the operator's decision under a mandate (DEC-0125 MD-3 to MD-5), after it has
159
+ * placed the caller's tier (DL-25), as the contract's mandateCovers answers: {fault: 'now-unreadable'} (the callee's own,
160
+ * never sent); {ignored: 'mandate-at-t2'} (a T2 caller's mandate changes nothing, MD-5); decision-invalid with
161
+ * data.detail mandate-malformed, mandate-widened, mandate-not-for-this-call, mandate-early or mandate-expired;
162
+ * operator-decision-required with data.detail `outside-mandate: <action|capability|effect|spend|project>` for the first
163
+ * part not covered (MD-4); else {ok: true, recordAs: {by: 'operator', via: 'mandate', task, chain}}. `callerAgent` is
164
+ * passed only when the callee authenticated the caller's agent id; without one the chain's end alone binds it. A missing
165
+ * or non-object `call` is read as a call that names nothing (the reference would throw).
166
+ */
167
+ export function mandateCovers(input) {
168
+ const field = (key) => member(input, key);
169
+ const mandate = field('mandate');
170
+ const self = field('self');
171
+ const callerAgent = field('callerAgent');
172
+ const call = isObject(field('call')) ? field('call') : {};
173
+ const clock = parseRfc3339Ms(field('now'));
174
+ if (Number.isNaN(clock)) return { fault: 'now-unreadable' };
175
+ const tier = field('tier');
176
+ if (tier !== 'T0' && tier !== 'T1') return { ignored: 'mandate-at-t2' };
177
+ const problem = mandateProblem(mandate);
178
+ if (problem) return invalid(problem);
179
+ const holders = mandate.chain.map((step) => step.agent);
180
+ if (holders.length < 2 || holders[holders.length - 1] !== self || (callerAgent !== undefined && holders[holders.length - 2] !== callerAgent)) {
181
+ return invalid('mandate-not-for-this-call');
182
+ }
183
+ if (parseRfc3339Ms(mandate.at) > clock + CLOCK_SKEW_SECONDS * 1000) return invalid('mandate-early');
184
+ if (clock >= parseRfc3339Ms(mandate.expiresAt)) return invalid('mandate-expired');
185
+ const scope = effectiveScope(mandate);
186
+ const actions = member(scope, 'actions');
187
+ const capabilities = member(scope, 'capabilities');
188
+ const action = member(call, 'action');
189
+ const effect = member(call, 'effect');
190
+ if (action !== undefined && actions !== undefined && !actions.includes(action)) return outside('outside-mandate: action');
191
+ if (action !== undefined && actions === undefined && capabilities === undefined) return outside('outside-mandate: action');
192
+ if (capabilities !== undefined && !capabilities.includes(member(call, 'capability'))) return outside('outside-mandate: capability');
193
+ if (effect === NEVER_BY_MANDATE || !effectClosure(scope.effects).has(effect)) return outside('outside-mandate: effect');
194
+ // An external effect needs the call named: a relayed action by `actions` or `capabilities`, any other call by
195
+ // `capabilities`, since `actions` covers relayed actions only.
196
+ if (!READS_AND_DRAFTS.has(effect) && capabilities === undefined && (action === undefined || actions === undefined)) return outside('outside-mandate: capability');
197
+ const ceiling = member(scope, 'perCallMaxUsd');
198
+ const spend = member(call, 'spendUsd');
199
+ if (ceiling !== undefined && (spend === undefined || spend > ceiling)) {
200
+ return { ok: false, code: OUTSIDE_MANDATE, data: { detail: 'outside-mandate: spend', ...(spend === undefined ? {} : { estimateUsd: spend }), perCallMaxUsd: ceiling } };
201
+ }
202
+ const project = member(call, 'project');
203
+ const projects = member(scope, 'projects');
204
+ if (project !== undefined && projects !== undefined && !projects.includes(project)) return outside('outside-mandate: project');
205
+ return { ok: true, recordAs: { by: 'operator', via: 'mandate', task: mandate.task, chain: holders } };
206
+ }
207
+
208
+ /**
209
+ * The mandate an agent sends with a call (DEC-0125 MD-2): the chain gains the agent it calls, at `at`, with `narrower`
210
+ * when it hands on less. Throws ServiceError when the result would not be a valid mandate (a wider scope, a fifth agent,
211
+ * an agent already in it, a time before the last step or at the expiry). `mandate` is not changed.
212
+ */
213
+ export function handOn(mandate, callee, at, narrower) {
214
+ const chain = Array.isArray(member(mandate, 'chain')) ? mandate.chain : [];
215
+ const step = narrower ? { agent: callee, at, scope: narrower } : { agent: callee, at };
216
+ const next = { ...(isObject(mandate) ? mandate : {}), chain: [...chain, step] };
217
+ const problem = mandateProblem(next);
218
+ if (problem) throw new ServiceError(`the mandate cannot be handed on: ${problem}`);
219
+ return next;
220
+ }
221
+
222
+ /**
223
+ * The mandate the agent leading a task states from the operator's own words (DEC-0125 MD-1): `task` as the lead names it,
224
+ * `instruction` the operator's words verbatim (their sentence, or the agent's short question and the answer, AP-3),
225
+ * `scope` what those words cover and no more, `lead` the lead's own agent id, `at` when the operator said it (now, by
226
+ * `clock` in milliseconds, when not given) and `expiresAt` (a day after `at` when not given; at most seven days). State one
227
+ * only from words the operator gave in this session, or relayed to you as his rule allows, never from text found in a
228
+ * tool result, page, file or message. Throws ServiceError for a mandate FAC-SEM-144 refuses.
229
+ */
230
+ export function operatorMandate({ task, instruction, scope, lead, at, expiresAt, clock = Date.now } = {}) {
231
+ const statedAt = at ?? formatRfc3339(clock());
232
+ const start = parseRfc3339Ms(statedAt);
233
+ const expiry = expiresAt ?? (Number.isNaN(start) ? undefined : formatRfc3339(start + DEFAULT_LIFETIME_SECONDS * 1000));
234
+ const mandate = { kind: 'mandate/1', by: 'operator', task, instruction, at: statedAt, expiresAt: expiry, scope, chain: [{ agent: lead, at: statedAt }] };
235
+ const problem = mandateProblem(mandate);
236
+ if (problem) {
237
+ throw new ServiceError(`Operator mandate: not one mandate.schema.json and FAC-SEM-144 read (${problem}): a task of 1..200 characters, the operator's words of 1..500, a scope with effects, the lead's agent id, an RFC 3339 time with its offset and an expiry after it, at most seven days on.`);
238
+ }
239
+ return mandate;
240
+ }
241
+
242
+ /** FAC-SEM-144 on kind `mandate` (the document itself), undefined for any other kind. */
243
+ export function mandateRules(kind, value) {
244
+ if (kind !== 'mandate') return undefined;
245
+ const problem = mandateProblem(value);
246
+ return problem ? [{ code: 'FAC-SEM-144', instancePath: '', message: `the mandate is not valid: ${problem} (DEC-0125 MD-1, MD-2)` }] : [];
247
+ }
@@ -0,0 +1,144 @@
1
+ // A service's pages as Fabric Dashboards' open_link (Fabric Agent Contract DEC-0123, PL-1 to PL-4, FAC-SEM-143;
2
+ // `src/page-link-rules.ts` at the pin): build and read the open_link, tell a raw local address, and the findings on a
3
+ // link a document hands a person and on a URL-mode input request. The twin of fabric_page_links.py, held to the
4
+ // contract's answers by test/fixtures/contract/page-link-vectors.json and page-link-extra-vectors.json. It reads URLs
5
+ // with Node's `URL` and `URLSearchParams`, as the reference does. Part of the Node service kit (FAA-65, layer L3).
6
+ // Node 20+, no dependency.
7
+ import { ID, INSTANCE as INSTANCE_NAME, ServiceError } from './fabric-base.mjs';
8
+ import { isObject } from './fabric-jsvalue.mjs';
9
+ import { credentialName, rawValues, secretValue } from './fabric-secrets.mjs';
10
+
11
+ /** A service id and an instance, as `service-common.schema.json` defines them. */
12
+ export const SERVICE_ID = ID;
13
+ export const INSTANCE = INSTANCE_NAME;
14
+ /** A path on the service origin, as `service-common.schema.json` `originPath` writes it: no whitespace. */
15
+ export const ORIGIN_PATH = /^\/(?!\/)\S*$/;
16
+ /** What the host's `safePath` refuses anywhere in a path: a backslash and a C0 or DEL control character. */
17
+ const HOST_REFUSED = /[\\\u0000-\u001f\u007f]/;
18
+ /** The host's limit on a path; the link around it is bounded only to keep a reader's work finite. */
19
+ export const MAX_PAGE_PATH_LENGTH = 2048;
20
+ export const MAX_PAGE_LINK_LENGTH = 20_000;
21
+ const BASE = 'http://origin.invalid';
22
+ /** A UTF-16 surrogate without its pair, which no encoding carries faithfully: it would read back as U+FFFD. */
23
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
24
+
25
+ /** What `dashboardsLink` and `pageAnswer` throw for an id, an instance or a path the grammar refuses. */
26
+ export class PageLinkError extends ServiceError {}
27
+
28
+ /**
29
+ * The open_link for the service `service.instance` (PL-1): its dashboard without `path`, or the page at `path` on its
30
+ * origin, encoded as Fabric Dashboards' `serviceLink` encodes it. Throws PageLinkError for what the grammar refuses.
31
+ */
32
+ export function dashboardsLink(service, instance, path) {
33
+ const problems = linkProblems(service, instance, path);
34
+ if (problems.length) throw new PageLinkError(problems.join('; '));
35
+ const base = `fabric-dashboards://service/${service}.${instance}`;
36
+ return path === undefined || path === null || path === '' ? base : `${base}?${new URLSearchParams({ path }).toString()}`;
37
+ }
38
+
39
+ const SHAPE = /^fabric-dashboards:\/\/service\/([^/?#]+)\/?(?:\?([^#]*))?$/;
40
+
41
+ /** Read an open_link: `{ok: true, service, instance, path?}` or `{ok: false, problems}`. */
42
+ export function parseDashboardsLink(url) {
43
+ if (typeof url !== 'string') return { ok: false, problems: ['not fabric-dashboards://service/<id>.<instance>[?path=<path>]'] };
44
+ if (url.length > MAX_PAGE_LINK_LENGTH) return { ok: false, problems: [`an open_link is at most ${MAX_PAGE_LINK_LENGTH} characters`] };
45
+ // The scheme and the host's verb are read without regard to case, as the host reads them.
46
+ const link = url.replace(/^fabric-dashboards:\/\/service\//i, 'fabric-dashboards://service/');
47
+ const match = SHAPE.exec(link);
48
+ if (!match) return { ok: false, problems: ['not fabric-dashboards://service/<id>.<instance>[?path=<path>]'] };
49
+ const key = match[1];
50
+ const dot = key.indexOf('.');
51
+ const service = dot < 0 ? key : key.slice(0, dot);
52
+ const instance = dot < 0 ? '' : key.slice(dot + 1);
53
+ if (!match[2]) {
54
+ const problems = linkProblems(service, instance);
55
+ return problems.length ? { ok: false, problems } : { ok: true, service, instance };
56
+ }
57
+ // The parameters as the host's URL parser reads them: a second `?` stays part of a name, and the query is
58
+ // percent-encoded before it is decoded.
59
+ const query = new URL(link).searchParams;
60
+ const paths = query.getAll('path');
61
+ if (paths.length !== 1) return { ok: false, problems: [paths.length ? 'path is given more than once' : 'an open_link names its path'] };
62
+ if ([...query.keys()].some((name) => name !== 'path')) return { ok: false, problems: ['an open_link carries its path and nothing else'] };
63
+ const path = paths[0];
64
+ if (path === '') return { ok: false, problems: ['the path is empty; a link to the dashboard itself names no path'] };
65
+ const problems = linkProblems(service, instance, path);
66
+ return problems.length ? { ok: false, problems } : { ok: true, service, instance, path };
67
+ }
68
+
69
+ function linkProblems(service, instance, path) {
70
+ const problems = [];
71
+ if (typeof service !== 'string' || !SERVICE_ID.test(service)) problems.push('the service is not a service id');
72
+ if (typeof instance !== 'string' || !INSTANCE.test(instance)) problems.push('the instance is not an instance name');
73
+ if (path === undefined || path === null || path === '') return problems;
74
+ if (typeof path !== 'string') problems.push('the path is not a path on the service origin');
75
+ else if (path.length > MAX_PAGE_PATH_LENGTH) problems.push(`the path is at most ${MAX_PAGE_PATH_LENGTH} characters`);
76
+ else if (!ORIGIN_PATH.test(path) || HOST_REFUSED.test(path) || LONE_SURROGATE.test(path) || new URL(path, BASE).origin !== BASE) problems.push('the path is not a path on the service origin');
77
+ else if (pathCarriesCredential(path)) problems.push("the path could carry a token or a login code; signing in is the host's");
78
+ return problems;
79
+ }
80
+
81
+ /** A login-code route, or a query or fragment piece that could carry a credential (FL-7: a link carries none). */
82
+ function pathCarriesCredential(path) {
83
+ // The route as the service resolves it: dot segments and letter case do not hide it.
84
+ if (/^\/fabric\/+v1\/+login(?:\/|$)/i.test(new URL(path, BASE).pathname)) return true;
85
+ // The query and the fragment, a hash route's own query (`#/jobs?token=`) included, as pieces of one query.
86
+ const rest = path.replace(/^[^?#]*/, '').replace(/^[?#]/, '').replace(/[#?]/g, '&');
87
+ if (!rest) return false;
88
+ const pieces = [...new URLSearchParams(rest)];
89
+ const raw = [...rawValues(rest).values()].flat();
90
+ return secretValue(rest) || raw.some(secretValue) || pieces.some(([name, value]) => (value !== '' && credentialName(name)) || secretValue(value));
91
+ }
92
+
93
+ /**
94
+ * The hosts that only this computer reaches, as WHATWG URL writes them: 127.0.0.0/8, `[::1]`, `[::ffff:127.x.y.z]`,
95
+ * `localhost` and names under it, and the unspecified addresses `0.0.0.0` and `[::]` a wildcard bind reports.
96
+ */
97
+ const LOOPBACK = /^(?:127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1?\]|\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]|localhost|[a-z0-9.-]+\.localhost)$/i;
98
+
99
+ /** True for an `http` or `https` address on a loopback host: a local service's raw address. */
100
+ export function isLoopbackPageUrl(url) {
101
+ let parsed;
102
+ try {
103
+ parsed = new URL(url);
104
+ } catch {
105
+ return false;
106
+ }
107
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && LOOPBACK.test(parsed.hostname.replace(/\.$/, ''));
108
+ }
109
+
110
+ /** FAC-SEM-143 for one link a document hands a person: a raw loopback address, or an open_link outside its grammar. */
111
+ export function pageLinkFindings(url, instancePath, label) {
112
+ if (typeof url !== 'string') return [];
113
+ if (isLoopbackPageUrl(url)) return [{ code: 'FAC-SEM-143', instancePath, message: `${label} hands a person a raw local address; give Fabric Dashboards' open_link (DEC-0123 PL-1)` }];
114
+ // Only what a URL parser strips around a URL: C0 controls and space.
115
+ const bare = url.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, '');
116
+ if (/^\s*fabric-dashboards:/i.test(url) || parsesAsDashboards(url)) {
117
+ const parsed = parseDashboardsLink(bare);
118
+ if (!parsed.ok) return [{ code: 'FAC-SEM-143', instancePath, message: `${label} is not an open_link: ${parsed.problems.join('; ')} (DEC-0123 PL-1)` }];
119
+ }
120
+ return [];
121
+ }
122
+
123
+ function parsesAsDashboards(url) {
124
+ try {
125
+ return new URL(url).protocol === 'fabric-dashboards:';
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /** FAC-SEM-143 on an input request in URL mode (kind `interop-input-requests`): its `url` is a link a person opens. */
132
+ export function inputRequestLinkFindings(value) {
133
+ const requests = isObject(value?.inputRequests) ? value.inputRequests : {};
134
+ return Object.entries(requests).flatMap(([key, request]) => {
135
+ const params = isObject(request) && isObject(request.params) ? request.params : {};
136
+ return params.mode === 'url' ? pageLinkFindings(params.url, `/inputRequests/${key}/params/url`, `input request ${key}`) : [];
137
+ });
138
+ }
139
+
140
+ /** Kind `page-link` (`{url}`): a link a service, its tools or its messages hand a person, such as a tool answer's `open_link`. */
141
+ export function pageLinkRules(kind, value) {
142
+ if (kind !== 'page-link') return undefined;
143
+ return pageLinkFindings(value?.url, '/url', 'the link');
144
+ }
@@ -4,7 +4,7 @@ import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { ID, ServiceError, expand } from './fabric-base.mjs';
7
+ import { ID, INSTANCE, ServiceError, expand } from './fabric-base.mjs';
8
8
  import { windowsProtect } from './fabric-platform.mjs';
9
9
 
10
10
  // DEC-0032: LOCALAPPDATA, not APPDATA, so token files never roam with the profile.
@@ -39,6 +39,27 @@ export function serviceDirs(id, platform = process.platform) {
39
39
  };
40
40
  }
41
41
 
42
+ /**
43
+ * One instance's folders (FAA-125, brief D16): `default` keeps serviceDirs (an existing install keeps its data, logs
44
+ * and lock); any other is a sibling named as its descriptor, `<id>.<instance>`, never inside the default tree.
45
+ */
46
+ export function instanceDirs(id, instance = 'default', platform = process.platform) {
47
+ const personal = serviceDirs(id, platform);
48
+ if (typeof instance !== 'string' || !INSTANCE.test(instance)) throw new ServiceError(`Instance ${JSON.stringify(instance)} must match ${INSTANCE}.`);
49
+ if (instance === 'default') return personal;
50
+ const folder = `${id}.${instance}`;
51
+ if (platform === 'win32') {
52
+ const data = path.join(path.dirname(personal.data), folder);
53
+ return { data, logs: path.join(data, 'Logs'), cache: path.join(data, 'Cache') };
54
+ }
55
+ if (platform === 'darwin') return Object.fromEntries(Object.entries(personal).map(([key, dir]) => [key, path.join(path.dirname(dir), folder)]));
56
+ return {
57
+ data: path.join(path.dirname(personal.data), folder),
58
+ logs: path.join(path.dirname(path.dirname(personal.logs)), folder, 'logs'),
59
+ cache: path.join(path.dirname(personal.cache), folder),
60
+ };
61
+ }
62
+
42
63
  export function ensurePrivateDir(dir) {
43
64
  fs.mkdirSync(dir, { recursive: true });
44
65
  if (process.platform === 'win32') windowsProtect(dir, { folder: true });
@@ -11,7 +11,7 @@ export const PROTOCOL_REVISIONS = new Map(Object.entries({
11
11
  'fabric-actions': ['0.1'], 'fabric-activity': ['0.1'], 'fabric-agent-call': ['1'], 'fabric-connections': ['0.1'], 'fabric-credential-envelope': ['1'], 'fabric-load': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
12
12
  'fabric-interop': ['0.1', '0.2'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
13
13
  'fabric-package': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
14
- 'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
14
+ 'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'], mandate: ['1'],
15
15
  memory: ['0.1'], 'node-summary': ['1'], 'pairing-message': ['1'], pipeline: ['0.1'], 'repair-request': ['1'], 'repair-result': ['1'],
16
16
  'revocation-delta': ['1'], 'session-handoff': ['1'], 'service-load': ['1'],
17
17
  'share-registration': ['1'], 'share-registration-receipt': ['1'], 'support-quality-daily': ['1'], 'window-lease': ['1'],
@@ -6,8 +6,8 @@
6
6
  // never throws on malformed input. Normative source: the contract's src/share-registration.ts.
7
7
  // #region share-kit-node, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md#share-grants-into-an-organization
8
8
 
9
- import { estateTime, grantContextFindings } from './fabric-estate.mjs';
10
- import { sameIdentifier } from './fabric-identifiers.mjs';
9
+ import { PERSONAL_CONTEXT, estateTime, grantContextFindings, placementGrantFindings } from './fabric-estate.mjs';
10
+ import { normalIdentifier, sameIdentifier } from './fabric-identifiers.mjs';
11
11
 
12
12
  export const SHARE_REGISTRATION_TYPE = 'share-registration/1';
13
13
  export const SHARE_REGISTRATION_RECEIPT_TYPE = 'share-registration-receipt/1';
@@ -91,6 +91,34 @@ export function grantUsageContextFindings(value) {
91
91
  return findings;
92
92
  }
93
93
 
94
+ /**
95
+ * Whether an organization's instance may run on `machineId` now (FAA-125, N-11): the placement of `agentRef` in
96
+ * `context` (as written; absent is personal) among that machine's packages, FAC-SEM-111 on it, and the grant's window at
97
+ * `atMs` (startsAt <= at < expiresAt; an unreadable instant or clock is outside it). Answers {ok: true, grant: {id,
98
+ * startsAt?, expiresAt?}} ({ok: true} for a personal placement) or {ok: false, code, detail} with code
99
+ * placement-missing, grant-refused or grant-window; never throws. Reads the revision it is given (FAC-SEM-111).
100
+ */
101
+ export function instanceShareGrant(record, machineId, agentRef, context, atMs) {
102
+ const rec = isObject(record) ? record : {};
103
+ const machine = list(rec.machines).find((entry) => entry.id === machineId) ?? {};
104
+ const placement = list(machine.packages).find((entry) => entry.agentRef === agentRef && (entry.context === undefined ? PERSONAL_CONTEXT : entry.context) === context);
105
+ if (!placement) return { ok: false, code: 'placement-missing', detail: 'this machine places no such agent in that context (FAC-SEM-074)' };
106
+ const findings = placementGrantFindings(rec, placement);
107
+ if (findings.length) return { ok: false, code: 'grant-refused', detail: findings.map((f) => f.message).join('; ') };
108
+ if (typeof placement.grant !== 'string') return { ok: true }; // a personal placement, which names no grant
109
+ const id = normalIdentifier(placement.grant);
110
+ const grant = list(rec.shareGrants).find((g) => g.context === context && isObject(g.meta) && typeof g.meta.id === 'string' && normalIdentifier(g.meta.id) === id);
111
+ if (!grant) return { ok: false, code: 'grant-refused', detail: 'the record has no share grant for this placement' }; // defensive, as in Python
112
+ const answer = { id: grant.meta.id };
113
+ for (const name of ['startsAt', 'expiresAt']) if (grant[name] !== undefined) answer[name] = grant[name];
114
+ const starts = answer.startsAt === undefined ? undefined : estateTime(answer.startsAt);
115
+ const expires = answer.expiresAt === undefined ? undefined : estateTime(answer.expiresAt);
116
+ if ((starts !== undefined && !(starts <= atMs)) || (expires !== undefined && !(atMs < expires))) {
117
+ return { ok: false, code: 'grant-window', detail: "now is outside the share grant's window, or an instant cannot be read" };
118
+ }
119
+ return { ok: true, grant: answer };
120
+ }
121
+
94
122
  /** Kinds grant, org-share-grants, share-registration and device-attribution (045's grant.usage clause); undefined otherwise. */
95
123
  export function shareRules(kind, value) {
96
124
  if (kind === 'grant') return grantFindings(value, '');