@passioncode-ai/passioncode 0.2.38 → 0.2.39

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 (50) 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 +29 -11
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +53 -3
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
  10. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +47 -12
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +5 -4
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +3 -2
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +53 -3
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -1
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +99 -1
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -2
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -3
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +45 -2
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +325 -2
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +246 -0
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +22 -0
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -2
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +45 -3
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +302 -0
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -1
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +22 -3
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +154 -8
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +45 -2
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +124 -16
  44. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
  45. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +53 -3
  46. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
  47. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +41 -9
  48. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -0
  49. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  50. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -20,6 +20,7 @@ export const REFUSAL_CODES = Object.freeze([
20
20
  'unauthenticated', 'token-expired', 'wrong-audience', 'scope-not-granted', 'effect-above-ceiling', 'no-grant',
21
21
  'grant-expired', 'grant-revoked', 'limit-reached', 'approval-required', 'approval-invalid', 'approval-reused',
22
22
  'principal-unproven', 'credential-source-refused', 'chain-too-deep', 'profile-forbids', 'issuer-unavailable', 'unknown-state',
23
+ 'operator-decision-required', 'decision-invalid', 'human-step-required',
23
24
  ]);
24
25
  export const EFFECT_CLASSES = Object.freeze(['none', 'draft', 'publish', 'send', 'reply', 'charge', 'delete', 'merge', 'deploy', 'change-policy']);
25
26
  export const DELEGATION_HEADER = 'Fabric-Delegation';
@@ -55,7 +56,13 @@ export const REFUSAL_MESSAGES = Object.freeze({
55
56
  'profile-forbids': "The caller's or the target's machine profile does not allow this call.",
56
57
  'issuer-unavailable': "The estate's published keys cannot be read.",
57
58
  'unknown-state': 'The state cannot be established, so the call is refused.',
59
+ // DEC-0121 (AP-2, AP-4): the refusals of an action call; each answers 403.
60
+ 'operator-decision-required': "This action is the operator's decision; ask the operator and call again with it in _meta.decision.",
61
+ 'decision-invalid': "The call's decision is not valid for this action, object or time.",
62
+ 'human-step-required': "This step is the person's own; hand them the link in error.data.url.",
58
63
  });
64
+ /** The detail each action-call refusal carries in error.data (the schema requires one, 1..80 characters). */
65
+ const ACTION_DETAILS = new Map([['operator-decision-required', 'no-decision'], ['decision-invalid', 'invalid-decision'], ['human-step-required', 'human-step'], ['approval-required', 'no-approval']]);
59
66
 
60
67
  const refuse = (code, detail, data) => (data ? { ok: false, code, detail, data } : { ok: false, code, detail });
61
68
  const B64URL = /^[A-Za-z0-9_-]+$/;
@@ -488,6 +495,21 @@ export function refusalFromDenial(denial) {
488
495
  return { error: { code: denial.code, message, ...(Object.keys(data).length > 0 ? { data } : {}) } };
489
496
  }
490
497
 
498
+ /**
499
+ * DL-19 (DEC-0121): the refusal a service answers for an action call that fabric-actions.mjs's actionDecision refused,
500
+ * or undefined when it took the call. operator-decision-required, decision-invalid and approval-required carry their
501
+ * detail; human-step-required carries humanStep and url (AP-4). A `fault` is the service's own and is never sent: the
502
+ * caller reads unknown-state with the detail service-fault, as for anything else that is not an answer.
503
+ */
504
+ export function refusalFromAction(answer) {
505
+ const isRecord = answer !== null && typeof answer === 'object' && !Array.isArray(answer);
506
+ if (isRecord && answer.ok === true) return undefined;
507
+ const code = isRecord && answer.ok === false && typeof answer.code === 'string' ? answer.code : undefined;
508
+ if (code === undefined || !ACTION_DETAILS.has(code)) return refuse('unknown-state', 'service-fault');
509
+ const data = answer.data !== null && typeof answer.data === 'object' && !Array.isArray(answer.data) && Object.keys(answer.data).length ? { ...answer.data } : undefined;
510
+ return refuse(code, ACTION_DETAILS.get(code), data);
511
+ }
512
+
491
513
  /** The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key. */
492
514
  export const refusalError = (refusal) => ({ error: { code: refusal.code, message: REFUSAL_MESSAGES[refusal.code] ?? 'Refused.', data: { detail: refusal.detail, ...(refusal.data ?? {}) } } });
493
515
 
@@ -7,13 +7,14 @@ import { ID, INSTANCE, LOCAL_PATH, PROTOCOL, ServiceError, TASK, UNIT, expand }
7
7
  import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
8
8
  import { protocolsFindings } from './fabric-protocols.mjs';
9
9
  import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
10
+ import { ACTIONS_EXTENSION_KEY, actionsExtension, actionsExtensionRules } from './fabric-actions.mjs';
10
11
  import { ROUTES } from './fabric-usage.mjs';
11
12
 
12
13
  // The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
13
14
  // document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
14
15
  export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
15
16
 
16
- export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.4';
17
+ export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.5';
17
18
 
18
19
  export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
