@passioncode-ai/fabric-agent-adapter 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.24.0 - 2026-10-13
4
+
5
+ An app's own descriptor (contract DEC-0069) and the descriptor for listings (DEC-0061) in both kits (FAA-34). The contract
6
+ pin does not move: both decisions are at `fe55fc6`.
7
+
8
+ ### Added
9
+
10
+ - **`lifecycle.manager` `node` and `person`.** `node`: Project Observatory's node supervises the service as its child,
11
+ with no supervisor file, so `label`, `plist`, `unit` and `task` are refused beside it. `person`: an app a person
12
+ installs and starts — `lifecycle.app` is required, `origin` is optional (its live origin is in the app's own liveness
13
+ file), and `installedBy` is `{by: "person", role, tool}`. `write_descriptor` / `writeDescriptor` claim no port for
14
+ it, and `check_service.py` reads it as installed, never running: `descriptor.app-installed`, no port claim, and the
15
+ well-known document `NOT_RUN`.
16
+ - **`installedBy` by role, `runner` and `peer`.**
17
+ - `installedBy` is a string or `{by, role?, tool}`.
18
+ - `runner` is `{kind?, credentialKind}`, a kind and never a credential.
19
+ - `peer` is `{endpoint, custody, residency, maxDataClass}`. An offer needs `runner` and never a subscription.
20
+ - FAC-SEM-085 refuses a peer on a reserved name. FAC-SEM-086 refuses a value shaped like a credential in `runner` or
21
+ `installedBy`, naming its place.
22
+ - **Ports held to the contract's vectors:** `peer_refusal` / `peerRefusal` (`service-peer.json`),
23
+ `app_descriptor_action` / `appDescriptorAction` and `app_descriptor_stale` / `appDescriptorStale`
24
+ (`app-descriptor.json`).
25
+ - **`fabric_button_presence` / `fabricButtonPresence`** answer whether Fabric's button may show. The answer is
26
+ `present` only while Fabric's own descriptor (`fabric.default.json`) is registered and, for an app's descriptor,
27
+ its app is still installed. A server sets `data-fabric="absent"` otherwise.
28
+ - **Usage:** `make_usage_receipt` / `makeUsageReceipt` accept `route: "platform-metered"`. A configuration's route
29
+ stays `subscription` or `key`.
30
+
31
+ ### Changed
32
+
33
+ - `register_remote` / `registerRemote` and the sample's `register` write `installedBy` in its object form
34
+ (`{by: "person", role: "operator", tool}`). `register_remote` / `registerRemote` also take `runner` and `peer`.
35
+
36
+ ### Fixed
37
+
38
+ - `check_service.py` reported two descriptors without an origin as claiming the same port.
39
+ - `write_descriptor` / `writeDescriptor` failed on a descriptor without one.
40
+
41
+ ### Evidence
42
+
43
+ - Every service-descriptor fixture of the contract at `fe55fc6` is held through both validators: 10 valid accepted;
44
+ every invalid one refused except the six local-channel negatives (DEC-0075, not a kit surface), a set the tests pin.
45
+ - The FAC-SEM-085/086 semantic fixtures answer at the contract's places.
46
+ - 32 planted defects, all caught.
47
+ - The scenario eval `app-descriptor-and-listing` was written before the prose.
48
+
3
49
  ## 0.23.0 - 2026-10-13
4
50
 
5
51
  The contract pin moves from `b291bf0` (DEC-0055) to contract `main` `fe55fc6`, and both kits follow every rule in that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.23.0",
6
+ "version": "0.24.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.23.0"
8
+ version: "0.24.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
11
11
  ---
@@ -16,7 +16,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
16
16
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
17
17
  metadata:
18
18
  author: PassionCode.ai
19
- version: "0.23.0"
19
+ version: "0.24.0"
20
20
  contract-version: "0.1.0"
21
21
  extension: "fabric-service/0.1"
22
22
  extension-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
@@ -323,8 +323,11 @@ this estate needs.
323
323
 
324
324
  ## When something is missing
325
325
 
