@passioncode-ai/fabric-agent-adapter 0.78.0 → 0.80.2

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 (105) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +14 -7
  3. package/bin/fabric-agent-adapter.js +26 -4
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +42 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
  91. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
  93. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  94. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  95. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
  96. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
  97. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  98. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  99. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  100. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  101. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  102. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
  103. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
  104. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  105. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -98,6 +98,15 @@ gate = fd.delegation_gate(audience=..., jwks=keys, issuer=pin["iss"], effects=..
98
98
  estate_context="org:acme-rehearsal") # the context fabric_context started it for
99
99
  ```
100
100
 
101
+ `fabric_context.context_gate_options(info, record)` / `contextGateOptions(info, record)` hands both back from the
102
+ instance `hold_context_instance` started (FAA-150): `estate_context` always, and `issuer` when the record pins that
103
+ organization; spread it after the estate issuer, so a personal instance keeps the estate's:
104
+
105
+ ```python
106
+ gate = fd.delegation_gate(audience=..., jwks=keys, effects=..., estate=lambda: current,
107
+ **{"issuer": estate_issuer, **fabric_context.context_gate_options(info, current)})
108
+ ```
109
+
101
110
  The `kid` is then certified only when exactly one machine lists it for that context with the published key and the
102
111
  revision pins the organization; omitted or `personal`, the personal `hubKey` is read. A context key never verifies a
103
112
  personal token, the personal key never an organization's, and a context that is neither (`Org:x`, a trailing space, a
@@ -218,7 +227,9 @@ const { dirs, instance, lock } = await holdContextInstance('example-agent', 'org
218
227
  kept; another context or instance, or unreadable, releases the lock and refuses. The folder never changes context.
219
228
 
220
229
  A refusal is one sentence on stderr naming the code and the context, never a record or grant value, and exit 78
221
- (`EXIT_CONTEXT_REFUSED`, EX_CONFIG; 75 stays "already running"). `ContextRefused` carries the code:
230
+ (`EXIT_CONTEXT_REFUSED`, EX_CONFIG; 75 stays "already running"); a copy its supervisor started waits the lock's
231
+ back-off (300 s in all) before that exit, so `KeepAlive` does not restart it every 10 s (FAA-279). `ContextRefused`
232
+ carries the code:
222
233
 
223
234
  | Code | When |
224
235
  |---|---|
@@ -125,6 +125,7 @@ action it also carries the operator's decision, [Actions](#actions)), act on eve
125
125
  (`jobs.quotaExhausted(id, message, {retryable, resetsAt})`, `jobCallerAction`, `intentIdempotencyKey`). The guidance
126
126
  for an agent's own design is in `creating-fabric-agents/references/resilience-and-autonomy.md`.
127
127
 
128
+ <a id="a-nodes-call-queue"></a>
128
129
  **A node's call queue (DEC-0104).** A provider that queues refuses a start past its queue with `queue-full`: an `isError`
129
130
  result with one text item whose JSON is `{error: {code: "queue-full", message, data: {retryable, retryAfterSeconds}}}`,
130
131
  never `structuredContent`; the caller starts again after `retryAfterSeconds` under the same key. Read that refusal
@@ -132,9 +133,31 @@ from the start's own `isError` result, before any answer reaches `job_caller_act
132
133
  job, and `queue-full` never ends one, so they answer a job it ended `invalid` (`QUEUE_FULL` names the code, and the
133
134
  kit's job store refuses to end a job with it). The kit has no reader for the refusal yet (FAA-103). Through Fabric's hub
134
135
  a caller today sees `product-error` instead, kept under the key for 24 hours, until the hub relays `queue-full`
135
- (Fabric CO-376). The kit does not queue: it declares no load extension (`extensions/load/0.1`) and serves no
136
- `fabric.job.list`, so its `unknown-job` and repeated keys keep the rules above. A node that will queue needs
137
- `jobCallerKey`, the contract's `agent-network.json` vectors and the load report first (FAA-103).
136
+ (Fabric CO-376). The kit's job store does not queue by itself: it declares no load extension (`extensions/load/0.1`)
137
+ and serves no `fabric.job.list`, so its `unknown-job` and repeated keys keep the rules above.
138
+
139
+ A node that queues takes the contract's reference functions from the kit instead of copying them (since 0.80.0,
140
+ FAA-289): `fabric_load.py` and `fabric-load.mjs`, exported by both facades, held to the contract's
141
+ `fixtures/vectors/agent-network.json` and its `job-queue`, `service-load` and `service-well-known-load` fixtures at the
142
+ pin (vendored as `test/fixtures/contract/load-vectors.json` by `make_load_vectors.mjs`):
143
+
144
+ | Python | Node | What |
145
+ |---|---|---|
146
+ | `job_caller_key(accepted, credential)` | `jobCallerKey` | the caller's opaque key: an accepted delegation's `principal`, `caller` and a partner organization ending the chain, else the credential; `None` / `undefined` for a refused or unreadable call |
147
+ | `next_queued_job(queue, running, limits)` | `nextQueuedJob` | the id to start next per (agentRef, context): nothing at `concurrency`, a caller at `perCaller` waits, the caller running fewest first, then the oldest arrival, ties by id |
148
+ | `admit_job(queue, running, job, limits, retry_after_seconds)` | `admitJob` | `{admit: True}` when the job would start at once or fewer than `queueMax` wait, else the `queue-full` error with `retryAfterSeconds` (1 to 3600, else 60) |
149
+ | `queue_status_message(position, length)` | `queueStatusMessage` | a waiting job's `statusMessage`, `queued <position> of <length>` |
150
+ | `read_queue_position(status_message)` | `readQueuePosition` | `{position, length}` from that message, or none |
151
+ | `job_queue_rules(value)` | `jobQueueRules` | FAC-SEM-131 on `{queue, running, limits, started}` |
152
+ | `load_report_rules(report)` | `loadReportRules` | FAC-SEM-132 on a `service-load/1` report: windows one `windowSeconds` apart, none after `generatedAt`, one row per context, p50 at most p95, a run time only where a job ended |
153
+ | `load_report_problems(report)` | `loadReportProblems` | the report against `service-load.schema.json` (no caller, no job id, counts and durations only) |
154
+ | `load_view(well_known)`, `load_extension_rules(well_known)` | `loadView`, `loadExtensionRules` | the declaration as a reader reads it, and FAC-SEM-133: an invalid one is treated as not declared and reported |
155
+ | `load_extension(path, window_seconds, limits)` | `loadExtension({path, windowSeconds, limits})` | the `extensions` entry a service declares; raises `ServiceError` on one a reader would hide |
156
+
157
+ Queue times are UTC instants (`T`, `Z`, at most three fraction digits); report times are RFC 3339 read strictly. A
158
+ node keys every per-caller rule with `job_caller_key`, counts every started job that has not ended (`input_required`
159
+ and paused ones too) as running, and answers another caller's job id `unknown-job`. The text-only `queue-full` result
160
+ and `fabric.job.list` are still the node's own (FAA-103).
138
161
 