19
20
 
@@ -377,7 +378,11 @@ export function configurationProblems(doc) {
377
378
  return problems;
378
379
  }
379
380
 
380
- export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols }) {
381
+ // `actions` (DEC-0121 AP-1) is the service's action table, every button its dashboard, app or page offers with the tool
382
+ // that performs it and its gate, written under extensions/actions/0.1 by actionsExtension. A declaration FAC-SEM-142
383
+ // finds, passed either way, is refused: not its schema's, an id named twice, a humanStep that disagrees with the gate,
384
+ // or a tool surfaces.mcp.capabilities does not list.
385
+ export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols, actions }) {
381
386
  if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
382
387
  if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
383
388
  if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
@@ -391,6 +396,11 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
391
396
  update: { available: updateAvailable },
392
397
  };
393
398
  if (summary?.length) doc.summary = summary.slice(0, 6);
399
+ if (actions !== undefined && actions !== null) {
400
+ if (extensions && Object.hasOwn(extensions, ACTIONS_EXTENSION_KEY)) throw new ServiceError('Actions declaration: pass the action table or a ready declaration, not both.');
401
+ const capabilities = Array.isArray(surfaces.mcp?.capabilities) ? surfaces.mcp.capabilities : undefined;
402
+ extensions = { ...(extensions ?? {}), ...actionsExtension(actions, { capabilities }) };
403
+ }
394
404
  if (extensions && Object.keys(extensions).length) {
395
405
  if (DASHBOARD_EXTENSION_KEY in extensions) {
396
406
  const problems = dashboardExtensionProblems(extensions[DASHBOARD_EXTENSION_KEY]);
@@ -401,6 +411,12 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
401
411
  const view = connectionsView({ extensions: { [CONNECTIONS_EXTENSION_KEY]: extensions[CONNECTIONS_EXTENSION_KEY] } });
402
412
  if (view.reported) throw new ServiceError(`Connections declaration: ${view.errors.join('; ')}.`);
403
413
  }
414
+ if (Object.hasOwn(extensions, ACTIONS_EXTENSION_KEY)) {
415
+ // DEC-0121 AP-1: a host reports an invalid declaration (FAC-SEM-142); a service never publishes one. The tools are
416
+ // compared with the capabilities this document's MCP surface lists.
417
+ const findings = actionsExtensionRules({ extensions: { [ACTIONS_EXTENSION_KEY]: extensions[ACTIONS_EXTENSION_KEY] }, surfaces });
418
+ if (findings.length) throw new ServiceError(`Actions declaration: ${findings.map((f) => f.message).join('; ')}.`);
419
+ }
404
420
  doc.extensions = { ...extensions };
405
421
  }
406
422
  if (configuration !== undefined) {
@@ -5,7 +5,8 @@
5
5
  // fabric_interop_rules.py. Node 20+, no dependency.
6
6
  import crypto from 'node:crypto';
7
7
  import { ServiceError } from './fabric-base.mjs';
8
- import { jcs } from './fabric-delegation.mjs';
8
+ import { jcs, refusalFromAction, refusalToolResult } from './fabric-delegation.mjs';
9
+ import { actionDecision, decisionShape } from './fabric-actions.mjs'; // DEC-0121
9
10
  import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
10
11
  import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
11
12
 
@@ -188,13 +189,54 @@ export function readsNullUsage(requestMeta) {
188
189
  return Array.isArray(protocols) && protocols.includes(INTEROP_NULL_USAGE);
189
190
  }
190
191
 
191
- /** A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087). */
192
- export function requestMeta(traceparent) {
192
+ /**
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.
196
+ */
197
+ export function requestMeta(traceparent, { decision } = {}) {
193
198
  const meta = { protocols: [...PROTOCOLS_READ] };
194
199
  if (parseTraceparent(traceparent)) meta.traceparent = traceparent;
200
+ if (decision !== undefined && decision !== null) {
201
+ if (!decisionShape(decision)) throw new InteropError('The decision is not one decision.schema.json reads; it is not sent.');
202
+ meta.decision = { ...decision };
203
+ }
195
204
  return meta;
196
205
  }
197
206
 
207
+ /**
208
+ * One call of a declared action, before its handler runs (DEC-0121 AP-2, AP-4), for a Node service's own MCP server:
209
+ * actionDecision over the call's `_meta.decision` (an own member of `meta` only, never arguments, decision D11), the
210
+ * service's authenticated `caller` (actionCaller), `target`, `elicited` (the service's own record of an accepted
211
+ * elicitation), `approval` (verifyApproval's accepted outcome), `actionDigest`, `humanStepUrl` and `now` (RFC 3339).
212
+ * Answers {ok: true, record, decision}, `decision` being the one taken as the operator's (null otherwise), or {ok: false,
213
+ * refusal} in the DL-19 form (refusalToolResult sends it); a service's own fault is the refusal unknown-state with
214
+ * `fault` beside it for the service's log.
215
+ */
216
+ export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now } = {}) {
217
+ const inputs = { action, tier, caller, elicited: elicited === true, now };
218
+ if (isObject(meta) && Object.hasOwn(meta, 'decision')) inputs.decision = meta.decision;
219
+ for (const [name, value] of [['target', target], ['approval', approval], ['actionDigest', actionDigest], ['humanStepUrl', humanStepUrl]]) {
220
+ if (value !== undefined && value !== null) inputs[name] = value;
221
+ }
222
+ const answer = actionDecision(inputs);
223
+ if (answer.ok === true) {
224
+ const record = answer.recordAs;
225
+ const taken = isObject(record) && record.via === 'relayed-instruction';
226
+ return { ok: true, record, decision: taken ? inputs.decision : null };
227
+ }
228
+ const out = { ok: false, refusal: refusalFromAction(answer) };
229
+ if ('fault' in answer) out.fault = answer.fault;
230
+ return out;
231
+ }
232
+
233
+ /** The tools/call result a service's own MCP server answers for an actionDecision answer: null when the action takes
234
+ * the call, else DL-19's refusal as structuredContent with isError true (DEC-0121). */
235
+ export function actionRefusalToolResult(answer, traceparent) {
236
+ const refusal = refusalFromAction(answer);
237
+ return refusal === undefined ? null : refusalToolResult(refusal, traceparent);
238
+ }
239
+
198
240
  /** FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null. */