326
- - **Not macOS / no launchd:** declare `lifecycle.manager: "none"`; hosts show the
327
- service read-only. Keep every other rule.
326
+ - **Not macOS / no launchd:** the kits write a systemd user unit (Linux) or a per-user Scheduled Task (Windows); with
327
+ no supervisor at all declare `lifecycle.manager: "none"`, and hosts show the service read-only. Keep every other rule.
328
+ - **A desktop app with no supervisor** (DEC-0069): its descriptor says `manager: "person"` with `lifecycle.app`, no
329
+ origin, `installedBy {by: "person", role, tool}`; its live origin is in its own liveness file
330
+ (the lifecycle reference, *An app's own descriptor and the node*).
328
331
  - **No Python:** use the Node kit; run the probe from any machine that has Python and
329
332
  can reach the port, or mark the probe `NOT_RUN` with the reason.
330
333
  - **Neither kit fits the framework:** implement the four routes and the lock by hand
@@ -100,10 +100,13 @@ service. Load it after the stylesheet, with ids only:
100
100
  — or one shaped like a credential — means no button, never a bad link. A press opens a view in Fabric and starts
101
101
  nothing (FB-8).
102
102
  - **The host (FB-6):** when the host's `fabric_button` cookie says `owner=host`, the page hides its own button.
103
- - **Fabric absent (FB-5):** when the cookie says `fabric=absent`, or `data-fabric="absent"` (a local service whose
104
- server checked for Fabric's hub document), or when the page is still visible and focused 1.5 s after a link was
105
- followed, the menu says "Fabric is not installed — get it at passioncode.ai/fabric". Nothing downloads, nothing
106
- retries.
103
+ - **Fabric absent (FB-5):** when the cookie says `fabric=absent`, or `data-fabric="absent"`, or when the page is still
104
+ visible and focused 1.5 s after a link was followed, the menu says "Fabric is not installed — get it at
105
+ passioncode.ai/fabric". Nothing downloads, nothing retries. A local service's server decides `data-fabric` with
106
+ `fabric_button_presence()` / `fabricButtonPresence()`: `present` while Fabric's own descriptor
107
+ (`services/fabric.default.json`, Fabric ADR-0139) is registered and, for an app's descriptor (`manager: "person"`,
108
+ DEC-0069), its app is still installed; `absent` otherwise. A press only follows a link, so the button never reads
109
+ Fabric's liveness file; a caller that sends Fabric anything reads that file and checks the process first.
107
110
 
108
111
  `window.FabricButton` exposes `buildFabricLink`, `parseFabricLink` and `parseButtonCookie` for a page that needs
109
112
  them. The button is optional: a page without the script has no button.
@@ -104,6 +104,22 @@ never infers supervision from its parent process.
104
104
  starts against a held lock are recorded and read as `FAC-SEM-078` reads them
105
105
  (`test/*supervisors*`).
106
106
 
107
+ ## An app's own descriptor and the node
108
+
109
+ **An app** a person installs and starts has no supervisor (contract DEC-0069). Its descriptor says
110
+ `"lifecycle": {"manager": "person", "app": "<the installed bundle or executable>"}`, `installedBy {by: "person", role,
111
+ tool}` and no `origin`: it says the app is installed, never that it runs. The installer writes it; where there is none
112
+ (a macOS app dragged into Applications), the app writes it on a start when `app_descriptor_action` /
113
+ `appDescriptorAction` answers `write` — no descriptor yet, another version in `installedBy.tool`, or another path than
114
+ `lifecycle.app` — and never from an App Translocation path. Its live origin and process id go in its own liveness file,
115
+ rewritten on every start and removed on quit; a caller that sends anything reads that file and checks the process.
116
+ `write_descriptor` claims no port for it, and a reader that finds `lifecycle.app` gone treats the descriptor as stale.
117
+
118
+ **The node** (DEC-0061): `"lifecycle": {"manager": "node"}` is a local service Project Observatory's node installed and
119
+ supervises as its child. There is no supervisor file, so `label`, `plist`, `unit` and `task` are refused beside it; the
120
+ node starts the child with the supervisor marker (`--fabric-supervisor node` or the variable) and owns Install, Off,
121
+ Code, Uninstall and state.
122
+
107
123
  ## Directories
108
124
 
109
125
  | What | macOS | Linux | Windows (DEC-0032) |
@@ -206,7 +222,9 @@ CI job has the real `schtasks` create, read back and delete such a task. The des
206
222
  A task cannot set an environment variable, so `task_xml` / `taskXml` write the marker as an
207
223
  argument, `--fabric-supervisor task-scheduler`, before any `--`: a copy the Task Scheduler starts
208
224
  backs off in-process like one under launchd or systemd. `IgnoreNew` keeps the scheduler from
209
- starting a second copy.
225
+ starting a second copy. Measured (FAA-38, [report](https://github.com/passioncode-ai/fabric-agent-adapter/blob/main/docs/reports/2026-10-13-faa-38-windows-exit75/README.md)):
226
+ `RestartOnFailure` does not restart a task whose action exits 75, so that wait is the only recovery there; a copy
227
+ that still exits 75 stays down until the next logon.
210
228
 
211
229
  **One copy on each system.** The Python kit takes `flock` (Linux) or a locked byte
212
230
  (Windows); the Node kit cannot, so it holds a name instead — an abstract unix socket on Linux,
@@ -31,13 +31,15 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
31
31
  | `protocol` | `"fabric-service/0.1"` |
32
32
  | `id`, `instance` | `^[a-z][a-z0-9-]{1,62}$`, `^[a-z][a-z0-9-]{0,31}$` (default `default`); the pair is unique per machine |
33
33
  | `name`, `summary` | ≤ 80 and ≤ 200 characters |
34
- | `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim |
34
+ | `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim. Optional for an app's descriptor only (`manager: "person"`, DEC-0069): its live origin and process id are in the app's own liveness file, and a descriptor without an origin claims no port (`FAC-SEM-010`) |
35
35
  | `auth` | `tokenFile` (0600; on Windows a protected ACL — owner and every granting entry the current user, SYSTEM or Administrators: service.md *Windows token files*, DEC-0032, DEC-0033); `header` default `Authorization` with `scheme` `Bearer`; any other header carries the raw token with `scheme: "none"` |
36
- | `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032). Both kits and the probe accept all four; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`) |
36
+ | `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task), `node` (Project Observatory's node supervises it as its child, with no supervisor file, DEC-0061), `person` (an app a person installs and starts, then `app`, the installed app, DEC-0069) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032), `label` and `plist` beside any but `launchd` (DEC-0057), `app` beside any but `person`. Both kits and the probe accept all six; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`). An app's descriptor says it is installed, never that it runs: the probe checks `app` is still there (`descriptor.app-installed`) and reads no well-known document; a reader that finds the app gone treats the descriptor as stale (`app_descriptor_stale` / `appDescriptorStale`), and an app that writes its own descriptor rewrites it only when `app_descriptor_action` / `appDescriptorAction` says `write` |
37
37
  | `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
38
38
  | `commands` | only `doctor` and `update`, argument arrays whose first item is an absolute or `~/` path (Windows: also `C:\…`, `C:/…` or `~\…`, never a network share — DEC-0032) |
39
39
  | `source.repository`, `fabricManifest` | optional |
40
- | `installedAt`, `installedBy` | when and by which installer version |
40
+ | `installedAt`, `installedBy` | when, and who installed it: the installer and its version as a string, or `{by, role?, tool}` (DEC-0061) — `by` `person` (with `role` `operator` or `administrator`), `node` or `organization`; `role` for a person only; `tool` the installer and its version. It names a role, never a person. An app's descriptor uses the object form with `by: "person"`. `register_remote` / `registerRemote` and the sample write the object form |
41
+ | `runner` | optional `{kind?, credentialKind}` (DEC-0061): `credentialKind` `subscription`, `key`, `platform-metered` or `none` — a kind, never a credential (`FAC-SEM-086` refuses a credential-shaped value here or in `installedBy`); a subscription names its vendor's runner (`claude-code`, `kimi-code`, `codex`) |
42
+ | `peer` | optional `{endpoint, custody, residency, maxDataClass}` (DEC-0061): the service is offered to others. `endpoint` `https://<dns-name>[:<port>][/<path>]`, never a reserved name (`FAC-SEM-085`); `custody` `organization-managed` or `personal`; `residency` 1–32 ISO 3166-1 alpha-2 codes, `EU` admitted, user-assigned codes refused; `maxDataClass` `public` < `internal` < `confidential` < `restricted`. An offer needs `runner` and never runs on a subscription. A host sends a request from anyone but the operator only when `peer_refusal` / `peerRefusal` answers null (`FAC-SEM-088`); usage rows may take `route: "platform-metered"` |
41
43
 
42
44
  ## Well-known document — `GET /.well-known/fabric-service`
43
45
 
@@ -189,10 +189,23 @@ class Probe:
189
189
  continue
190
190
  if key == me:
191
191
  clashes.append("%s declared again in %s" % (me, path.name))
192
- elif not self.remote and fs.placement_of(other) != "remote" and other.get("origin") == self.d.get("origin"):
192
+ # DEC-0069: a descriptor without an origin (an app's) claims no port, so two of them never clash.
193
+ elif (not self.remote and "origin" in self.d and fs.placement_of(other) != "remote"
194
+ and other.get("origin") == self.d.get("origin")):
193
195
  clashes.append("port %d also claimed by %s" % (self.port, key))
194
- unique = "a remote origin claims no port here" if self.remote else "port %d is unique" % self.port
196
+ if self.remote:
197
+ unique = "a remote origin claims no port here"
198
+ elif "origin" not in self.d:
199
+ unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
200
+ else:
201
+ unique = "port %d is unique" % self.port
195
202
  self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
203
+ if (self.d.get("lifecycle") or {}).get("manager") == "person":
204
+ # DEC-0069: installed, never running — a reader checks the app is still there; liveness is the app's own file.
205
+ present = fs.app_present(self.d)
206
+ self.add("descriptor.app-installed", "PASS" if present else "FAIL",
207
+ "%s is installed" % self.d["lifecycle"].get("app") if present
208
+ else "%s is gone: a reader treats this descriptor as stale" % self.d["lifecycle"].get("app"))
196
209
 
197
210
  # well-known ----------------------------------------------------------------
198
211
  def well_known_rules(self) -> None:
@@ -762,6 +775,10 @@ class Probe:
762
775
 
763
776
  def run(self) -> List[Result]:
764
777
  self.descriptor_rules()
778
+ if "origin" not in self.d and not self.remote:
779
+ self.add("well-known.answers", "NOT_RUN",
780
+ "an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file (DEC-0069)")
781
+ return self.results
765
782
  if not self.port:
766
783
  return self.results
767
784
  self.well_known_rules()
@@ -54,7 +54,11 @@ const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from f
54
54
  // DEC-0032: a local path starts with ~/ (or ~\ on Windows), / (never //) or a drive letter; a network share never does.
55
55
  // DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
56
56
  const LOCAL_PATH = /^(~[\\/]|\/(?![\\/])|[A-Za-z]:[\\/])/;
57
- export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'none']);
57
+ // DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app a
58
+ // person installs and starts; its descriptor says it is installed, never that it runs).
59
+ export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node', 'person', 'none']);
60
+ /** Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069). */
61
+ const NOT_LAUNCHD = Object.freeze(['systemd', 'task-scheduler', 'node', 'person']);
58
62
  const AUTH_HEADER = /^[A-Za-z][A-Za-z0-9-]{0,63}$/;