139
162
  <a id="actions"></a>
140
163
 
@@ -59,7 +59,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
59
59
  | L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
60
60
  | L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
61
61
  | L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
62
- | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.3`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
62
+ | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.4`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
63
63
  | L0 | `fabric_tailnet` | who calls an agent behind `tailscale serve` and what that person may open (FAA-297): `Tailscale-User-Login` only on the agent's own serve listener from a loopback peer, groups from a generated `tailnet-perimeter/1` file, a personal SHA-256-stored key for MCP no wider than the groups; `authorize`, `load_perimeter`, `load_keys`, `hash_key`, refusals 401/403 with a code ([surfaces-and-auth.md](surfaces-and-auth.md#behind-tailscale-serve)) |
64
64
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
65
65
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
@@ -78,6 +78,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
78
78
  | L3 | `fabric_connections` | what an agent uses, `fabric-connections/0.1`: the reference reading and the declaration (DEC-0096, FAC-SEM-127) |
79
79
  | L3 | `fabric_a2a` | the reading side of an A2A Agent Card: `surfaces.a2a` as the schema reads it, and what a public card must not carry (DEC-0122) |
80
80
  | L3 | `fabric_page_links` | a service's pages as Fabric Dashboards' open_link: build and read it, a raw local address, FAC-SEM-143 on a link and on a URL-mode input request (DEC-0123); the Python twin reads URLs as WHATWG does by hand |
81
+ | L3 | `fabric_load` | the call queue and the load report of DEC-0104 (FAA-289): `job_caller_key`, `next_queued_job`, `admit_job`, `queue_status_message`, `read_queue_position`, FAC-SEM-131 to 133, the `service-load/1` and `extensions/load/0.1` shapes, `load_extension`; imports `fabric_connections` for ajv's `date-time` ([interop](interop.md#a-nodes-call-queue)) |
81
82
  | L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
82
83
  | L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
83
84
  | L3 | `fabric_backups` | settings backups (DEC-0025) |
@@ -105,7 +106,8 @@ Beside the kit, not in `kit.json`: the probe (`check_service.py`, `check_service
105
106
  `check_service_channel_agent.py`, `check_service_channel_agent_schema.py` and the
106
107
  `service-channel-agent-extension.schema.json` it reads at run time), the sample
107
108
  (`sample_service.py`, `sample-remote-service.mjs`), `fabric_kit.py` itself and the dashboard tools. Recipes built on
108
- the kit live in `../recipes/` ([the draft-and-check step of a support agent](../recipes/support-agent/README.md)).
109
+ the kit live in `../recipes/` ([the draft-and-check step of a support agent](../recipes/support-agent/README.md), [a service that is only a
110
+ dashboard](../recipes/dashboard-only/README.md), [a key-delivery door](../recipes/key-delivery-door/README.md)).
109
111
 
110
112
  ## What 0.39 code keeps
111
113
 
@@ -45,13 +45,19 @@ Starting, stopping and restarting go through the supervisor and nothing else:
45
45
  atomic, mode 0600) and the uninstaller removes it; a running service never writes its own, except an app
46
46
  ([below](#an-apps-own-descriptor-and-the-node)). It names the supervisor and its file (`label` and `plist`, `unit`,
47
47
  or `task`; none for `node` and `person`), the `origin` `http://127.0.0.1:<port>` (a port is a claim: an installer
48
- refuses a port another instance declares), the service's directories, and commands as argument arrays that never
48
+ refuses a port another instance declares, and one another OS account listens on, read with the kit's
49
+ `fabric_peer_owner`, FAA-225; the recorded port per account and its claim file wait on contract DEC-0160), the service's directories, and commands as argument arrays that never
49
50
  carry a credential. Only `doctor` and `update` are commands a host may run.
50
51
 
51
52
  **Started is not ready.** Readiness is the well-known document, `GET /.well-known/fabric-service`: answered without
52
53
  authentication, from memory, in under 100 ms, with `service.id` and `service.instance` equal to the descriptor's,