199
241
  export function usageFindings(usage, at) {
200
242
  if (!isObject(usage) || usage.partial !== false) return [];
@@ -36,6 +36,8 @@ export {
36
36
  intentIdempotencyKey,
37
37
  readsNullUsage,
38
38
  requestMeta,
39
+ actionGate,
40
+ actionRefusalToolResult,
39
41
  usageFindings,
40
42
  usageToward,
41
43
  jobLimitRules,
@@ -8,7 +8,7 @@ import { credentialName, secretArgument, secretUrl, secretValue } from './fabric
8
8
  /** Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`); a test holds it equal to the contract's. */
9
9
  export const PROTOCOL_REVISIONS = new Map(Object.entries({
10
10
  'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'agent-directory': ['1'], 'call-record': ['1'], 'coding-agents': ['1'], 'credential-decision': ['1'], 'credential-request': ['1'], 'credential-revocation': ['1'], 'enroll-token': ['1'], entitlement: ['1'], 'entitlement-key-set': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
11
- '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'],
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
14
  'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
@@ -40,6 +40,7 @@ export {
40
40
  feedDataFindings,
41
41
  feedDataProblems,
42
42
  } from './fabric-protocols.mjs';
43
+ export { ACTIONS_EXTENSION_KEY, actionDecision, actionRules, actionsExtension, actionsExtensionRules, actionsView, decisionShape } from './fabric-actions.mjs';
43
44
  export { CONNECTIONS_EXTENSION_KEY, connectionsExtension, connectionsRules, connectionsView } from './fabric-connections.mjs';
44
45
  export {
45
46
  EXIT_ALREADY_RUNNING,
@@ -0,0 +1,302 @@
1
+ """Every action a person has, its agent tool and who decides it: `fabric-actions/0.1`, the extension keyed
2
+ extensions/actions/0.1 (DEC-0121, FAC-SEM-142). Part of the service kit (layer L3); the twin of fabric-actions.mjs.
3
+
4
+ - ``action_decision(inputs)``: whether a service takes a call of one declared action and as whose act it records it,
5
+ exactly as the contract's ``actionDecision`` answers (AP-2, AP-4; DL-17, DL-18, DL-25). A ``fault`` is the
6
+ service's own and is never sent to a caller.
7
+ - ``decision_shape(value)``: a decision as ``decision.schema.json`` reads it, ``at`` read strictly (the contract's
8
+ ``strictTime``).
9
+ - ``actions_view(well_known)``: the host reader of the declaration, as the contract's ``actionsView``.
10
+ - ``actions_extension_rules(well_known)`` and ``action_rules(kind, value)``: FAC-SEM-142.
11
+ - ``actions_extension(actions, capabilities)``: the ``extensions`` entry a service declares; it refuses a declaration
12
+ FAC-SEM-142 would find, so a service never publishes one.
13
+ - ``operator_decision(action, instruction, ...)``: the decision an agent relays in ``_meta.decision`` (AP-2, AP-3),
14
+ refused when ``decision.schema.json`` would refuse it.
15
+ - ``action_caller(tier, authenticated)``: the caller a service records (AP-2): the identity it authenticated, else
16
+ ``local:T0`` / ``local:T1`` for a caller its local floor authenticated, never a name the call carries.
17
+
18
+ The kit has no JSON Schema validator: the two shapes are written here from the schemas, lengths in code points and
19
+ patterns over the whole string, as the contract's validator reads them. Never raises on what it reads. Standard library
20
+ only.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import re
26
+ import time
27
+ from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional
28
+
29
+ import sys as _sys # noqa: E402
30
+ from pathlib import Path as _Path # noqa: E402
31
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
32
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
33
+
34
+ from fabric_base import ServiceError # noqa: E402
35
+ from fabric_jsvalue import MISSING, contains, get, json_equal, json_stringify # noqa: E402
36
+ from fabric_time import format_rfc3339, parse_rfc3339_ms # noqa: E402
37
+
38
+ ACTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/actions/0.1"
39
+ #: The three refusals DEC-0121 adds to DL-19's one code set, and DL-17's refusal a T2 caller still gets.
40
+ OPERATOR_DECISION_REQUIRED = "operator-decision-required"
41
+ DECISION_INVALID = "decision-invalid"
42
+ HUMAN_STEP_REQUIRED = "human-step-required"
43
+ APPROVAL_REQUIRED = "approval-required"
44
+ GATES = ("none", "relayed", "human")
45
+ HUMAN_STEPS = ("sign-in", "consent", "payment", "secret", "spend-above-limit", "passkey")
46
+ MAX_ACTIONS = 128
47
+ #: How far ahead of the service's clock a decision's `at` may be (the contract's CLOCK_SKEW_SECONDS).
48
+ CLOCK_SKEW_SECONDS = 60
49
+ #: How long after the operator decided a relayed decision still stands: approval/1's lifetime (DL-17, FAC-SEM-051).
50
+ DECISION_LIFETIME_SECONDS = 24 * 60 * 60
51
+
52
+ _REQUEST_KEY = re.compile(r"[A-Za-z0-9_.-]{1,64}", re.ASCII) # interop-common.schema.json#/$defs/requestKey
53
+ _CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}", re.ASCII) # common.schema.json#/$defs/capabilityName
54
+ _SHA256 = re.compile(r"sha256:[0-9a-f]{64}", re.ASCII)
55
+ _STEP_URL = re.compile(r"https://|/") # a prefix, as the contract's /^(https:\/\/|\/)/ tests it
56
+ _ACTION_MEMBERS = ("id", "title", "tool", "gate", "humanStep")
57
+ _DECISION_MEMBERS = ("by", "via", "action", "target", "instruction", "at")
58
+ _KEY_PATH = "/extensions/" + ACTIONS_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
59
+
60
+
61
+ def _text(value: Any, low: int, high: int) -> bool:
62
+ """A string of low..high code points, as the validator counts them (Python counts code points)."""
63
+ return isinstance(value, str) and low <= len(value) <= high
64
+
65
+
66
+ def _matches(pattern: "re.Pattern[str]", value: Any, high: int) -> bool:
67
+ return isinstance(value, str) and len(value) <= high and pattern.fullmatch(value) is not None
68
+
69
+
70
+ def _non_empty(value: Any) -> bool:
71
+ return isinstance(value, str) and len(value) > 0
72
+
73
+
74
+ def _one_of(value: Any, choices: Iterable[str]) -> bool:
75
+ return isinstance(value, str) and value in choices
76
+
77
+
78
+ def _action_errors(value: Any, at: str) -> List[str]:
79
+ """`service-actions-extension.schema.json#/$defs/action`: the errors at `at`, empty for a valid entry."""
80
+ if not isinstance(value, dict):
81
+ return ["%s type" % at]
82
+ errors = ["%s/%s additionalProperties" % (at, key) for key in value if key not in _ACTION_MEMBERS]
83
+ errors += ["%s required %s" % (at, key) for key in ("id", "title", "tool", "gate") if key not in value]
84
+ if "id" in value and not _matches(_REQUEST_KEY, value["id"], 64):
85
+ errors.append("%s/id pattern" % at)
86
+ if "title" in value and not _text(value["title"], 1, 80):
87
+ errors.append("%s/title maxLength" % at)
88
+ if "tool" in value and not _matches(_CAPABILITY, value["tool"], 128):
89
+ errors.append("%s/tool pattern" % at)
90
+ if "gate" in value and not _one_of(value["gate"], GATES):
91
+ errors.append("%s/gate enum" % at)
92
+ if "humanStep" in value and not _one_of(value["humanStep"], HUMAN_STEPS):
93
+ errors.append("%s/humanStep enum" % at)
94
+ return errors
95
+
96
+
97
+ def _extension_errors(value: Any) -> List[str]:
98
+ """`service-actions-extension.schema.json`: the errors, empty for a valid declaration."""
99
+ if not isinstance(value, dict):
100
+ return ["/ type"]
101
+ errors = ["/%s additionalProperties" % key for key in value if key != "actions"]
102
+ if "actions" not in value:
103
+ return errors + ["/ required actions"]
104
+ actions = value["actions"]
105
+ if not isinstance(actions, list):
106
+ return errors + ["/actions type"]
107
+ if not 1 <= len(actions) <= MAX_ACTIONS:
108
+ errors.append("/actions %s" % ("minItems" if not actions else "maxItems"))
109
+ for index, action in enumerate(actions):
110
+ errors += _action_errors(action, "/actions/%d" % index)
111
+ return errors
112
+
113
+
114
+ def readable_action(action: Any) -> bool:
115
+ """One valid `actions[]` entry whose `humanStep` is present exactly when `gate` is `human`."""
116
+ return not _action_errors(action, "") and (action["gate"] == "human") == ("humanStep" in action)
117
+
118
+
119
+ def decision_shape(value: Any) -> bool:
120
+ """`decision.schema.json` (DEC-0121 AP-2): by `operator`, via `relayed-instruction`, the action's id, an optional
121
+ target of 1..200, the operator's words of 1..500, and `at` read strictly (the contract's strictTime; ajv-formats'
122
+ date-time also admits a space for T and a leap second, which this refuses, decision D5)."""
123
+ if not isinstance(value, dict) or any(key not in _DECISION_MEMBERS for key in value):
124
+ return False
125
+ if any(key not in value for key in ("by", "via", "action", "instruction", "at")):
126
+ return False
127
+ return (isinstance(value["by"], str) and value["by"] == "operator"
128
+ and isinstance(value["via"], str) and value["via"] == "relayed-instruction"
129
+ and _matches(_REQUEST_KEY, value["action"], 64)
130
+ and ("target" not in value or _text(value["target"], 1, 200))
131
+ and _text(value["instruction"], 1, 500)
132
+ and isinstance(value["at"], str) and not math.isnan(parse_rfc3339_ms(value["at"])))
133
+
134
+
135
+ def _accepted_approval(approval: Any) -> bool:
136
+ return isinstance(approval, dict) and approval.get("ok") is True and _non_empty(approval.get("approver"))
137
+
138
+
139
+ def _refuse(code: str) -> Dict[str, Any]:
140
+ return {"ok": False, "code": code}
141
+
142
+
143
+ def action_decision(inputs: Any) -> Dict[str, Any]:
144
+ """Whether a service takes a call of one declared action, and as whose act it records it (DEC-0121 AP-2, AP-4;
145
+ DL-17, DL-18, DL-25, DL-26), as the contract's actionDecision answers. `inputs` holds `action` (the service's own
146
+ declaration), `tier`, `caller` (the service's authenticated identity of the caller, never a claim in the call),
147
+ `decision` (the call's `_meta.decision`), `target`, `elicited` (the service's own record of an accepted
148
+ elicitation), `approval` (verify_approval's accepted outcome), `actionDigest`, `humanStepUrl` and `now`.
149
+
150
+ - `none`: the caller's own act. `human`: `human-step-required` with the step and its link, at every tier.
151
+ - `relayed`: an accepted approval is the approver's; a T2 caller without one gets `approval-required` with the
152
+ digest; an accepted elicitation is the operator's; no decision is `operator-decision-required`; a decision that
153
+ is not the schema's, is for another action or object, or whose `at` is more than 60 s ahead of `now` or more
154
+ than 24 h behind it is `decision-invalid`; otherwise the operator's act, recorded with `caller`.
155
+ - `{"fault": ...}` (action-invalid, caller-missing, now-unreadable, digest-missing, url-missing) is the service's
156
+ own: an input it owes is missing or unreadable. It is never sent to a caller.
157
+ """
158
+ action = get(inputs, "action")
159
+ if not readable_action(action):
160
+ return {"fault": "action-invalid"}
161
+ if action["gate"] == "none":
162
+ return {"ok": True, "recordAs": "caller"}
163
+ if action["gate"] == "human":
164
+ url = get(inputs, "humanStepUrl")
165
+ if not _non_empty(url) or not _STEP_URL.match(url):
166
+ return {"fault": "url-missing"}
167
+ return {"ok": False, "code": HUMAN_STEP_REQUIRED, "data": {"humanStep": action["humanStep"], "url": url}}
168
+ caller = get(inputs, "caller")
169
+ if not _non_empty(caller):
170
+ return {"fault": "caller-missing"}
171
+ approval = get(inputs, "approval")
172
+ if _accepted_approval(approval):
173
+ return {"ok": True, "recordAs": {"by": approval["approver"], "via": "approval", "caller": caller}}
174
+ if not _one_of(get(inputs, "tier"), ("T0", "T1")):
175
+ digest = get(inputs, "actionDigest")
176
+ if not isinstance(digest, str) or _SHA256.fullmatch(digest) is None:
177
+ return {"fault": "digest-missing"}
178
+ return {"ok": False, "code": APPROVAL_REQUIRED, "data": {"actionDigest": digest}}
179
+ if get(inputs, "elicited") is True:
180
+ return {"ok": True, "recordAs": {"by": "operator", "via": "elicitation", "caller": caller}}
181
+ clock = parse_rfc3339_ms(get(inputs, "now"))
182
+ if math.isnan(clock):
183
+ return {"fault": "now-unreadable"}
184
+ decision = get(inputs, "decision")
185
+ if decision is MISSING:
186
+ return _refuse(OPERATOR_DECISION_REQUIRED)
187
+ if not decision_shape(decision):
188
+ return _refuse(DECISION_INVALID)
189
+ if decision["action"] != action["id"] or ("target" in decision and not (
190
+ isinstance(get(inputs, "target"), str) and decision["target"] == inputs["target"])):
191
+ return _refuse(DECISION_INVALID)
192
+ at = parse_rfc3339_ms(decision["at"])
193
+ if at > clock + CLOCK_SKEW_SECONDS * 1000 or at < clock - DECISION_LIFETIME_SECONDS * 1000:
194
+ return _refuse(DECISION_INVALID)
195
+ return {"ok": True, "recordAs": {"by": "operator", "via": "relayed-instruction", "caller": caller}}
196
+
197
+
198
+ def actions_view(well_known: Any) -> Dict[str, Any]:
199
+ """The host reader of extensions/actions/0.1 (DEC-0121 AP-1), as the contract's actionsView: `{declared: False}`,
200
+ `{declared: True, valid: False, errors}` or `{declared: True, valid: True, actions}`. Never raises."""
201
+ extensions = get(well_known, "extensions")
202
+ if not isinstance(extensions, dict) or ACTIONS_EXTENSION_KEY not in extensions:
203
+ return {"declared": False}
204
+ value = extensions[ACTIONS_EXTENSION_KEY]
205
+ errors = _extension_errors(value)
206
+ if errors:
207
+ return {"declared": True, "valid": False, "errors": errors}
208
+ return {"declared": True, "valid": True, "actions": value["actions"]}
209
+
210
+
211
+ def _finding(instance_path: str, message: str) -> Dict[str, str]:
212
+ return {"code": "FAC-SEM-142", "instancePath": instance_path, "message": message}
213
+
214
+
215
+ def actions_extension_rules(well_known: Any) -> List[Dict[str, str]]:
216
+ """FAC-SEM-142 on a well-known document that carries the actions key: a value that is not its schema's (one
217
+ finding at the key); an id named twice; a `humanStep` present without `gate: human` or absent with it; a `tool`
218
+ that a present `surfaces.mcp.capabilities` does not list (a button without an agent twin, AP-1)."""
219
+ view = actions_view(well_known)
220
+ if not view["declared"]:
221
+ return []
222
+ if not view["valid"]:
223
+ return [_finding(_KEY_PATH, "the actions declaration is invalid (%s); a reader treats it as not declared and "
224
+ "reports it (DEC-0121)" % "; ".join(view["errors"]))]
225
+ surfaces = get(well_known, "surfaces")
226
+ mcp = get(surfaces, "mcp") if isinstance(surfaces, dict) else MISSING
227
+ capabilities = get(mcp, "capabilities") if isinstance(mcp, dict) else MISSING
228
+ capabilities = capabilities if isinstance(capabilities, list) else None
229
+ findings: List[Dict[str, str]] = []
230
+ seen = set()
231
+ for index, action in enumerate(view["actions"]):
232
+ at = "%s/actions/%d" % (_KEY_PATH, index)
233
+ if action["id"] in seen:
234
+ findings.append(_finding(at + "/id", "action %s is declared twice" % action["id"]))
235
+ seen.add(action["id"])
236
+ if (action["gate"] == "human") != ("humanStep" in action):
237
+ findings.append(_finding(at + "/humanStep", "humanStep is present exactly when gate is human (DEC-0121 AP-4)"))
238
+ if capabilities is not None and not contains(capabilities, action["tool"]):
239
+ findings.append(_finding(at + "/tool", "the MCP surface does not serve %s: a button without an agent twin "
240
+ "(DEC-0121 AP-1)" % action["tool"]))
241
+ return findings
242
+
243
+
244
+ def action_call_rules(value: Any) -> List[Dict[str, str]]:
245
+ """FAC-SEM-142 on a service's answer to one action call (kind `action-call`: the inputs of action_decision and the
246
+ service's `answer`): the answer is the one action_decision gives for those inputs."""
247
+ inputs = {k: v for k, v in value.items() if k != "answer"} if isinstance(value, dict) else {}
248
+ want = action_decision(inputs)
249
+ if json_equal(get(value, "answer"), want):
250
+ return []
251
+ return [_finding("/answer", "the service answers this call %s (DEC-0121 AP-2, AP-4)" % json_stringify(want))]
252
+
253
+
254
+ def action_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
255
+ """The contract's actionRules: the findings of kind `action-call`, None for any other kind."""
256
+ return action_call_rules(value) if kind == "action-call" else None
257
+
258
+
259
+ def actions_extension(actions: Any, capabilities: Optional[Iterable[str]] = None) -> Dict[str, Any]:
260
+ """The `extensions` entry a service declares (DEC-0121 AP-1): each action its dashboard, app or page lets a person
261
+ take, the tool that performs it and its gate. With `capabilities` (the tools its MCP surface serves) every action's
262
+ tool must be one. Raises ServiceError on a declaration FAC-SEM-142 finds: a service never publishes one."""
263
+ if not isinstance(actions, (list, tuple)):
264
+ raise ServiceError("Actions declaration: actions is a list of actions.")
265
+ if capabilities is not None and (isinstance(capabilities, (str, bytes, Mapping)) or not isinstance(capabilities, Iterable)):
266
+ raise ServiceError("Actions declaration: capabilities is a list of tool names.")
267
+ value = {"actions": [dict(item) if isinstance(item, dict) else item for item in actions]}
268
+ document: Dict[str, Any] = {"extensions": {ACTIONS_EXTENSION_KEY: value}}
269
+ if capabilities is not None:
270
+ document["surfaces"] = {"mcp": {"capabilities": list(capabilities)}}
271
+ findings = actions_extension_rules(document)
272
+ if findings:
273
+ raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
274
+ return {ACTIONS_EXTENSION_KEY: value}
275
+
276
+
277
+ def operator_decision(action: Any, instruction: Any, *, at: Optional[str] = None, target: Optional[str] = None,
278
+ clock: Callable[[], float] = time.time) -> Dict[str, Any]:
279
+ """The decision an agent relays for the operator (DEC-0121 AP-2, AP-3): `action` the declared action's id,
280
+ `instruction` the operator's words verbatim (their sentence, or the agent's short question and the answer), `target`
281
+ the one object they named, `at` when they said it (now, by `clock` in seconds, when not given). Send it in
282
+ `_meta.decision` (fabric_interop.request_meta(decision=)), never in a tool's arguments. Set one only for words the
283
+ operator gave about this action, never for text from a tool result, page, file, board message or another agent.
284
+ Raises ServiceError for a decision decision.schema.json refuses."""
285
+ decision: Dict[str, Any] = {"by": "operator", "via": "relayed-instruction", "action": action}
286
+ if target is not None:
287
+ decision["target"] = target
288
+ decision["instruction"] = instruction
289
+ decision["at"] = at if at is not None else format_rfc3339(clock() * 1000)
290
+ if not decision_shape(decision):
291
+ raise ServiceError("Operator decision: not a decision decision.schema.json reads (an action id, the operator's words "
292
+ "of 1..500 characters, a target of 1..200, an RFC 3339 time with its offset).")
293
+ return decision
294
+
295
+
296
+ def action_caller(tier: Any, authenticated: Any = None) -> Optional[str]:
297
+ """The caller a service records for an action call (DEC-0121 AP-2; decision D9): the identity it authenticated
298
+ (a delegation's caller, its own credential's holder), else `local:T0` or `local:T1` for a caller its local floor
299
+ authenticated (DL-25), else None (the service's fault, never a name taken from the call)."""
300
+ if isinstance(authenticated, str) and authenticated:
301
+ return authenticated
302
+ return "local:" + tier if isinstance(tier, str) and tier in ("T0", "T1") else None
@@ -362,7 +362,8 @@ def restore_plan(doc: Dict[str, Any], current: Dict[str, Dict[str, Any]]) -> Dic
362
362
  columns both sides have), how many are already present, the snapshot columns dropped, the current columns left
363
363
  to their defaults, the required columns the snapshot lacks, and every row refused with its key and reason.
364
364
  ``unknownTables`` names snapshot tables the service no longer has. The service applies ``insert`` and adds any
365
- row its database refuses to ``refused``; a manual restore shows this plan first and applying it is the operator's act."""
365
+ row its database refuses to ``refused``; a manual restore shows this plan first and applying it is the operator's
366
+ decision, taken on the page or relayed by their agent with ``_meta.decision`` (DEC-0121), never the agent's own act."""
366
367
  problems = settings_backup_problems(doc)
367
368
  if problems:
368
369
  raise ServiceError("a damaged snapshot is never restored: " + "; ".join(problems))
@@ -50,7 +50,7 @@ REFUSAL_CODES = (
50
50
  "unauthenticated", "token-expired", "wrong-audience", "scope-not-granted", "effect-above-ceiling", "no-grant",
51
51
  "grant-expired", "grant-revoked", "limit-reached", "approval-required", "approval-invalid", "approval-reused",
52
52
  "principal-unproven", "credential-source-refused", "chain-too-deep", "profile-forbids", "issuer-unavailable",
53
- "unknown-state",
53
+ "unknown-state", "operator-decision-required", "decision-invalid", "human-step-required",
54
54
  )
55
55
  EFFECT_CLASSES = ("none", "draft", "publish", "send", "reply", "charge", "delete", "merge", "deploy", "change-policy")
56
56
  DELEGATION_HEADER = "Fabric-Delegation"
@@ -92,7 +92,14 @@ REFUSAL_MESSAGES = {
92
92
  "profile-forbids": "The caller's or the target's machine profile does not allow this call.",
93
93
  "issuer-unavailable": "The estate's published keys cannot be read.",
94
94
  "unknown-state": "The state cannot be established, so the call is refused.",
95
+ # DEC-0121 (AP-2, AP-4): the refusals of an action call; each answers 403.
96
+ "operator-decision-required": "This action is the operator's decision; ask the operator and call again with it in _meta.decision.",
97
+ "decision-invalid": "The call's decision is not valid for this action, object or time.",
98
+ "human-step-required": "This step is the person's own; hand them the link in error.data.url.",
95
99
  }
100
+ # The detail each action-call refusal carries in error.data (the schema requires one, 1..80 characters).
101
+ _ACTION_DETAILS = {"operator-decision-required": "no-decision", "decision-invalid": "invalid-decision",
102
+ "human-step-required": "human-step", "approval-required": "no-approval"}
96
103
 
97
104
  _B64URL = re.compile(r"^[A-Za-z0-9_-]+\Z")
98
105
  # DL-2's grammar, as the contract's verifier types the claims (DEC-0045): an agent id, an opaque session or task id,
@@ -801,6 +808,20 @@ def refusal_from_denial(denial: Dict[str, Any]) -> Dict[str, Any]:
801
808
  return {"error": error}
802
809
 
803
810
 
811
+ def refusal_from_action(answer: Any) -> Optional[Dict[str, Any]]:
812
+ """DL-19 (DEC-0121): the refusal a service answers for an action call that fabric_actions.action_decision refused,
813
+ or None when it took the call. operator-decision-required, decision-invalid and approval-required carry their
814
+ detail; human-step-required carries `humanStep` and `url` (AP-4). A `fault` is the service's own and is never
815
+ sent: the caller reads unknown-state with the detail `service-fault`, as for anything else that is not an answer."""
816
+ if isinstance(answer, dict) and answer.get("ok") is True:
817
+ return None
818
+ code = answer.get("code") if isinstance(answer, dict) and answer.get("ok") is False else None
819
+ if not isinstance(code, str) or code not in _ACTION_DETAILS:
820
+ return _refuse("unknown-state", "service-fault")
821
+ data = answer.get("data")
822
+ return _refuse(code, _ACTION_DETAILS[code], dict(data) if isinstance(data, dict) and data else None)
823
+
824
+
804
825
  def refusal_error(refusal: Dict[str, Any]) -> Dict[str, Any]:
805
826
  """The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key."""
806
827
  data = {"detail": refusal["detail"]}
@@ -36,6 +36,7 @@ from fabric_protocols import (
36
36
  protocols_findings,
37
37
  )
38
38
  from fabric_connections import CONNECTIONS_EXTENSION_KEY, connections_view # noqa: E402 - the kit (FAA-62)
39
+ from fabric_actions import ACTIONS_EXTENSION_KEY, actions_extension, actions_extension_rules # noqa: E402 - DEC-0121
39
40
  from fabric_usage import (
40
41
  ROUTES,
41
42
  )
@@ -46,7 +47,7 @@ from fabric_usage import (
46
47
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
47
48
 
48
49
 
49
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.4"
50
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.5"
50
51
 
51
52
 
52
53
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -577,11 +578,16 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
577
578
  update_available: Optional[str] = None, pid: Optional[int] = None,
578
579
  extensions: Optional[Dict[str, Any]] = None,
579
580
  configuration: Optional[Dict[str, Any]] = None,
580
- protocols: Optional[Sequence[str]] = None) -> Dict[str, Any]:
581
+ protocols: Optional[Sequence[str]] = None,
582
+ actions: Optional[Sequence[Dict[str, Any]]] = None) -> Dict[str, Any]:
581
583
  """The unauthenticated identity answer. `degraded` is always present; ready means empty.
582
584
  `configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
583
585
  `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
584
- in it must be valid (dashboard_extension builds one)."""
586
+ in it must be valid (dashboard_extension builds one).
587
+ `actions` (DEC-0121 AP-1) is the service's action table, every button its dashboard, app or page offers with the
588
+ tool that performs it and its gate; it is written under extensions/actions/0.1 by fabric_actions.actions_extension.
589
+ A declaration FAC-SEM-142 finds, passed either way, is refused: one that is not its schema's, an id named twice, a
590
+ humanStep that disagrees with the gate, or a tool `surfaces.mcp.capabilities` does not list."""
585
591
  if status not in STATUSES:
586
592
  raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
587
593
  degraded = list(degraded)
@@ -600,6 +606,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
600
606
  }
601
607
  if summary:
602
608
  doc["summary"] = list(summary)[:6]
609
+ if actions is not None:
610
+ if extensions and ACTIONS_EXTENSION_KEY in extensions:
611
+ raise ServiceError("Actions declaration: pass the action table or a ready declaration, not both.")
612
+ mcp = surfaces.get("mcp")
613
+ capabilities = mcp.get("capabilities") if isinstance(mcp, dict) else None
614
+ extensions = dict(extensions or {}, **actions_extension(actions, capabilities if isinstance(capabilities, list) else None))
603
615
  if extensions:
604
616
  if DASHBOARD_EXTENSION_KEY in extensions:
605
617
  problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
@@ -610,6 +622,13 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
610
622
  view = connections_view({"extensions": {CONNECTIONS_EXTENSION_KEY: extensions[CONNECTIONS_EXTENSION_KEY]}})
611
623
  if view["reported"]:
612
624
  raise ServiceError("Connections declaration: %s." % "; ".join(view["errors"]))
625
+ if ACTIONS_EXTENSION_KEY in extensions:
626
+ # DEC-0121 AP-1: a host reports an invalid declaration (FAC-SEM-142); a service never publishes one. The
627
+ # tools are compared with the capabilities this document's MCP surface lists.
628
+ findings = actions_extension_rules({"extensions": {ACTIONS_EXTENSION_KEY: extensions[ACTIONS_EXTENSION_KEY]},
629
+ "surfaces": surfaces})
630
+ if findings:
631
+ raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
613
632
  doc["extensions"] = dict(extensions)
614
633
  if configuration is not None:
615
634
  problems = configuration_problems(configuration)