59
63
  const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
60
64
  const TASK = /^\\[A-Za-z0-9 ._\\-]{1,254}$/;
@@ -521,7 +525,9 @@ export function validateDescriptor(d) {
521
525
  const problems = [];
522
526
  const remote = placementOf(d) === 'remote';
523
527
  if (d.placement !== undefined && !PLACEMENTS.includes(d.placement)) problems.push('placement must be local or remote');
524
- const required = ['protocol', 'id', 'instance', 'name', 'origin', 'auth', 'lifecycle', 'installedAt', 'installedBy'];
528
+ // DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
529
+ const app = d.lifecycle?.manager === 'person';
530
+ const required = ['protocol', 'id', 'instance', 'name', ...(app ? [] : ['origin']), 'auth', 'lifecycle', 'installedAt', 'installedBy'];
525
531
  for (const key of remote ? required : [...required, 'paths']) {
526
532
  if (!(key in d)) problems.push(`missing ${key}`);
527
533
  }
@@ -530,7 +536,7 @@ export function validateDescriptor(d) {
530
536
  if (!ID.test(d.id)) problems.push(`id must match ${ID}`);
531
537
  if (!INSTANCE.test(d.instance)) problems.push(`instance must match ${INSTANCE}`);
532
538
  if (remote) problems.push(...remoteOriginProblems(d.origin));
533
- else if (!ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
539
+ else if (d.origin !== undefined && !ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
534
540
  if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
535
541
  else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be a local path, never a network share');
536
542
  for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
@@ -543,14 +549,18 @@ export function validateDescriptor(d) {
543
549
  }
544
550
  const life = d.lifecycle ?? {};
545
551
  const manager = life.manager;
546
- if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler or none');
552
+ if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none');
553
+ // DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
554
+ if (manager === 'person' && life.app === undefined) problems.push("a person's app declares lifecycle.app, the installed app");
555
+ if (life.app !== undefined && manager !== 'person') problems.push('lifecycle.app belongs to manager person only');
556
+ if (life.app !== undefined && !LOCAL_PATH.test(String(life.app))) problems.push('lifecycle.app must be a local path');
547
557
  if (manager === 'launchd' && !(life.label && String(life.plist ?? '').endsWith('.plist'))) {
548
558
  problems.push('a launchd service declares label and plist');
549
559
  }
550
560
  if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
551
- // DEC-0057: a descriptor names one supervisor; launchd's label and plist never sit beside another.
561
+ // DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
552
562
  for (const field of ['label', 'plist']) {
553
- if (life[field] !== undefined && ['systemd', 'task-scheduler'].includes(manager)) problems.push(`lifecycle.${field} belongs to launchd, not to ${manager}`);
563
+ if (life[field] !== undefined && NOT_LAUNCHD.includes(manager)) problems.push(`lifecycle.${field} belongs to launchd, not to ${manager}`);
554
564
  }
555
565
  // DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
556
566
  for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
@@ -565,6 +575,7 @@ export function validateDescriptor(d) {
565
575
  }
566
576
  if (d.commands?.update !== undefined) problems.push('a remote service declares no update command');
567
577
  }
578
+ problems.push(...descriptorMemberProblems(d));
568
579
  for (const [name, argv] of Object.entries(d.commands ?? {})) {
569
580
  if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
570
581
  else if (!Array.isArray(argv) || !argv.length || !argv.every((a) => typeof a === 'string')) problems.push(`command ${name} must be an argument array`);
@@ -574,6 +585,127 @@ export function validateDescriptor(d) {
574
585
  }
575
586
 
576
587
  /** DEC-0019: `local` unless the descriptor says `remote`. */
588
+ // --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
589
+
590
+ /** The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053). */
591
+ export const PEER_DATA_CLASSES = Object.freeze(['public', 'internal', 'confidential', 'restricted']);
592
+ /** The kind of a runner's sign-in, never a credential's value (DEC-0061). */
593
+ export const CREDENTIAL_KINDS = Object.freeze(['subscription', 'key', 'platform-metered', 'none']);
594
+ /** A subscription runs only in its vendor's runner (DEC-0046): runner kind → login. */
595
+ export const SUBSCRIPTION_RUNNERS = Object.freeze({ 'claude-code': 'claude', 'kimi-code': 'kimi', codex: 'chatgpt' });
596
+ const PEER_ENDPOINT = /^https:\/\/((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$/;
597
+ const RESIDENCY = /^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$/;
598
+ const RUNNER_KIND_NAME = /^[a-z][a-z0-9-]{1,62}$/;
599
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
600
+ const text = (v, low = 1, high = 200) => typeof v === 'string' && v.length >= low && v.length <= high;
601
+ /** A JSON Pointer token (RFC 6901 §4). */
602
+ const pointer = (name) => String(name).replace(/~/g, '~0').replace(/\//g, '~1');
603
+
604
+ /**
605
+ * DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with FAC-SEM-085
606
+ * (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`, named by its
607
+ * place, never its value).
608
+ */
609
+ export function descriptorMemberProblems(d) {
610
+ const problems = [];
611
+ const life = isObj(d.lifecycle) ? d.lifecycle : {};
612
+ const by = d.installedBy;
613
+ const places = [];
614
+ if (typeof by === 'string') {
615
+ if (!text(by)) problems.push('installedBy is the installer and its version, 1 to 200 characters');
616
+ places.push(['/installedBy', by]);
617
+ } else if (isObj(by)) {
618
+ for (const k of Object.keys(by)) if (!['by', 'role', 'tool'].includes(k)) problems.push(`installedBy has no member ${k}`);
619
+ if (!['person', 'node', 'organization'].includes(by.by)) problems.push('installedBy.by is person, node or organization');
620
+ if (by.by === 'person' && !['operator', 'administrator'].includes(by.role)) problems.push('installedBy.role is operator or administrator for a person');
621
+ if (by.by !== 'person' && 'role' in by) problems.push('installedBy.role belongs to a person only');
622
+ if (!text(by.tool)) problems.push('installedBy.tool is the installer and its version, 1 to 200 characters');
623
+ for (const [k, v] of Object.entries(by)) places.push([`/installedBy/${pointer(k)}`, v]);
624
+ } else problems.push("installedBy is the installer's name or {by, role?, tool}");
625
+ if (life.manager === 'person' && !(isObj(by) && by.by === 'person')) problems.push("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)");
626
+ let runner = d.runner;
627
+ if ('runner' in d) {
628
+ if (!isObj(runner)) { problems.push('runner is {kind?, credentialKind}'); runner = {}; }
629
+ for (const k of Object.keys(runner)) if (!['kind', 'credentialKind'].includes(k)) problems.push(`runner has no member ${k}`);
630
+ if (!CREDENTIAL_KINDS.includes(runner.credentialKind)) problems.push('runner.credentialKind is subscription, key, platform-metered or none');
631
+ if ('kind' in runner && !(typeof runner.kind === 'string' && RUNNER_KIND_NAME.test(runner.kind))) problems.push("runner.kind is a runner kind's name");
632
+ if (runner.credentialKind === 'subscription' && !Object.hasOwn(SUBSCRIPTION_RUNNERS, runner.kind)) problems.push("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex");
633
+ for (const [k, v] of Object.entries(runner)) places.push([`/runner/${pointer(k)}`, v]);
634
+ }
635
+ if ('peer' in d) {
636
+ let peer = d.peer;
637
+ if (!isObj(peer)) { problems.push('peer is {endpoint, custody, residency, maxDataClass}'); peer = {}; }
638
+ for (const k of Object.keys(peer)) if (!['endpoint', 'custody', 'residency', 'maxDataClass'].includes(k)) problems.push(`peer has no member ${k}`);
639
+ const m = typeof peer.endpoint === 'string' && peer.endpoint.length <= 2048 ? PEER_ENDPOINT.exec(peer.endpoint) : null;
640
+ if (!m) problems.push('peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal');
641
+ else if (RESERVED_HOST.test(m[1])) problems.push(`FAC-SEM-085: a service offered to others cannot be reached at the reserved name ${m[1]}`);
642
+ if (!['organization-managed', 'personal'].includes(peer.custody)) problems.push('peer.custody is organization-managed or personal');
643
+ const r = peer.residency;
644
+ if (!Array.isArray(r) || r.length < 1 || r.length > 32 || new Set(r.map((x) => JSON.stringify(x))).size !== r.length || !r.every((x) => typeof x === 'string' && RESIDENCY.test(x))) {
645
+ problems.push('peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code');
646
+ }
647
+ if (!PEER_DATA_CLASSES.includes(peer.maxDataClass)) problems.push('peer.maxDataClass is public, internal, confidential or restricted');
648
+ if (!('runner' in d)) problems.push('a service offered to others declares runner (DEC-0061)');
649
+ else if (isObj(runner) && runner.credentialKind === 'subscription') problems.push('a service offered to others never runs on a subscription (DEC-0061)');
650
+ }
651
+ for (const [place, value] of places) {
652
+ if (typeof value === 'string' && secretArgument(value)) problems.push(`FAC-SEM-086: ${place} holds a value shaped like a credential; it names a kind, a role or an installer`);
653
+ }
654
+ return problems;
655
+ }
656
+
657
+ /**
658
+ * The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
659
+ * `not-offered`, `peer-invalid`, `data-class-unknown` or `above-max-data-class`; null when it may. The contract's `peerRefusal`.
660
+ */
661
+ export function peerRefusal(descriptor, dataClass) {
662
+ if (!isObj(descriptor) || !isObj(descriptor.peer)) return 'not-offered';
663
+ const max = typeof descriptor.peer.maxDataClass === 'string' ? PEER_DATA_CLASSES.indexOf(descriptor.peer.maxDataClass) : -1;
664
+ if (max < 0) return 'peer-invalid';
665
+ const asked = typeof dataClass === 'string' ? PEER_DATA_CLASSES.indexOf(dataClass) : -1;
666
+ if (asked < 0) return 'data-class-unknown';
667
+ return asked > max ? 'above-max-data-class' : null;
668
+ }
669
+
670
+ /**
671
+ * What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` from a translocated copy (macOS
672
+ * App Translocation); otherwise `write` when there is none, when it is not an app's descriptor, or when its version
673
+ * (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's `appDescriptorAction`.
674
+ */
675
+ export function appDescriptorAction(existing, tool, appPath) {
676
+ if (appPath.includes('/AppTranslocation/')) return 'keep';
677
+ if (!isObj(existing) || !isObj(existing.installedBy)) return 'write';
678
+ const recorded = isObj(existing.lifecycle) ? existing.lifecycle.app : undefined;
679
+ return existing.installedBy.tool === tool && recorded === appPath ? 'keep' : 'write';
680
+ }
681
+
682
+ /** Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find. */
683
+ export function appDescriptorStale(descriptor, appPresent) {
684
+ if (!isObj(descriptor) || !isObj(descriptor.lifecycle) || descriptor.lifecycle.manager !== 'person') return false;
685
+ return !appPresent;
686
+ }
687
+
688
+ /** Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file). */
689
+ export function appPresent(descriptor) {
690
+ const app = isObj(descriptor?.lifecycle) ? descriptor.lifecycle.app : undefined;
691
+ try { return typeof app === 'string' && fs.existsSync(expand(app)); } catch { return false; }
692
+ }
693
+
694
+ /** Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`. */
695
+ export const FABRIC_SERVICE = Object.freeze(['fabric', 'default']);
696
+
697
+ /**
698
+ * `present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
699
+ * descriptor is registered and, for an app's descriptor, its app is still installed; a server sets `data-fabric="absent"`
700
+ * on the button's script otherwise. The button only follows a link, so the liveness file is not read here.
701
+ */
702
+ export function fabricButtonPresence(dir = servicesDir()) {
703
+ let found;
704
+ try { found = JSON.parse(fs.readFileSync(descriptorPath(...FABRIC_SERVICE, dir), 'utf8')); } catch { return 'absent'; }
705
+ if (!isObj(found) || validateDescriptor(found).length) return 'absent';
706
+ return appDescriptorStale(found, appPresent(found)) ? 'absent' : 'present';
707
+ }
708
+
577
709
  export function placementOf(d) {
578
710
  return d?.placement === 'remote' ? 'remote' : 'local';
579
711
  }
@@ -597,8 +729,8 @@ export function writeDescriptor(d, dir = servicesDir()) {
597
729
  const problems = validateDescriptor(d);
598
730
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
599
731
  const me = `${d.id}.${d.instance}`;
600
- // DEC-0019: only a local placement claims a port on this computer.
601
- const port = placementOf(d) === 'remote' ? null : portOf(d.origin);
732
+ // DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
733
+ const port = placementOf(d) === 'remote' || d.origin === undefined ? null : portOf(d.origin);
602
734
  for (const [file, other] of readDescriptors(dir)) {
603
735
  const key = `${other.id}.${other.instance ?? 'default'}`;
604
736
  if (key === me) continue;
@@ -661,6 +793,8 @@ const PROFILE_ID = /^[a-z][a-z0-9-]{0,62}$/;
661
793
  const RUNNER_KIND = /^[a-z][a-z0-9-]{1,62}$/;
662
794
  const MODEL_ID = /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._:-]{0,127}$/;
663
795
  export const ROUTES = Object.freeze(['subscription', 'key']);
796
+ /** A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models. */
797
+ export const USAGE_ROUTES = Object.freeze([...ROUTES, 'platform-metered']);
664
798
 
665
799
  /** DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it. */
666
800
  export function configurationProblems(doc) {
@@ -766,7 +900,7 @@ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, c
766
900
  if (costUsd !== null && (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd < 0)) throw new ServiceError('costUsd must be a non-negative number or null.');
767
901
  const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
768
902
  if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
769
- if (route !== undefined && !ROUTES.includes(route)) throw new ServiceError('route is subscription or key (DEC-0046).');
903
+ if (route !== undefined && !USAGE_ROUTES.includes(route)) throw new ServiceError('route is subscription, key or platform-metered (DEC-0046, DEC-0061).');
770
904
  return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(route === undefined ? {} : { route }) };
771
905
  }
772
906
 
@@ -1650,13 +1784,20 @@ export function remoteTokenFile(id, instance = 'default', dir = servicesDir()) {
1650
1784
  * a secret. Returns the descriptor path. `token` is read from the caller — a file or stdin —
1651
1785
  * never from an argument vector.
1652
1786
  */
1653
- export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(), installedBy = 'fabric-service register-remote' }) {
1787
+ /**
1788
+ * DEC-0019, installer side: register an online service on THIS computer — the token file (0600, never printed) and the
1789
+ * descriptor. DEC-0061: `installedBy` defaults to `{by: 'person', role: 'operator', tool: 'fabric-service register-remote'}`
1790
+ * (a person runs this); `runner` (`{kind?, credentialKind}`) says how its model calls sign in, as a kind and never a
1791
+ * credential; `peer` (`{endpoint, custody, residency, maxDataClass}`) offers it to others and needs `runner`.
1792
+ */
1793
+ export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(),
1794
+ installedBy = { by: 'person', role: 'operator', tool: 'fabric-service register-remote' }, runner, peer }) {
1654
1795
  if (!token || String(token).trim().length < 16) throw new ServiceError('the service token must be at least 16 characters.');
1655
1796
  const tokenFile = remoteTokenFile(id, instance, dir);
1656
1797
  const d = {
1657
1798
  protocol: PROTOCOL, id, instance, name, ...(summary ? { summary } : {}), placement: 'remote', origin,
1658
1799
  auth: { tokenFile }, lifecycle: { manager: 'none' }, ...(doctor ? { commands: { doctor } } : {}),
1659
- installedAt: nowIso(), installedBy,
1800
+ installedAt: nowIso(), installedBy, ...(runner ? { runner: { ...runner } } : {}), ...(peer ? { peer: { ...peer } } : {}),
1660
1801
  };
1661
1802
  const problems = validateDescriptor(d);
1662
1803
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
@@ -90,7 +90,11 @@ _SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
90
90
  # DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
91
91
  _LOCAL_PATH = re.compile(r"^(~[\\/]|/(?![\\/])|[A-Za-z]:[\\/])")
92
92
  # DEC-0032: the supervisor per system and the field that names its job.
93
- MANAGERS = ("launchd", "systemd", "task-scheduler", "none")
93
+ # DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app
94
+ # a person installs and starts; its descriptor says it is installed, never that it runs).
95
+ MANAGERS = ("launchd", "systemd", "task-scheduler", "node", "person", "none")
96
+ #: Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069).
97
+ _NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
94
98
  _AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
95
99
  _UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$")
96
100
  _TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}$")
@@ -886,7 +890,10 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
886
890
  remote = placement_of(descriptor) == "remote"
887
891
  if "placement" in descriptor and descriptor["placement"] not in PLACEMENTS:
888
892
  problems.append("placement must be local or remote")
889
- required = ("protocol", "id", "instance", "name", "origin", "auth", "lifecycle", "installedAt", "installedBy")
893
+ life_in = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
894
+ app = life_in.get("manager") == "person"
895
+ # DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
896
+ required = ("protocol", "id", "instance", "name", "auth", "lifecycle", "installedAt", "installedBy") + (() if app else ("origin",))
890
897
  if not remote:
891
898
  required = required + ("paths",)
892
899
  for key in required:
@@ -902,7 +909,7 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
902
909
  problems.append("instance must match %s" % _INSTANCE.pattern)
903
910
  if remote:
904
911
  problems.extend(remote_origin_problems(descriptor["origin"]))
905
- elif not _ORIGIN.match(str(descriptor["origin"])):
912
+ elif "origin" in descriptor and not _ORIGIN.match(str(descriptor["origin"])):
906
913
  problems.append("origin must be http://127.0.0.1:<port>")
907
914
  auth = descriptor.get("auth") or {}
908
915
  if "tokenFile" not in auth:
@@ -924,14 +931,21 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
924
931
  life = descriptor.get("lifecycle") or {}
925
932
  manager = life.get("manager")
926
933
  if manager not in MANAGERS:
927
- problems.append("lifecycle.manager must be launchd, systemd, task-scheduler or none")
934
+ problems.append("lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none")
935
+ # DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
936
+ if manager == "person" and "app" not in life:
937
+ problems.append("a person's app declares lifecycle.app, the installed app")
938
+ if "app" in life and manager != "person":
939
+ problems.append("lifecycle.app belongs to manager person only")
940
+ if "app" in life and not _LOCAL_PATH.match(str(life["app"])):
941
+ problems.append("lifecycle.app must be a local path")
928
942
  if manager == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
929
943
  problems.append("a launchd service declares label and plist")
930
944
  if "plist" in life and not _LOCAL_PATH.match(str(life["plist"])):
931
945
  problems.append("lifecycle.plist must be a local path")
932
- # DEC-0057: a descriptor names one supervisor; launchd's label and plist never sit beside another.
946
+ # DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
933
947
  for field in ("label", "plist"):
934
- if field in life and manager in ("systemd", "task-scheduler"):
948
+ if field in life and manager in _NOT_LAUNCHD:
935
949
  problems.append("lifecycle.%s belongs to launchd, not to %s" % (field, manager))
936
950
  # DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
937
951
  for field, owner, pattern in (("unit", "systemd", _UNIT), ("task", "task-scheduler", _TASK)):
@@ -949,6 +963,7 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
949
963
  problems.append("a remote service has no %s %s" % (of, field))
950
964
  if "update" in (descriptor.get("commands") or {}):
951
965
  problems.append("a remote service declares no update command")
966
+ problems.extend(descriptor_member_problems(descriptor))
952
967
  for name, argv in (descriptor.get("commands") or {}).items():
953
968
  if name not in ("doctor", "update"):
954
969
  problems.append("unknown command %s" % name)
@@ -959,6 +974,166 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
959
974
  return problems
960
975
 
961
976
 
977
+ # --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
978
+
979
+ #: The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053).
980
+ PEER_DATA_CLASSES = ("public", "internal", "confidential", "restricted")
981
+ #: The kind of a runner's sign-in, never a credential's value (DEC-0061).
982
+ CREDENTIAL_KINDS = ("subscription", "key", "platform-metered", "none")
983
+ #: A subscription runs only in its vendor's runner (DEC-0046): runner kind → login.
984
+ SUBSCRIPTION_RUNNERS = {"claude-code": "claude", "kimi-code": "kimi", "codex": "chatgpt"}
985
+ _PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$", re.ASCII)
986
+ _RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$", re.ASCII)
987
+ _RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}$", re.ASCII)
988
+
989
+
990
+ def _pointer(name: str) -> str:
991
+ """A JSON Pointer token (RFC 6901 §4)."""
992
+ return str(name).replace("~", "~0").replace("/", "~1")
993
+
994
+
995
+ def _text(value: Any, low: int = 1, high: int = 200) -> bool:
996
+ return isinstance(value, str) and low <= len(value) <= high
997
+
998
+
999
+ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
1000
+ """DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with
1001
+ FAC-SEM-085 (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`,
1002
+ named by its place, never its value)."""
1003
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
1004
+ problems: List[str] = []
1005
+ life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
1006
+ by = descriptor.get("installedBy")
1007
+ places: List[Tuple[str, Any]] = []
1008
+ if isinstance(by, str):
1009
+ if not _text(by):
1010
+ problems.append("installedBy is the installer and its version, 1 to 200 characters")
1011
+ places.append(("/installedBy", by))
1012
+ elif isinstance(by, dict):
1013
+ problems += ["installedBy has no member %s" % k for k in by if k not in ("by", "role", "tool")]
1014
+ if by.get("by") not in ("person", "node", "organization"):
1015
+ problems.append("installedBy.by is person, node or organization")
1016
+ if by.get("by") == "person" and by.get("role") not in ("operator", "administrator"):
1017
+ problems.append("installedBy.role is operator or administrator for a person")
1018
+ if by.get("by") != "person" and "role" in by:
1019
+ problems.append("installedBy.role belongs to a person only")
1020
+ if not _text(by.get("tool")):
1021
+ problems.append("installedBy.tool is the installer and its version, 1 to 200 characters")
1022
+ places += [("/installedBy/%s" % _pointer(k), v) for k, v in by.items()]
1023
+ else:
1024
+ problems.append("installedBy is the installer's name or {by, role?, tool}")
1025
+ if life.get("manager") == "person" and not (isinstance(by, dict) and by.get("by") == "person"):
1026
+ problems.append("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)")
1027
+ runner = descriptor.get("runner")
1028
+ if "runner" in descriptor:
1029
+ if not isinstance(runner, dict):
1030
+ problems.append("runner is {kind?, credentialKind}")
1031
+ runner = {}
1032
+ problems += ["runner has no member %s" % k for k in runner if k not in ("kind", "credentialKind")]
1033
+ if runner.get("credentialKind") not in CREDENTIAL_KINDS:
1034
+ problems.append("runner.credentialKind is subscription, key, platform-metered or none")
1035
+ if "kind" in runner and not (isinstance(runner["kind"], str) and _RUNNER_KIND_NAME.match(runner["kind"])):
1036
+ problems.append("runner.kind is a runner kind's name")
1037
+ if runner.get("credentialKind") == "subscription" and runner.get("kind") not in SUBSCRIPTION_RUNNERS:
1038
+ problems.append("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex")
1039
+ places += [("/runner/%s" % _pointer(k), v) for k, v in runner.items()]
1040
+ if "peer" in descriptor:
1041
+ peer = descriptor.get("peer")
1042
+ if not isinstance(peer, dict):
1043
+ problems.append("peer is {endpoint, custody, residency, maxDataClass}")
1044
+ peer = {}
1045
+ problems += ["peer has no member %s" % k for k in peer if k not in ("endpoint", "custody", "residency", "maxDataClass")]
1046
+ endpoint = peer.get("endpoint")
1047
+ match = _PEER_ENDPOINT.match(endpoint) if isinstance(endpoint, str) and len(endpoint) <= 2048 else None
1048
+ if not match:
1049
+ problems.append("peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal")
1050
+ elif _RESERVED_HOST.search(match.group("host")):
1051
+ problems.append("FAC-SEM-085: a service offered to others cannot be reached at the reserved name %s" % match.group("host"))
1052
+ if peer.get("custody") not in ("organization-managed", "personal"):
1053
+ problems.append("peer.custody is organization-managed or personal")
1054
+ residency = peer.get("residency")
1055
+ if (not isinstance(residency, list) or not 1 <= len(residency) <= 32 or len(set(map(json.dumps, residency))) != len(residency)
1056
+ or not all(isinstance(r, str) and _RESIDENCY.match(r) for r in residency)):
1057
+ problems.append("peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code")
1058
+ if peer.get("maxDataClass") not in PEER_DATA_CLASSES:
1059
+ problems.append("peer.maxDataClass is public, internal, confidential or restricted")
1060
+ if "runner" not in descriptor:
1061
+ problems.append("a service offered to others declares runner (DEC-0061)")
1062
+ elif isinstance(runner, dict) and runner.get("credentialKind") == "subscription":
1063
+ problems.append("a service offered to others never runs on a subscription (DEC-0061)")
1064
+ for place, value in places:
1065
+ if isinstance(value, str) and fabric_secrets.secret_argument(value):
1066
+ problems.append("FAC-SEM-086: %s holds a value shaped like a credential; it names a kind, a role or an installer" % place)
1067
+ return problems
1068
+
1069
+
1070
+ def peer_refusal(descriptor: Any, data_class: Any) -> Optional[str]:
1071
+ """The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
1072
+ `not-offered` (no `peer`), `peer-invalid` (its maximum is not one of the four classes), `data-class-unknown` (the
1073
+ request's class is not one of them, exactly as written) or `above-max-data-class`; None when it may. The contract's
1074
+ `peerRefusal`."""
1075
+ if not isinstance(descriptor, dict) or not isinstance(descriptor.get("peer"), dict):
1076
+ return "not-offered"
1077
+ ceiling = descriptor["peer"].get("maxDataClass")
1078
+ if not isinstance(ceiling, str) or ceiling not in PEER_DATA_CLASSES:
1079
+ return "peer-invalid"
1080
+ if not isinstance(data_class, str) or data_class not in PEER_DATA_CLASSES:
1081
+ return "data-class-unknown"
1082
+ return "above-max-data-class" if PEER_DATA_CLASSES.index(data_class) > PEER_DATA_CLASSES.index(ceiling) else None
1083
+
1084
+
1085
+ def app_descriptor_action(existing: Any, tool: str, app_path: str) -> str:
1086
+ """What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` when it runs from a
1087
+ translocated copy (macOS App Translocation); otherwise `write` when there is none, when it is not an app's descriptor,
1088
+ when its version (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's
1089
+ `appDescriptorAction`."""
1090
+ if "/AppTranslocation/" in app_path:
1091
+ return "keep"
1092
+ if not isinstance(existing, dict) or not isinstance(existing.get("installedBy"), dict):
1093
+ return "write"
1094
+ recorded = existing["lifecycle"].get("app", _MISSING_APP) if isinstance(existing.get("lifecycle"), dict) else _MISSING_APP
1095
+ return "keep" if existing["installedBy"].get("tool", _MISSING_APP) == tool and recorded == app_path else "write"
1096
+
1097
+
1098
+ _MISSING_APP = object()
1099
+
1100
+
1101
+ def app_descriptor_stale(descriptor: Any, app_present: bool) -> bool:
1102
+ """Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find.
1103
+ A supervised service's descriptor is never stale by this test. The contract's `appDescriptorStale`."""
1104
+ life = descriptor.get("lifecycle") if isinstance(descriptor, dict) else None
1105
+ return isinstance(life, dict) and life.get("manager") == "person" and not app_present
1106
+
1107
+
1108
+ def app_present(descriptor: Dict[str, Any]) -> bool:
1109
+ """Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file)."""
1110
+ life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
1111
+ app = life.get("app")
1112
+ try:
1113
+ return isinstance(app, str) and expand(app).exists()
1114
+ except ServiceError: # not a local path: nothing installed there
1115
+ return False
1116
+
1117
+
1118
+ #: Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`.
1119
+ FABRIC_SERVICE = ("fabric", "default")
1120
+
1121
+
1122
+ def fabric_button_presence(directory: Optional[Path] = None) -> str:
1123
+ """`present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
1124
+ descriptor is registered and, for an app's descriptor, its app is still installed. A server sets
1125
+ `data-fabric="absent"` on the button's script otherwise. The button only follows a link, so the liveness file is
1126
+ not read here; a caller that sends anything reads it."""
1127
+ path = descriptor_path(*FABRIC_SERVICE, directory=directory)
1128
+ try:
1129
+ found = json.loads(path.read_text(encoding="utf-8"))
1130
+ except (OSError, ValueError):
1131
+ return "absent"
1132
+ if not isinstance(found, dict) or validate_descriptor(found):
1133
+ return "absent"
1134
+ return "absent" if app_descriptor_stale(found, app_present(found)) else "present"
1135
+
1136
+
962
1137
  def placement_of(descriptor: Dict[str, Any]) -> str:
963
1138
  """DEC-0019: ``local`` unless the descriptor says ``remote``."""
964
1139
  return "remote" if (descriptor or {}).get("placement") == "remote" else "local"
@@ -990,11 +1165,11 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
990
1165
  raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
991
1166
  root = directory or services_dir()
992
1167
  me = "%s.%s" % (descriptor["id"], descriptor["instance"])
993
- # DEC-0019: only a local placement claims a port on this computer.
994
- port = None if placement_of(descriptor) == "remote" else port_of(descriptor["origin"])
1168
+ # DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
1169
+ port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
995
1170
  for path, other in read_descriptors(root):
996
1171
  key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
997
- if key == me or port is None: # a remote origin's port is another computer's
1172
+ if key == me or port is None: # a remote origin's port is another computer's; an app claims none
998
1173
  continue
999
1174
  try:
1000
1175
  other_port = port_of(str(other.get("origin", "")))
@@ -1096,6 +1271,8 @@ _PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
1096
1271
  _RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
1097
1272
  _MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}$")
1098
1273
  ROUTES = ("subscription", "key")
1274
+ #: A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models.
1275
+ USAGE_ROUTES = ROUTES + ("platform-metered",)
1099
1276
 
1100
1277
 
1101
1278
  def configuration_problems(doc: Any) -> List[str]:
@@ -1289,12 +1466,12 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
1289
1466
  basis = cost_basis or ("unknown" if cost_usd is None else "provider")
1290
1467
  if basis not in COST_BASES or (cost_usd is None) != (basis == "unknown"):
1291
1468
  raise ServiceError("cost_basis is provider or price-list with a cost, unknown without one.")
1292
- if route is not None and route not in ROUTES:
1293
- raise ServiceError("route is subscription or key (DEC-0046).")
1469
+ if route is not None and route not in USAGE_ROUTES:
1470
+ raise ServiceError("route is subscription, key or platform-metered (DEC-0046, DEC-0061).")
1294
1471
  receipt = {"at": at or now_iso(), "provider": provider, "model": model, **counts,
1295
1472
  "costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
1296
1473
  if route is not None:
1297
- receipt["route"] = route # the operator's own login in its vendor's runner, or an issued key
1474
+ receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
1298
1475
  return receipt
1299
1476
 
1300
1477
 
@@ -2346,18 +2523,30 @@ def remote_token_file(service_id: str, instance: str = "default", directory: Opt
2346
2523
 
2347
2524
  def register_remote(*, service_id: str, name: str, origin: str, token: str, instance: str = "default",
2348
2525
  summary: Optional[str] = None, doctor: Optional[List[str]] = None,
2349
- directory: Optional[Path] = None, installed_by: str = "fabric-service register-remote") -> Path:
2526
+ directory: Optional[Path] = None, installed_by: Any = None,
2527
+ runner: Optional[Dict[str, str]] = None, peer: Optional[Dict[str, Any]] = None) -> Path:
2350
2528
  """DEC-0019, installer side: register an online service on THIS computer — the token file
2351
- (0600, never printed) and the descriptor. The same token is set on the platform as a secret."""
2529
+ (0600, never printed) and the descriptor. The same token is set on the platform as a secret.
2530
+
2531
+ DEC-0061: ``installed_by`` defaults to ``{"by": "person", "role": "operator", "tool": "fabric-service
2532
+ register-remote"}`` (a person runs this); pass ``role: "administrator"`` for an organization's administrator, or a
2533
+ string as before. ``runner`` (``{kind?, credentialKind}``) says how its model calls sign in, as a kind and never a
2534
+ credential; ``peer`` (``{endpoint, custody, residency, maxDataClass}``) offers it to others and needs ``runner``."""
2352
2535
  if not token or len(token.strip()) < 16:
2353
2536
  raise ServiceError("the service token must be at least 16 characters.")
2354
2537
  root = directory or services_dir()
2355
2538
  token_file = remote_token_file(service_id, instance, root)
2356
2539
  descriptor: Dict[str, Any] = {"protocol": PROTOCOL, "id": service_id, "instance": instance, "name": name,
2357
2540
  "placement": "remote", "origin": origin, "auth": {"tokenFile": str(token_file)},
2358
- "lifecycle": {"manager": "none"}, "installedAt": now_iso(), "installedBy": installed_by}
2541
+ "lifecycle": {"manager": "none"}, "installedAt": now_iso(),
2542
+ "installedBy": installed_by if installed_by is not None else
2543
+ {"by": "person", "role": "operator", "tool": "fabric-service register-remote"}}
2359
2544
  if summary:
2360
2545
  descriptor["summary"] = summary
2546
+ if runner is not None:
2547
+ descriptor["runner"] = dict(runner)
2548
+ if peer is not None:
2549
+ descriptor["peer"] = dict(peer)
2361
2550
  if doctor:
2362
2551
  descriptor["commands"] = {"doctor": list(doctor)}
2363
2552
  problems = validate_descriptor(descriptor)
@@ -350,7 +350,8 @@ def register(args: argparse.Namespace) -> int:
350
350
  else {"manager": "none"},
351
351
  "paths": {"data": str(data), "logs": []},
352
352
  "fabricManifest": str(manifest),
353
- "installedAt": fs.now_iso(), "installedBy": "sample_service.py register",
353
+ # DEC-0061: who installed it, by role and never by name — a person runs this register command.
354
+ "installedAt": fs.now_iso(), "installedBy": {"by": "person", "role": "operator", "tool": "sample_service.py register %s" % VERSION},
354
355
  }
355
356
  try:
356
357
  path = fs.write_descriptor(descriptor, Path(args.services_dir) if args.services_dir else None)
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.23.0"
8
+ version: "0.24.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0"
11
11
  ---
@@ -18,7 +18,7 @@ compatibility: >-
18
18
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.23.0"
21
+ version: "0.24.0"
22
22
  ---
23
23
 
24
24
  # Working with Fabric
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
60
60
  SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
61
61
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
62
62
  LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
63
- VERSION = "0.23.0"
63
+ VERSION = "0.24.0"
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
65
65
  # A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
66
66
  # what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value