53
54
  on a listener of this OS account (DEC-0154 HA-2: another account's listener is `foreign-user` whatever it answers; a
54
- LaunchDaemon's root installer reads it as *The system domain* says).
55
+ LaunchDaemon's root installer reads it as *The system domain* says). `well_known_of(origin)` / `wellKnownOf(origin)` read
56
+ it so a status tells HA-2's order apart: None / null when nothing answers (`down`), a `ForeignUserAnswer` whose
57
+ `reason` starts `foreign-user` or `table-restricted`, else the document; `fetch_well_known` keeps answering None for
58
+ both (FAA-259). Every loopback spelling of an origin (`127.0.0.1`, another `127/8` address, `localhost`, `[::1]`) is read
59
+ on a checked connection (FAA-257). `launchd_install` takes an injected reader, `fetch_well_known=` and
60
+ `fetch_job_well_known=`, as the Node `launchdInstall` takes `fetchWellKnown` and `fetchJobWellKnown`.
55
61
  `status` is `starting`, `ready`, `degraded` or `stopping`; `degraded` is always present, and an empty list asserts
56
62
  full health. A process that cannot serve does not answer, and a host derives `down`. An install or upgrade polls
57
63
  that document until `service.id` matches (at most 40 s) before it calls the service started (the launchd installers;
@@ -200,6 +206,10 @@ if the holder leaves, and exits 75 only when the wait runs out: a few starts an
200
206
  360. It touches nothing while it waits. Two installs sharing one data directory are still the
201
207
  defect to remove; the back-off only stops the loop from costing the machine.
202
208
 
209
+ A context refusal (`fabric_context`, exit 78: no record, a revoked grant, a folder of another context) is the same
210
+ loop under `KeepAlive`, so a supervised copy waits the same back-off, holding nothing and with the lock released,
211
+ before it exits 78 (FAA-279); started by hand it exits 78 at once.
212
+
203
213
  The marker (contract DEC-0057, CT-01) is the variable `FABRIC_SERVICE_SUPERVISOR=<manager>` or
204
214
  the argument `--fabric-supervisor <manager>` (also `--fabric-supervisor=<manager>`), after the
205
215
  executable and before any `--`; `<manager>` is `launchd`, `systemd`, `task-scheduler` or `node`
@@ -318,7 +328,7 @@ descriptor says `"lifecycle": {"manager": "systemd", "unit": "<id>.<instance>.se
318
328
 
319
329
  | Verb | Command |
320
330
  |---|---|
321
- | install, upgrade | `systemd_install` / `systemdInstall`: write `~/.config/systemd/user/<unit>`, `daemon-reload`, `enable`, `restart` (so the new unit is what runs). A unit already installed that `is-enabled` reports `disabled` stays off: the file is rewritten and nothing starts (LC-14), unless `force_enable` |
331
+ | install, upgrade | `systemd_install` / `systemdInstall`: write `~/.config/systemd/user/<unit>`, `daemon-reload`, `enable`, `restart` (so the new unit is what runs). A unit already installed that `is-enabled` reports `disabled` stays off: the file is rewritten and nothing starts (LC-14), unless `force_enable`. With `origin` and `service_id` it then polls the well-known answer as `launchd_install` does until this account's listener answers (another account's is refused, DEC-0154 HA-2; FAA-253); Node's `systemdInstall` then returns a promise |
322
332
  | stop, keep off | `systemctl --user disable --now <unit>` |
323
333
  | uninstall | `systemd_uninstall` / `systemdUninstall`: `disable --now`, remove the unit, `daemon-reload` |
324
334
 
@@ -335,7 +345,7 @@ CI job has the real `schtasks` create, read back and delete such a task. The des
335
345
 
336
346
  | Verb | Command |
337
347
  |---|---|
338
- | install, upgrade | `task_install` / `taskInstall`: `schtasks /Create /TN <task> /XML <file> /F` from a UTF-16 file it then removes, then `/End` (when it existed) and `/Run`. A task already installed and disabled stays off: it is recreated and disabled again (LC-14), unless `force_enable` |
348
+ | install, upgrade | `task_install` / `taskInstall`: `schtasks /Create /TN <task> /XML <file> /F` from a UTF-16 file it then removes, then `/End` (when it existed) and `/Run`. A task already installed and disabled stays off: it is recreated and disabled again (LC-14), unless `force_enable`. With `origin` and `service_id` it then polls as `launchd_install` does and returns the answer (FAA-253); Node's `taskInstall` then returns a promise |
339
349
  | stop, keep off | `schtasks /End /TN <task>`, then `schtasks /Change /TN <task> /DISABLE` |
340
350
  | uninstall | `task_uninstall` / `taskUninstall`: `/End`, then `/Delete /F` |
341
351
 
@@ -157,6 +157,9 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
157
157
  `auth.delegation {issuer, jwks}` is then required. With `own` or `delegation` the descriptor's
158
158
  token is not an MCP credential: a host and the probe never call `/mcp` with it as a caller.
159
159
  The kits verify them with `fabric_delegation` / `fabric-delegation.mjs` ([delegation](delegation.md)).
160
+ - `surfaces.mcp.capabilities` and an action's `tool` never name `project.work` (`PRIMARY_CAPABILITY`): DEC-0140 PR-1
161
+ reserves it for a Project's primary responder, a session, and no service serves or declares it. `build_well_known`
162
+ / `buildWellKnown` refuse it with `ServiceError` (FAA-244); a longer name such as `project.workspace` is not reserved.
160
163
 
161
164
  ## Events page: `GET /fabric/v1/events?after=<cursor>&limit=<n>`
162
165
 
@@ -238,8 +241,9 @@ IPv4 wildcard for a LaunchAgent's installer, exactly `127.0.0.1` for a LaunchDae
238
241
  the contract's ruling on vouching is CT-261.
239
242
 
240
243
  Every kit client that sends a credential to loopback checks first: `check_service.py` (rule `listener.owner`, the first that reads the port for
241
- a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`),
242
- `fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user`, never retried),
244
+ a local placement; `FAIL` `foreign-user` and no rule reads the port), the hub client (`HubError` `foreign-user`, its
245
+ `data.reason` and sentence the owner's reason: `table-restricted`, then not retryable, or the owner's kind or `no-row`, FAA-272),
246
+ `fabric_http` / `fabric-http.mjs` for a plain `http://` loopback URL (`HttpError` kind `foreign-user` with the same `reason`, never retried),
243
247
  `check_dashboard_layout.mjs` before a local login code, and the launchd installers' poll (a listener of another account
244
248
  is never a successful install, except a LaunchDaemon's job as its root installer proves it; the systemd and Task Scheduler installers do not poll yet, FAA-253). A running board
245
249
  session reports a door another account holds as `board.down` with reason `unreachable` until FAA-252. On macOS the Node
@@ -10,6 +10,7 @@ DEC-0019, at the commit this skill pins. This page is the how-to.
10
10
  - [On the platform](#on-the-platform)
11
11
  - [Checking it](#checking-it)
12
12
  - [What a host does and will not do](#what-a-host-does-and-will-not-do)
13
+ - [Registered from several hosts](#registered-from-several-hosts)
13
14
  - [Rotation](#rotation)
14
15
 
15
16
  ## Is it a remote service?
@@ -68,8 +69,9 @@ watch the gate fail.
68
69
  ## On the platform
69
70
 
70
71
  - **Token**: a random value of at least 16 characters (32 bytes recommended), set as a secret;
71
- the same value is written on the operator's computer by `registerRemote`. Compare in constant
72
- time (`tokenMatches`).
72
+ the same value is written on the registering host by `registerRemote`. Compare in constant
73
+ time (`tokenMatches`). A service registered from more than one host holds one token per host
74
+ ([Registered from several hosts](#registered-from-several-hosts)).
73
75
  - **Session key**: 32 random bytes, a separate secret; rotating it signs every operator out.
74
76
  - **Codes and sessions**: `MemoryCodeStore`: a restart forgets every outstanding code, so none
75
77
  can be replayed, and every session record, so a deploy signs the operator out (DEC-0039: the
@@ -122,10 +124,49 @@ The login rules keep every `Set-Cookie` of the answer and judge the session cook
122
124
  | start, stop, restart, update | not offered |
123
125
  | a host older than the remote placement | shows the service as invalid and never contacts it |
124
126
 
127
+ ## Registered from several hosts
128
+
129
+ One cloud service is often registered from more than one host: the operator's two computers, or an organization's
130
+ space beside a personal one (FAA-280). Each registering host gets **its own token**; a token is never copied from one
131
+ host to another, so one host can be revoked or rotated without touching the others.
132
+
133
+ - **On the platform**, keep one secret per host (`FABRIC_HOST_TOKEN_<HOST>`, each at least 16 characters) and accept
134
+ a request whose bearer matches any of them. Compare it with every token, each with `tokenMatches` /
135
+ `token_matches`, with no early return, so the answer's timing does not say which host's token matched:
136
+
137
+ ```python
138
+ def host_of(authorization, tokens): # tokens: {host name: token}; the kit's comparison, once per token
139
+ matched = [host for host, token in sorted(tokens.items()) if fs.token_matches(authorization, token)]
140
+ return matched[0] if matched else None # None: a 401 with an empty body, as with one token
141
+ ```
142
+
143
+ `well_known_allowed` / `wellKnownAllowed` take one token: call it once per token the same way, or gate the route on
144
+ `host_of(...) is not None`.
145
+ - **On each host**, run `registerRemote` / `register_remote` with that host's token only. The descriptor is the same
146
+ on every host but its `tokenFile`, which holds that host's value and nothing else (0600, never printed).
147
+ - **Sign-in**: `LoginCodes` binds a session to one token's revision. Give each host its own `LoginCodes` with
148
+ `token=` that host's token and its own store (`MemoryCodeStore`, or a durable row per host); the session key may be
149
+ shared. Issue the login code from the instance whose token the bearer matched; redeem a code and check a session by
150
+ trying each instance in turn (a code another instance issued is not in its store, and a session cookie carries
151
+ another token's revision, so neither changes anything there). Removing or rotating one host's token then ends that
152
+ host's sessions only. One `LoginCodes` whose `token=` is a callable returning every token joined also works, and
153
+ signs every host out when any one rotates.
154
+ - **The probe**: `check_service.py <id>` run on a host sends that host's token, so `well-known.requires-token` passes
155
+ on each; the request without a token, or with a value no host holds, still gets the `401` with an empty body.
156
+ - **Revoking a host**: delete its secret on the platform and restart; that host then sees `401` and shows the service
157
+ `down`, never `foreign`. Remove its descriptor there with `remove_descriptor` / `removeDescriptor`.
158
+ - **Adding a host**: create its secret, restart, then `registerRemote` on the new host. The other hosts' tokens and
159
+ sessions do not change.
160
+
161
+ A token per host still gives each host the descriptor token's whole authority over the service. A remote surface no
162
+ host token reaches (callers with their own delegation, the page opened only with Fabric Dashboards' one-time code)
163
+ is the "local and cloud" form, FAA-204, which waits on the contract recording that form.
164
+
125
165
  ## Rotation
126
166
 
127
167
  Rotate the token by setting the new value on the platform and re-running `registerRemote` on the
128
- operator's computer with it; the old token stops working the moment the platform restarts with the
168
+ registering host with it (on a service registered from several hosts, only that host's token,
169
+ [above](#registered-from-several-hosts)); the old token stops working the moment the platform restarts with the
129
170
  new value, and so does every session begun under it when `LoginCodes` has `token=`. The re-run keeps the
130
171
  installed `source` when it passes none (one the kit refuses, edited by hand, is dropped with the warning); pass
131
172
  `runner`, `peer`, `summary`, `doctor` and `installedBy` / `installed_by` again, since a re-run writes only what it is
@@ -133,12 +133,20 @@ def check(data):
133
133
  return {'ok': True, 'kind': kind}
134
134
 
135
135
 
136
+ def read_input(limit=65536):
137
+ # The handoff's bytes, decoded as UTF-8 with a byte order mark dropped: Windows before Python 3.15 decodes a text
138
+ # stdin with its code page, which misreads a Cyrillic handoff (FAA-210). A stream without bytes is read as text.
139
+ stream = getattr(sys.stdin, 'buffer', None)
140
+ raw = stream.read(limit + 1) if stream is not None else sys.stdin.read(limit + 1)
141
+ require(len(raw) <= limit, 'input_too_large')
142
+ return raw.decode('utf-8-sig') if isinstance(raw, bytes) else raw.lstrip('\ufeff')
143
+
144
+
136
145
  def main():
137
146
  try:
138
- raw = sys.stdin.read(65537)
139
- require(len(raw) <= 65536, 'input_too_large')
140
- result = check(json.loads(raw))
141
- except (ValueError, TypeError, KeyError, AttributeError):
147
+ result = check(json.loads(read_input()))
148
+ # RecursionError: Python 3.9 to 3.13 raise it on a deeply nested document under the cap
149
+ except (ValueError, TypeError, KeyError, AttributeError, RecursionError):
142
150
  # Fixed reasons only: never echo a submitted URL, token or JSON blob.
143
151
  result = {'ok': False, 'reason': 'dashboard_handoff_refused'}
144
152
  print(json.dumps(result))
@@ -673,12 +673,14 @@ class Probe:
673
673
  self.events_trace_rule(self.events)
674
674
 
675
675
  def channel_agent_rule(self) -> None:
676
- """DEC-0150 (FAA-197): the channel agent's declaration, read from the manifest the descriptor names, else from the
677
- well-known answer (CA-1); one read as not declared is NOT_RUN with its evidence, never a FAIL (D3), except a string
678
- shaped like a credential in it or in a copy the probe reads beside it (CA-5)."""
676
+ """DEC-0150 (FAA-197): the declaration, from the manifest the descriptor names, else the well-known answer (CA-1); one
677
+ read as not declared is NOT_RUN, never a FAIL (D3), except a credential's shape in it or a copy beside it (CA-5)."""
679
678
  manifest, _, where = self.load_manifest()
680
- self.add("channel-agent.declaration", *cac.declaration_verdict(
681
- manifest, bool(self.d.get("fabricManifest")), where, self.wk, getattr(self, "wk_bytes", None)))
679
+ try: # a copy of the probe without its schema file is NOT_RUN, and the later rules run (FAA-250)
680
+ verdict = cac.declaration_verdict(manifest, bool(self.d.get("fabricManifest")), where, self.wk, getattr(self, "wk_bytes", None))
681
+ except (OSError, ValueError) as exc:
682
+ verdict = ("NOT_RUN", "the probe cannot read %s beside it (%s)" % (cac.cas.SCHEMA_FILE.name, type(exc).__name__))
683
+ self.add("channel-agent.declaration", *verdict)
682
684
 
683
685
  def configuration_rule(self) -> None:
684
686
  """DEC-0046: a well-known `configuration` is {slots, resolved} as the schema types it; absent is NOT_RUN."""
@@ -18,7 +18,8 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
18
18
 
19
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
20
20
  from fabric_jsvalue import indexed_objects, js_keys # noqa: E402
21
- from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE, _url_parts, raw_values, secret_argument, secret_url, secret_value # noqa: E402
21
+ from fabric_secrets import JS_SPACE, JWT_SHAPE, SECRET_VALUE, raw_values, secret_argument, secret_url, secret_value # noqa: E402
22
+ from fabric_secrets import url_parts as _whatwg_url_parts # noqa: E402 - public since FAA-250
22
23
  import check_service_channel_agent_schema as cas # noqa: E402
23
24
 
24
25
  KEY = "https://fabric.passioncode.ai/agent-contract/extensions/channel-agent/0.1"
@@ -62,6 +63,9 @@ _IGNORABLE = ((0x00AD, 0x00AD), (0x034F, 0x034F), (0x061C, 0x061C), (0x115F, 0x1
62
63
  (0x200B, 0x200F), (0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xFE00, 0xFE0F), (0xFEFF, 0xFEFF),
63
64
  (0xFFA0, 0xFFA0), (0xFFF0, 0xFFF8), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF))
64
65
  _FILLERS = frozenset("\u115f\u1160\u3164\uffa0\u2800\ufffc")
66
+ #: Format characters (\p{Cf}) assigned after Unicode 13, which Python 3.9's unicodedata reads as unassigned (FAA-250):
67
+ #: U+0890..0891 (Unicode 14) and U+13439..1343F (Unicode 15); the white-space and control categories gained none since.
68
+ _LATER_FORMATS = ((0x0890, 0x0891), (0x13439, 0x1343F))
65
69
 
66
70
 
67
71
  def utf16(text: str) -> str:
@@ -75,7 +79,7 @@ def utf16(text: str) -> str:
75
79
  def _blank_char(char: str) -> bool:
76
80
  code = ord(char)
77
81
  return (char in JS_SPACE or char in _FILLERS or unicodedata.category(char) in ("Cf", "Cc", "Zs", "Zl", "Zp")
78
- or any(low <= code <= high for low, high in _IGNORABLE))
82
+ or any(low <= code <= high for low, high in _IGNORABLE + _LATER_FORMATS))
79
83
 
80
84
 
81
85
  def blank(text: str) -> bool:
@@ -397,7 +401,7 @@ def url_parts(text: str) -> Optional[Tuple[str, str]]:
397
401
  return None
398
402
  if host and not _host_ok(host, special):
399
403
  return None
400
- _, query, fragment = _url_parts(text)
404
+ _, query, fragment = _whatwg_url_parts(text)
401
405
  has_query = "?" in rest.partition("#")[0]
402
406
  has_fragment = "#" in rest
403
407
  return ("?" + query if has_query and query else "", "#" + fragment if has_fragment and fragment else "")
@@ -823,8 +827,9 @@ def declaration_verdict(manifest: Optional[Dict[str, Any]], manifest_named: bool
823
827
  where = "the manifest's provider.extensions" if site == "manifest" else "the well-known answer's extensions"
824
828
  shapes = _credential_shapes(others + copy)
825
829
  if shapes: # a credential's shape in a copy no reader reads still sits in a document the service publishes (CA-5)
826
- return "FAIL", "%d string(s) shaped like a credential outside the read declaration: %s (DEC-0150 CA-5)%s" % (
827
- len(shapes), _shown(shapes), tail)
830
+ also = ("; on the read declaration itself, %d finding(s): %s" % (len(own), _shown(own))) if own else ""
831
+ return "FAIL", "%d string(s) shaped like a credential outside the read declaration: %s (DEC-0150 CA-5)%s%s" % (
832
+ len(shapes), _shown(shapes), also, tail)
828
833
  if declared and own:
829
834
  if not channel_agent_view(doc, site)["declared"] and not _credential_shapes(own):
830
835
  # D3: what the contract's reader reads as not declared (an older form, a repeat, a dangling reference) is
@@ -3,6 +3,7 @@ network guard's verdict, the board responder's fixture conversation, a session c
3
3
  whether two repository URLs name one repository, a service's actions declaration (DEC-0121) and whether its checkout
4
4
  opens its pages in a browser (FAA-118), and its A2A member and public Agent Card (DEC-0122). ``check_service.py`` runs them against a live service.
5
5
  Standard library only, Python 3.9+."""
6
+ # Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
6
7
  from __future__ import annotations
7
8
 
8
9
  import email.utils
@@ -11,6 +11,11 @@ export const PROTOCOL = 'fabric-service/0.1';
11
11
 
12
12
  export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
13
13
 
14
+ // DEC-0140 PR-1 (contract project-comms.md, interop.md's C3.1 amendment): the capability of a Project's primary
15
+ // responder, a session's. No service serves it as a tool or declares it (surfaces.mcp.capabilities, an action's tool),
16
+ // and the board kit never enrolls a service's responder for it (FAA-244).
17
+ export const PRIMARY_CAPABILITY = 'project.work';
18
+
14
19
  // Lifecycle rules: references/lifecycle.md (the rule for this kit's users; it implements the public
15
20
  // contract's service specification, Lifecycle), the same values as fabric_service.py.
16
21
  // A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
@@ -30,6 +30,8 @@ export class BoardSession {
30
30
  try {
31
31
  return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: timeout > 0 ? timeout : this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
32
32
  } catch (e) {
33
+ // FAA-252: another OS account's process holds the port; nothing was sent
34
+ if (e instanceof HubError && e.code === 'foreign-user') throw new BoardError('foreign-user', 'The hub\'s port is held by another OS account\'s process; nothing was sent.', true);
33
35
  if (e instanceof HubError) throw new BoardError('unreachable', 'Fabric\'s hub did not answer; a write may have been stored, so send it again with the same idempotency key.', true);
34
36
  throw e;
35
37
  }
@@ -3,7 +3,7 @@
3
3
  // Part of the Node board connector (FAA-65, layer L4); the twin of fabric_board_session.py. Node 20+, no dependency.
4
4
  import crypto from 'node:crypto';
5
5
  import { Backoff, HubError, normalProject } from './fabric-hub.mjs';
6
- import { acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
6
+ import { PRIMARY_CAPABILITY, acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
7
7
  import { BoardError, CAPABILITY, LEASE_SECONDS, REQUEST_STATES, SUMMARY_MAX, Stopped, isInt, isObj, outcomeProblems, receiptProblems } from './fabric-board-shapes.mjs';
8
8
  import { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD, covers, mandateOptions, readMandate } from './fabric-board-link.mjs';
9
9
  import { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES, SlotKeeper } from './fabric-board-slots.mjs';
@@ -87,6 +87,8 @@ export class Responder extends SlotKeeper {
87
87
  if (typeof admit !== 'function' || typeof handler !== 'function') throw new BoardError('invalid_arguments', 'A responder needs the service\'s own input gate (admit) and a handler.');
88
88
  const caps = [...(capabilities ?? [])];
89
89
  if (!caps.length || caps.some((c) => typeof c !== 'string' || !CAPABILITY.test(c)) || new Set(caps).size !== caps.length) throw new BoardError('invalid_arguments', 'Name each capability once, [a-z][a-z0-9._-]{1,127}.');
90
+ // FAA-244: DEC-0140 PR-1, a session's slot, never a service's
91
+ if (caps.includes(PRIMARY_CAPABILITY)) throw new BoardError('invalid_arguments', `${PRIMARY_CAPABILITY} is a Project's primary responder's, a session's: a service answers the capabilities it declares (DEC-0140 PR-1).`);
90
92
  Object.assign(this, { hub, capabilities: caps, credentialFile, handler, admit, observe, emitTo: emit, now, rand });
91
93
  this.project = normalProject(projectId);
92
94
  // FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; null reads none)
@@ -102,6 +104,8 @@ export class Responder extends SlotKeeper {
102
104
  this.state = 'idle';
103
105
  this.waitUntil = 0;
104
106
  this.stopping = false;
107
+ /** Whether shutdown found work to drain, published before it waits; null before shutdown starts (FAA-186). */
108
+ this.draining = null;
105
109
  this.claimKeys = {};
106
110
  this.probing = new Set(); // moved slots whose move-back claim lost its answer: asked again under the same key
107
111
  this.said = {};
@@ -196,7 +200,7 @@ export class Responder extends SlotKeeper {
196
200
  this.session = session;
197
201
  const status = await this.status();
198
202
  if (missing.length) {
199
- this.emit('board.access.needed', 'warning', `The operator has not let this service answer ${missing.join(', ')} on this Project; ask in Fabric (Agents & access).`, { notify: true, episode: missing.join(','), capabilities: missing });
203
+ this.emit('board.access.needed', 'warning', `The operator has not let this service answer ${missing.join(', ')} on this Project; ask in Fabric (Settings → Agent access).`, { notify: true, episode: missing.join(','), capabilities: missing });
200
204
  }
201
205
  this.slots = {};
202
206
  await this.recover(status, this.capabilities.filter((c) => grant.capabilities.includes(c)));
@@ -375,7 +379,8 @@ export class Responder extends SlotKeeper {
375
379
  try {
376
380
  this.receipt('com.reply', await this.call('com.reply', { ...this.replyExtra(entry), fence: this.fence({ ...entry, generation, attempt, digest, revision }, key) }));
377
381
  } catch (e) {
378
- if (['unreachable', 'session-ended', 'authority_changed', 'not_authorized', 'not_available', 'capacity_exceeded'].includes(e.code) || !(e instanceof BoardError)) throw e;
382
+ // foreign-user: nothing was sent now, so whether the old reply was stored is unknown (0.79.0 seam reading)
383
+ if (['unreachable', 'foreign-user', 'session-ended', 'authority_changed', 'not_authorized', 'not_available', 'capacity_exceeded'].includes(e.code) || !(e instanceof BoardError)) throw e;
379
384
  replied = false;
380
385
  }
381
386
  if (replied) entry.replied = true;
@@ -496,7 +501,7 @@ export class Responder extends SlotKeeper {
496
501
  if (['not_authorized', 'authority_changed', 'credential'].includes(code)) {
497
502
  this.state = 'not-authorized';
498
503
  await this.dropSession();
499
- this.emit('board.access.needed', 'warning', 'This service may not answer requests for this Project: the operator has to allow it in Fabric (Agents & access). Nothing is claimed until then.', { notify: true, episode: 'access', reason: code });
504
+ this.emit('board.access.needed', 'warning', 'This service may not answer requests for this Project: the operator has to allow it in Fabric (Settings → Agent access). Nothing is claimed until then.', { notify: true, episode: 'access', reason: code });
500
505
  } else if (code === 'held') {
501
506
  this.state = 'paused';
502
507
  this.emit('board.held', 'info', 'The operator holds new work on this Project; the responder waits and asks again with a growing pause.', { episode: 'held', reason: code });
@@ -508,8 +513,11 @@ export class Responder extends SlotKeeper {
508
513
  } else {
509
514
  this.state = 'down';
510
515
  // A refusal in recovery that left no slot held reconnects (seam 6); one that left some held serves them (seam 7).
511
- if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) && !keep) await this.dropSession();
512
- this.emit('board.down', 'warning', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
516
+ if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'foreign-user'].includes(code)) && !keep) await this.dropSession();
517
+ // FAA-252: a port another OS account holds is said as such; nothing was sent there
518
+ this.emit('board.down', 'warning', code === 'foreign-user'
519
+ ? 'Fabric\'s board port is held by another OS account\'s process; nothing was sent, and the responder tries again with a growing pause.'
520
+ : 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
513
521
  }
514
522
  return wait;
515
523
  }
@@ -933,6 +941,7 @@ export class Responder extends SlotKeeper {
933
941
  async shutdown(drain = DRAIN_SECONDS) {
934
942
  this.stopping = true;
935
943
  const work = this.current;
944
+ this.draining = Boolean(work); // what this shutdown waits for (FAA-186)
936
945
  if (work && !work.done) {
937
946
  await Promise.race([work.promise, new Promise((resolve) => setTimeout(resolve, drain * 1000).unref?.())]);
938
947
  if (!work.done) work.ctx.stopped = true;
@@ -14,7 +14,7 @@ export const HOLD_RETRY_MAX_SECONDS = 900;
14
14
 
15
15
  /** Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault. */
16
16
  export const SESSION_CODES = Object.freeze(new Set(['unreachable', 'session-ended', 'not_authorized', 'authority_changed', 'credential', 'no-epoch',
17
- 'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available']));
17
+ 'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available', 'foreign-user']));
18
18
 
19
19
  const sameRecord = (a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
20
20
 
@@ -83,7 +83,7 @@ export class SlotKeeper {
83
83
  const moved = isInt(before, 1) && isInt(rest.generation, 1) && before > rest.generation;
84
84
  if (isInt(generation, 1) && (answer.repeated === true || !moved)) record = { project: this.project, generation };
85
85
  } catch (e) {
86
- if (LOST.includes(e.code)) throw e;
86
+ if (LOST.includes(e.code) || e.code === 'foreign-user') throw e; // foreign-user: the earlier ask is unknown
87
87
  if (e.code === 'idempotency_window_expired') record = {}; // a retired epoch: unknown, so a first start, no move
88
88
  }
89
89
  }
@@ -254,6 +254,7 @@ export class SlotKeeper {
254
254
  if (!(e instanceof BoardError)) throw e;
255
255
  this.retryLater(capability, entries, e);
256
256
  if (SESSION_CODES.has(e.code)) throw e;
257
+ if (e.code === 'idempotency_window_expired') await this.status(); // the epoch moved: read it before the retry (FAA-186)
257
258
  }
258
259
  }
259
260
  }
@@ -6,7 +6,7 @@
6
6
  // An organization's instance whose context the record does not pin (`organizations`, DEC-0127) still starts: the helper
7
7
  // checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
8
8
  // hubKeyCertified reads a context's hub keys only while the record pins that organization (DL-14a, DEC-0128), so the
9
- // service passes `estateContext: context` to its delegation gate. A refusal at start would stop every organization
9
+ // service passes contextGateOptions(info, record) (its context and its pin's issuer, FAA-150) to its delegation gate. A refusal at start would stop every organization
10
10
  // instance on today's records, which pin none until every reader is pinned at or after DEC-0127.
11
11
  import crypto from 'node:crypto';
12
12
  import fs from 'node:fs';
@@ -14,7 +14,7 @@ import path from 'node:path';
14
14
  import { INSTANCE, ServiceError } from './fabric-base.mjs';
15
15
  import { descriptorPath } from './fabric-descriptor.mjs';
16
16
  import { parseContext } from './fabric-estate.mjs';
17
- import { InstanceLock, holdSingleInstance } from './fabric-lock.mjs';
17
+ import { InstanceLock, LOCK_BACKOFF_FIRST_SECONDS, LOCK_BACKOFF_MAX_SECONDS, holdSingleInstance, lockWaitBudgetSeconds, supervisorMarker } from './fabric-lock.mjs';
18
18
  import { atomicWrite, instanceDirs, servicesDir } from './fabric-paths.mjs';
19
19
  import { instanceShareGrant } from './fabric-share.mjs';
20
20
  import { clockMs } from './fabric-time.mjs';
@@ -112,9 +112,29 @@ function markerMatches(file, expected) {
112
112
  return found !== null && typeof found === 'object' && Object.entries(expected).every(([key, value]) => found[key] === value);
113
113
  }
114
114
 
115
+ /**
116
+ * What delegationGate takes from this instance's context (FAA-150): `estateContext`, and for an organization whose
117
+ * context `record` pins, `issuer`: the pin's context issuer (DL-14a, DEC-0128), so an organization's instance refuses a
118
+ * token the personal hub key signed. `info` is contextInstance's (or holdContextInstance's) answer; spread it after the
119
+ * service's own estate issuer: `delegationGate({ ..., issuer: estateIssuer, ...contextGateOptions(info, record) })`.
120
+ * Throws ContextRefused (`context-invalid`) for an answer with no context.
121
+ */
122
+ export function contextGateOptions(info, record) {
123
+ const context = info !== null && typeof info === 'object' ? info.context : undefined;
124
+ const parsed = parseContext(context);
125
+ if (!parsed) throw new ContextRefused('context-invalid');
126
+ const options = { estateContext: context };
127
+ const pins = record !== null && typeof record === 'object' && !Array.isArray(record) ? record.organizations : undefined;
128
+ if (parsed.kind === 'org' && Array.isArray(pins)) {
129
+ const pin = pins.find((p) => p !== null && typeof p === 'object' && p.org === context && typeof p.iss === 'string');
130
+ if (pin) options.issuer = pin.iss;
131
+ }
132
+ return options;
133
+ }
134
+
115
135
  /**
116
136
  * Run as `context`'s one instance, before any side effect (brief D17): contextInstance (a refusal writes one sentence
117
- * naming the code and exits 78, nothing created); then holdSingleInstance on the instance's data folder (its markers,
137
+ * naming the code and exits 78, nothing created; a supervised copy backs off first, FAA-279); then holdSingleInstance on the instance's data folder (its markers,
118
138
  * its supervised wait, its exit 75); then `<data>/fabric-context.json`: absent, written (0600, atomic); equal, kept;
119
139
  * another context or instance, or unreadable, releases the lock and exits 78 (`context-mismatch`). Resolves to the
120
140
  * instance with its held `lock`; `exit` is injectable as holdSingleInstance has it, and its answer is returned.
@@ -123,8 +143,20 @@ export async function holdContextInstance(id, context, {
123
143
  record, machineId, agentRef, at, personalInstance = 'default', servicesDirectory, platform = process.platform,
124
144
  waitSeconds, sleep, clock, exit = (code) => process.exit(code), env, argv,
125
145
  } = {}) {
126
- const refuse = (code) => {
127
- process.stderr.write(`${id} will not run for ${shown(context)} (${code}): ${REASONS[code]}.\n`);
146
+ // FAA-279: under a supervisor that restarts on exit an exit 78 at once is a respawn every few seconds, forever; a
147
+ // supervised copy first waits the lock's back-off (0.5 s doubling to 30 s, 300 s in all), holding nothing.
148
+ const refuse = async (code) => {
149
+ const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
150
+ const wait = Math.max(0, waitSeconds ?? lockWaitBudgetSeconds(marker));
151
+ const tail = wait > 0 ? ` Waiting ${Math.round(wait)} s before exiting, so its supervisor does not restart it every few seconds.` : '';
152
+ process.stderr.write(`${id} will not run for ${shown(context)} (${code}): ${REASONS[code]}.${tail}\n`);
153
+ const now = clock ?? (() => performance.now() / 1000);
154
+ const pause = sleep ?? ((seconds) => new Promise((resolve) => { setTimeout(resolve, seconds * 1000); }));
155
+ const deadline = now() + wait;
156
+ for (let delay = LOCK_BACKOFF_FIRST_SECONDS, remaining = deadline - now(); remaining > 0; remaining = deadline - now()) {
157
+ await pause(Math.min(delay, remaining));
158
+ delay = Math.min(delay * 2, LOCK_BACKOFF_MAX_SECONDS);
159
+ }
128
160
  return exit(EXIT_CONTEXT_REFUSED);
129
161
  };
130
162
  let info;