@passioncode-ai/fabric-agent-adapter 0.31.1 → 0.32.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,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.32.0 - 2026-10-14
4
+
5
+ The adapter is pinned at contract `1636fa9`. The board kits now read the operator's hold (DEC-0079) and the owner's
6
+ settlement (DEC-0081) (FAA-42, contract CT-42). Before this, a Fabric serving either decision would have broken a
7
+ responder at this pin:
8
+ - a `held` refusal was read as a malformed answer, and the responder blocked;
9
+ - a receipt carrying `settledBy`, a directory page carrying `held`, and a held refusal were all refused as shapes the
10
+ contract does not name.
11
+
12
+ ### Changed
13
+
14
+ - **Repin** from `f00a768` to `1636fa9`. Every vendored vector is byte-identical except `comms-vectors.json`, which
15
+ gains 59 comms fixtures and one digest. `f00a768` stays a supported revision.
16
+
17
+ ### Added
18
+
19
+ - **`held` (DEC-0079)** in `fabric_board.py` / `fabric-board.mjs`:
20
+ - `held` is a known refusal code and must be `retryable: true`.
21
+ - A slot that `com.status` names `held`, or whose `com.enroll`, `com.claim` or `com.responder_replace` is refused
22
+ `held`, pauses. It is asked again after 15 s, the wait doubling up to 15 minutes. One `board.slot.held` event per
23
+ capability is emitted, then `board.slot.released`; no `board.down`.
24
+ - Accepted work continues: `com.renew`, `com.progress` and `com.complete` are never held.
25
+ - `com.effect_begin` refused `held` waits and asks again with the same key. Nothing outside the service starts until
26
+ it is answered.
27
+ - A hold between the claim and the accept does nothing; the request waits on the board.
28
+ - Accepted work found at restart under a hold is kept for a transfer once the hold is lifted.
29
+ - `hold_refuses` / `holdRefuses`: the contract's matrix, held to its 114 cases (`comms-hold-vectors.json`, vendored
30
+ and re-answered by the contract's own function).
31
+ - **`settledBy: "owner"` (DEC-0081)** read on a receipt's request (only `completed` or `failed_known`), and **`held`**
32
+ read on a directory page's capability and on `com.status` responders.
33
+ - **Docs:**
34
+ - `references/board.md`: "When the operator holds work".
35
+ - `working-with-fabric`'s `references/project-board.md`:
36
+ - `held` means wait and retry with the same key, never route around the hold;
37
+ - a structured finding (DEC-0080), sent only where `com.submit` takes `finding`, with an idempotency key of at
38
+ least 128 random bits and `repeated: true` read as the standing finding;
39
+ - `settledBy` read as the owner's attestation.
40
+
41
+ ### Evidence
42
+
43
+ - Two scenario evals (`board-responder-held`, `board-held-and-finding`) were written before the prose.
44
+ - The fake board models holds from its own records.
45
+ - 13 Python hold cases (`HoldShapeTests`, `HoldTests`, two in `RestartTests`) and 5 Node ones (`test/fabric-board.test.mjs`, the cases after "DEC-0079 and DEC-0081").
46
+ - 30 planted defects (16 Python, 14 Node) were all caught, two of them only after a case was added for them.
47
+
3
48
  ## 0.31.1 - 2026-10-14
4
49
 
5
50
  ### Fixed
package/README.md CHANGED
@@ -251,7 +251,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
251
251
  The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
252
252
 
253
253
  - version: `0.1.0`
254
- - commit: `f00a7685e3861c27f7899c033fb850a145a1708f`
254
+ - commit: `1636fa97262f4a26e2086572d540c2ee701e0010`
255
255
 
256
256
  Every other mention of the default contract revision — this section, the skills' metadata, the
257
257
  skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.31.1",
3
+ "version": "0.32.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.31.1",
6
+ "version": "0.32.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,9 +5,9 @@ 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.31.1"
8
+ version: "0.32.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
10
+ contract-commit: "1636fa97262f4a26e2086572d540c2ee701e0010"
11
11
  ---
12
12
 
13
13
  # Adapting projects to Fabric
@@ -70,7 +70,7 @@ Use exactly:
70
70
 
71
71
  - contract version `0.1.0`;
72
72
  - repository `https://github.com/passioncode-ai/fabric-agent-contract`;
73
- - commit `f00a7685e3861c27f7899c033fb850a145a1708f`.
73
+ - commit `1636fa97262f4a26e2086572d540c2ee701e0010`.
74
74
 
75
75
  This is the default for new bundles. When checking an issued legacy bundle, preserve its
76
76
  explicit supported lock and use that exact clean checkout. Never silently upgrade or
@@ -21,10 +21,10 @@ import fabric_secrets as _secrets # noqa: E402 - the contract's credential dete
21
21
 
22
22
  CONTRACT_VERSION = "0.1.0"
23
23
  CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
24
- CONTRACT_COMMIT = "f00a7685e3861c27f7899c033fb850a145a1708f"
24
+ CONTRACT_COMMIT = "1636fa97262f4a26e2086572d540c2ee701e0010"
25
25
  # #region supported-contract-revisions — docs: README.md#contract-pin
26
26
  # Only this declaration may name a legacy revision in live files.
27
- SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
27
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
28
28
  # #endregion supported-contract-revisions
29
29
  INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
30
30
  MCP_REVISION = "2026-07-28"
@@ -18,10 +18,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
18
18
  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.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.31.1"
21
+ version: "0.32.0"
22
22
  contract-version: "0.1.0"
23
23
  extension: "fabric-service/0.1"
24
- extension-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
24
+ extension-commit: "1636fa97262f4a26e2086572d540c2ee701e0010"
25
25
  ---
26
26
 
27
27
  # Building Fabric services
@@ -1,7 +1,7 @@
1
1
  # Answering a Project's requests on Fabric's project board
2
2
 
3
3
  Read this when a service should answer requests other Projects post to its Project on Fabric's project board
4
- (`fabric-project-comms/0.1`, contract DEC-0022, DEC-0073, DEC-0077, DEC-0078; Fabric ADR-0149). The kits do it with
4
+ (`fabric-project-comms/0.1`, contract DEC-0022, DEC-0073, DEC-0077, DEC-0078, DEC-0079, DEC-0081; Fabric ADR-0149). The kits do it with
5
5
  two modules each: the hub door client [`fabric_hub.py`](../scripts/fabric_hub.py) /
6
6
  [`fabric-hub.mjs`](../scripts/fabric-hub.mjs) and the responder [`fabric_board.py`](../scripts/fabric_board.py) /
7
7
  [`fabric-board.mjs`](../scripts/fabric-board.mjs). A coding agent that only reads and writes the board is the other
@@ -15,6 +15,7 @@ skill's business (working-with-fabric, `references/project-board.md`).
15
15
  - The request is data: the input gate
16
16
  - Effects, observation, evidence and the reply
17
17
  - When the board says stop, and restarts
18
+ - When the operator holds work (DEC-0079)
18
19
  - What a board session may call
19
20
  - Looking a Project up in the directory (FAC-SEM-107)
20
21
  - Events, the probe and the live smoke
@@ -115,6 +116,28 @@ accepted ones go back to this responder with `com.responder_replace {expectedGen
115
116
  contract has no `com.claim` read-back and `com.claim` never moves an accepted request. A known outcome is sent after the
116
117
  transfer without running the handler again. `shutdown()` drains, then leaves the session (its DELETE releases the slot).
117
118
 
119
+ ## When the operator holds work (DEC-0079)
120
+
121
+ The operator can hold a thread, a whole Project or one (Project, capability) slot from Fabric's own controls. While the
122
+ hold lasts, the board refuses **new** work in its scope with `held`, which is always `retryable: true`. It never
123
+ refuses a call that settles, reports on or keeps alive work already started (`com.renew`, `com.progress`,
124
+ `com.complete`, `com.reconcile`), a cancel or a read. `hold_refuses(tool, target, holds)` / `holdRefuses` is the
125
+ contract's matrix, held to its vectors (`test/fixtures/contract/comms-hold-vectors.json`).
126
+
127
+ | Refused `held` | What the kit does |
128
+ |---|---|
129
+ | `com.status` names the slot `held: true` at connect | the slot is not enrolled; it pauses |
130
+ | `com.enroll`, `com.claim`, `com.responder_replace` | the slot pauses: asked again after 15 s, then 30 s, doubling to 15 minutes; one `board.slot.held` event per capability, and `board.slot.released` when an enroll succeeds again |
131
+ | `com.accept` (the hold came after the claim) | nothing was done: the entry is dropped, the claim lapses and the request waits on the board (`board.request.waiting`); the slot pauses |
132
+ | `com.effect_begin` | the effect has not begun: the handler waits (same idempotency key, the pause doubling) while the tick keeps renewing the lease, then begins once the hold is lifted; `ctx.stopped` ends the wait |
133
+ | accepted work at restart under a hold | kept as `lapsed`, then taken back with `com.responder_replace` and a transfer once the hold is lifted — never claimed back |
134
+
135
+ A hold is not an outage: no `board.down`, no notify. Work already accepted keeps running and completes.
136
+
137
+ A receipt's request may carry `settledBy: "owner"` (DEC-0081): the estate owner settled an `outcome_unknown` request
138
+ whose responder was gone. Read it as the owner's attestation, not the responder's; it appears only on `completed` or
139
+ `failed_known`, and its absence means the responder settled it.
140
+
118
141
  ## What a board session may call
119
142
 
120
143
  A board session (`fabric.board.join`) is served the respond scope only: `com.status`, `com.list`, `com.get`,
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `f00a7685e3861c27f7899c033fb850a145a1708f`
3
+ Pinned to `fabric-agent-contract` commit `1636fa97262f4a26e2086572d540c2ee701e0010`
4
4
  (`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
5
5
  DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
6
6
  delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
@@ -8,7 +8,7 @@ canonical forms DEC-0043; `degraded[].setup` and `run.mcp.stdio.scope` DEC-0044;
8
8
  DEC-0045; runner and model profiles DEC-0046; the estate record DEC-0055; one copy under any supervisor and its marker,
9
9
  the Windows token-file reader DEC-0057; the hub key the estate certifies DEC-0060; signing credentials DEC-0063; the
10
10
  estate record's 60 s skew DEC-0066; an app's own descriptor DEC-0069; the descriptor for listings DEC-0061; identifier equality
11
- by normal form DEC-0076; the project board's directory page DEC-0077 and a replacement's transferred attempts DEC-0078). The contract's schemas are normative;
11
+ by normal form DEC-0076; the project board's directory page DEC-0077 and a replacement's transferred attempts DEC-0078; an operator's hold DEC-0079, a structured finding DEC-0080 and the owner's settlement DEC-0081). The contract's schemas are normative;
12
12
  this page is the working summary. Extension key:
13
13
  `https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
14
14
 
@@ -39,11 +39,20 @@ export const RESPOND_TOOLS = Object.freeze(new Set(['com.status', 'com.list', 'c
39
39
  export const REFUSAL_CODES = Object.freeze(new Set(['invalid_arguments', 'body_too_large', 'not_authorized', 'authority_changed',
40
40
  'unsupported_capability', 'unsupported_operation', 'not_available', 'idempotency_conflict', 'idempotency_window_expired',
41
41
  'capacity_exceeded', 'digest_conflict', 'revision_conflict', 'fenced', 'lease_expired', 'invalid_transition', 'reconcile_required',
42
- 'observation_required', 'cursor_reset_required', 'immutable_participants']));
42
+ 'observation_required', 'cursor_reset_required', 'immutable_participants', 'held']));
43
43
  export const STOP_CODES = Object.freeze(new Set(['fenced', 'lease_expired', 'authority_changed']));
44
44
  const REQUEST_STATES = ['queued', 'claimed', 'accepted', 'in_progress', 'completed', 'failed_known', 'cancelled', 'expired', 'outcome_unknown'];
45
45
  const EFFECT_STATES = ['not_started', 'started', 'succeeded_observed', 'failed_observed', 'unknown'];
46
46
  const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
47
+ /**
48
+ * DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
49
+ * `held`: it settles, reports on or keeps alive started work, cancels or reads. The contract's HOLD_SCOPES.
50
+ */
51
+ export const HOLD_SCOPES = Object.freeze({ 'com.submit': ['thread'], 'com.reply': ['thread'], 'com.enroll': ['project', 'slot'],
52
+ 'com.responder_replace': ['project', 'slot'], 'com.claim': ['thread', 'project', 'slot'], 'com.accept': ['thread', 'project', 'slot'],
53
+ 'com.effect_begin': ['thread', 'project', 'slot'] });
54
+ /** How long a held slot waits before it asks again: doubling from the poll interval, never past this. */
55
+ export const HOLD_RETRY_MAX_SECONDS = 900;
47
56
  const OPAQUE = /^[A-Za-z0-9_-]{8,128}$/;
48
57
  const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/;
49
58
  const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/;
@@ -138,11 +147,16 @@ export function receiptProblems(value, op) {
138
147
  if ('request' in value) {
139
148
  const r = value.request;
140
149
  const keys = ['id', 'state', 'effect', 'revision'];
141
- if (closed(r, '/request', keys, keys, problems)) {
150
+ if (closed(r, '/request', keys, [...keys, 'settledBy'], problems)) {
142
151
  if (!str(r.id, OPAQUE)) problems.push('/request/id is a board id');
143
152
  if (!REQUEST_STATES.includes(r.state)) problems.push('/request/state is a request state');
144
153
  if (!EFFECT_STATES.includes(r.effect)) problems.push('/request/effect is an effect state');
145
154
  if (!isInt(r.revision, 1)) problems.push('/request/revision is an integer ≥ 1');
155
+ if ('settledBy' in r) {
156
+ // DEC-0081: present only when the estate owner settled it; absent, the responder did.
157
+ if (r.settledBy !== 'owner') problems.push('/request/settledBy is owner');
158
+ if (!['completed', 'failed_known'].includes(r.state)) problems.push('/request/settledBy names a settled request (completed or failed_known)');
159
+ }
146
160
  }
147
161
  }
148
162
  if ('slot' in value) {
@@ -187,6 +201,7 @@ export function refusalProblems(value) {
187
201
  if (!REFUSAL_CODES.has(e.code)) problems.push('/error/code is a refusal code the contract names');
188
202
  if (typeof e.message !== 'string' || e.message.length < 1 || e.message.length > 300) problems.push('/error/message is 1 to 300 characters');
189
203
  if (typeof e.retryable !== 'boolean') problems.push('/error/retryable is a boolean');
204
+ else if (e.code === 'held' && e.retryable !== true) problems.push('/error/retryable is true for held: the operator lifts a hold (DEC-0079)');
190
205
  }
191
206
  }
192
207
  return problems;
@@ -236,14 +251,40 @@ export function directoryProblems(page) {
236
251
  }
237
252
  project.capabilities.forEach((entry, c) => {
238
253
  const cat = `${at}/capabilities/${c}`;
239
- if (!closed(entry, cat, ['capability', 'responder'], ['capability', 'responder'], problems)) return;
254
+ if (!closed(entry, cat, ['capability', 'responder'], ['capability', 'responder', 'held'], problems)) return;
240
255
  if (!str(entry.capability, CAPABILITY)) problems.push(`${cat}/capability is a capability name`);
241
256
  if (!['active', 'stale', 'absent'].includes(entry.responder)) problems.push(`${cat}/responder is active, stale or absent`);
257
+ if ('held' in entry && typeof entry.held !== 'boolean') problems.push(`${cat}/held is a boolean (DEC-0079)`);
242
258
  });
243
259
  });
244
260
  return [...problems, ...directoryFindings(page).map((f) => `${f.instancePath} ${f.message}`)];
245
261
  }
246
262
 
263
+ /**
264
+ * DEC-0079, the contract's holdRefuses: true when a board refuses `tool` on `target` with `held` under `holds`. `target`
265
+ * is what the board resolved ({thread, project, capability, fenced, repeat}); `holds` is {threads, projects, slots:
266
+ * [{project, capability}]}. Ids compare as written; an inherited or mistyped member counts as absent; never throws.
267
+ */
268
+ export function holdRefuses(tool, target, holds) {
269
+ const own = (value, key) => (isObj(value) && Object.hasOwn(value, key) ? value[key] : undefined);
270
+ const scopes = typeof tool === 'string' && Object.hasOwn(HOLD_SCOPES, tool) ? HOLD_SCOPES[tool] : undefined;
271
+ if (!scopes || !isObj(target)) return false;
272
+ if (own(target, 'repeat') === true) return false;
273
+ if (tool === 'com.reply' && own(target, 'fenced') === true) return false;
274
+ const text = (v) => (typeof v === 'string' ? v : undefined);
275
+ const strings = (v) => new Set(Array.isArray(v) ? v.filter((item) => typeof item === 'string') : []);
276
+ const thread = text(own(target, 'thread'));
277
+ const project = text(own(target, 'project'));
278
+ const capability = text(own(target, 'capability'));
279
+ if (scopes.includes('thread') && thread !== undefined && strings(own(holds, 'threads')).has(thread)) return true;
280
+ if (scopes.includes('project') && project !== undefined && strings(own(holds, 'projects')).has(project)) return true;
281
+ const slots = own(holds, 'slots');
282
+ if (scopes.includes('slot') && project !== undefined && capability !== undefined && Array.isArray(slots)) {
283
+ return slots.some((slot) => own(slot, 'project') === project && own(slot, 'capability') === capability);
284
+ }
285
+ return false;
286
+ }
287
+
247
288
  /** Look a Project up in one com.directory page a Project session read; the page is checked first (FAC-SEM-107). */
248
289
  export function findProject(page, projectId, capability) {
249
290
  const problems = directoryProblems(page);
@@ -493,6 +534,9 @@ export class Responder {
493
534
  this.said = {};
494
535
  this.rr = 0;
495
536
  this.reconcileAt = 0;
537
+ /** The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS. */
538
+ this.holdDelay = POLL_SECONDS;
539
+ this.onHold = new Set();
496
540
  }
497
541
 
498
542
  // -- events --
@@ -568,6 +612,10 @@ export class Responder {
568
612
  try {
569
613
  receipt = this.receipt('com.enroll', await this.call('com.enroll', args));
570
614
  } catch (e) {
615
+ if (e.code === 'held') {
616
+ this.pause(capability);
617
+ return;
618
+ }
571
619
  if (e.code === 'fenced') {
572
620
  this.slots[capability] = { state: 'contested', renewedAt: this.now() };
573
621
  this.emit('board.slot.contested', 'warn', `Another responder holds the ${capability} slot on this Project; this service waits for it to lapse and never takes it over by itself.`, { notify: true, episode: capability, capability });
@@ -577,6 +625,27 @@ export class Responder {
577
625
  }
578
626
  this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
579
627
  delete this.said['board.slot.contested'];
628
+ this.lifted(capability);
629
+ }
630
+
631
+ /**
632
+ * The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and never act
633
+ * around the hold. Accepted work in progress continues; only new work waits.
634
+ */
635
+ pause(capability) {
636
+ const slot = this.slots[capability] ?? {};
637
+ const pauses = slot.state === 'paused' ? (slot.pauses ?? 0) + 1 : 1;
638
+ const wait = Math.min(HOLD_RETRY_MAX_SECONDS, this.holdDelay * 2 ** (pauses - 1));
639
+ this.slots[capability] = { state: 'paused', pauses, retryAt: this.now() + wait, renewedAt: this.now() };
640
+ if (!this.onHold.has(capability)) { // once per hold, per capability
641
+ this.onHold.add(capability);
642
+ this.emit('board.slot.held', 'info', `The operator holds new work for ${capability} on this Project; the service waits and asks again later. Work already accepted continues.`, { capability });
643
+ }
644
+ }
645
+
646
+ lifted(capability) {
647
+ if (!this.onHold.delete(capability)) return;
648
+ this.emit('board.slot.released', 'info', `The operator's hold on ${capability} was lifted; the service takes new work again.`, { capability });
580
649
  }
581
650
 
582
651
  // -- restart: reconcile and transfer --
@@ -607,9 +676,17 @@ export class Responder {
607
676
  else if (state === 'accepted') (transfer[entry.capability] ??= []).push(entry);
608
677
  else reconcile.push(entry);
609
678
  }
610
- const generations = Object.fromEntries((status.responders ?? []).filter(isObj).map((r) => [r.capability, r.generation ?? 0]));
679
+ const responders = (status.responders ?? []).filter(isObj);
680
+ const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
681
+ const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
611
682
  for (const capability of capabilities) {
612
- if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
683
+ if (onHold.has(capability)) {
684
+ for (const entry of transfer[capability] ?? []) {
685
+ entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
686
+ this.checkpoint.put(entry);
687
+ }
688
+ this.pause(capability);
689
+ } else if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
613
690
  else await this.enroll(capability);
614
691
  }
615
692
  for (const entry of reconcile) if (this.slots[entry.capability]?.state === 'held') await this.reconcile(entry);
@@ -618,8 +695,20 @@ export class Responder {
618
695
  async replace(capability, entries, generation) {
619
696
  const args = { idempotency: this.idem(this.key('replace')), capability, expectedGeneration: isInt(generation, 0) ? generation : 0,
620
697
  transfer: { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } } };
621
- const receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
698
+ let receipt;
699
+ try {
700
+ receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
701
+ } catch (e) {
702
+ if (e.code !== 'held') throw e;
703
+ for (const entry of entries) {
704
+ entry.step = 'lapsed'; // nothing moved: taken back by a transfer once the hold is lifted
705
+ this.checkpoint.put(entry);
706
+ }
707
+ this.pause(capability);
708
+ return;
709
+ }
622
710
  this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
711
+ this.lifted(capability);
623
712
  const moved = new Map((receipt.transferred ?? []).map((item) => [item.request, item]));
624
713
  for (const entry of entries) {
625
714
  const item = moved.get(entry.request);
@@ -697,6 +786,9 @@ export class Responder {
697
786
  this.state = 'not-authorized';
698
787
  await this.dropSession();
699
788
  this.emit('board.access.needed', 'warn', '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 });
789
+ } else if (code === 'held') {
790
+ this.state = 'paused';
791
+ 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 });
700
792
  } else if (['no-epoch', 'schema-unsupported', 'checkpoint', 'receipt-shape'].includes(code)) {
701
793
  this.state = 'down';
702
794
  await this.dropSession();
@@ -736,6 +828,11 @@ export class Responder {
736
828
  }
737
829
  const heldByWork = work && this.current ? work.entry.capability : null;
738
830
  for (const [capability, slot] of Object.entries(this.slots)) {
831
+ if (slot.state === 'paused') {
832
+ // still held: a longer pause; lifted: the slot is ours again
833
+ if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0)) await this.enroll(capability);
834
+ continue;
835
+ }
739
836
  if (capability !== heldByWork && this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.enroll(capability);
740
837
  }
741
838
  if (!this.current && !this.stopping) await this.continueLapsed();
@@ -790,6 +887,10 @@ export class Responder {
790
887
  this.slots[capability] = { state: 'contested', renewedAt: this.now() };
791
888
  continue;
792
889
  }
890
+ if (e.code === 'held') {
891
+ this.pause(capability);
892
+ continue;
893
+ }
793
894
  throw e;
794
895
  }
795
896
  if (!('request' in answer)) continue; // nothing claimable; the same key polls again
@@ -843,7 +944,17 @@ export class Responder {
843
944
  entry.keys.accept ??= this.key('accept');
844
945
  entry.step = 'accepting';
845
946
  this.checkpoint.put(entry);
846
- const receipt = await this.fenced('com.accept', entry, { fence: this.fence(entry, entry.keys.accept) });
947
+ let receipt;
948
+ try {
949
+ receipt = await this.fenced('com.accept', entry, { fence: this.fence(entry, entry.keys.accept) });
950
+ } catch (e) {
951
+ if (e.code !== 'held') throw e;
952
+ // A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request waits.
953
+ this.checkpoint.drop(entry.request);
954
+ this.emit('board.request.waiting', 'info', 'A claimed request was not accepted: the operator holds it. Nothing was done; it waits on the board.', { request: entry.request, capability: entry.capability });
955
+ this.pause(entry.capability);
956
+ return false;
957
+ }
847
958
  if (!receipt) return false; // the claim was lost before it was accepted; nothing was done
848
959
  entry.revision = receipt.request.revision;
849
960
  entry.step = 'accepted';
@@ -916,20 +1027,42 @@ export class Responder {
916
1027
  if (!(await this.fenced('com.progress', entry, args))) throw new Stopped();
917
1028
  }
918
1029
 
919
- async effectBegin(entry, summary) {
1030
+ async effectBegin(entry, summary, ctx) {
920
1031
  entry.keys.effect_begin ??= this.key('effect');
921
1032
  entry.step = 'effect-beginning';
922
1033
  entry.effect = true; // from here a lost attempt is an unknown outcome, never "nothing happened"
923
1034
  this.checkpoint.put(entry);
924
1035
  const args = { fence: this.fence(entry, entry.keys.effect_begin), ...(summary ? { summary: clean(summary) } : {}) };
925
1036
  let receipt = null;
926
- for (let attempt = 0; attempt < 3; attempt += 1) {
1037
+ let lost = 0;
1038
+ let delay = this.holdDelay;
1039
+ for (;;) { // a lost answer: the same key again tells whether it was stored
927
1040
  try {
928
1041
  receipt = await this.fenced('com.effect_begin', entry, args);
929
1042
  break;
930
1043
  } catch (e) {
931
- if (e.code !== 'unreachable' || attempt === 2) throw new Stopped();
932
- await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 1000));
1044
+ if (e.code === 'held') {
1045
+ // The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick renews it;
1046
+ // com.renew is never held), and ask again with the same key.
1047
+ entry.effect = false;
1048
+ entry.step = 'effect-held';
1049
+ this.checkpoint.put(entry);
1050
+ this.emit('board.request.waiting', 'info', 'The operator holds this request before its effect: the service waits and does nothing outside it until the hold is lifted.', { episode: entry.request, request: entry.request, capability: entry.capability });
1051
+ const until = Date.now() + delay * 1000;
1052
+ while (Date.now() < until) {
1053
+ if (ctx?.stopped) throw new Stopped();
1054
+ await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, until - Date.now()))));
1055
+ }
1056
+ if (ctx?.stopped) throw new Stopped();
1057
+ delay = Math.min(HOLD_RETRY_MAX_SECONDS, delay * 2);
1058
+ entry.effect = true;
1059
+ entry.step = 'effect-beginning';
1060
+ this.checkpoint.put(entry);
1061
+ continue;
1062
+ }
1063
+ if (e.code !== 'unreachable' || lost === 2) throw new Stopped();
1064
+ lost += 1;
1065
+ await new Promise((resolve) => setTimeout(resolve, lost * 1000));
933
1066
  }
934
1067
  }
935
1068
  if (!receipt) throw new Stopped();
@@ -60,13 +60,21 @@ REFUSAL_CODES = frozenset(["invalid_arguments", "body_too_large", "not_authorize
60
60
  "unsupported_capability", "unsupported_operation", "not_available", "idempotency_conflict",
61
61
  "idempotency_window_expired", "capacity_exceeded", "digest_conflict", "revision_conflict",
62
62
  "fenced", "lease_expired", "invalid_transition", "reconcile_required", "observation_required",
63
- "cursor_reset_required", "immutable_participants"])
63
+ "cursor_reset_required", "immutable_participants", "held"])
64
64
  #: On these the attempt is no longer ours: stop acting at once (C4).
65
65
  STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
66
66
  REQUEST_STATES = ("queued", "claimed", "accepted", "in_progress", "completed", "failed_known", "cancelled", "expired",
67
67
  "outcome_unknown")
68
68
  EFFECT_STATES = ("not_started", "started", "succeeded_observed", "failed_observed", "unknown")
69
69
  TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
70
+ #: DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
71
+ #: `held` — it settles, reports on or keeps alive started work (com.renew, com.progress, com.complete, com.reconcile),
72
+ #: cancels or reads. The contract's HOLD_SCOPES.
73
+ HOLD_SCOPES = {"com.submit": ("thread",), "com.reply": ("thread",), "com.enroll": ("project", "slot"),
74
+ "com.responder_replace": ("project", "slot"), "com.claim": ("thread", "project", "slot"),
75
+ "com.accept": ("thread", "project", "slot"), "com.effect_begin": ("thread", "project", "slot")}
76
+ #: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
77
+ HOLD_RETRY_MAX_SECONDS = 900.0
70
78
 
71
79
  _OPAQUE = re.compile(r"[A-Za-z0-9_-]{8,128}")
72
80
  _PROJECT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,191}")
@@ -188,7 +196,7 @@ def receipt_problems(value: Any, op: Optional[str] = None) -> List[str]:
188
196
  if "request" in value:
189
197
  r = value["request"]
190
198
  keys = ("id", "state", "effect", "revision")
191
- if _closed(r, "/request", keys, keys, problems):
199
+ if _closed(r, "/request", keys, keys + ("settledBy",), problems):
192
200
  if not (isinstance(r.get("id"), str) and _OPAQUE.fullmatch(r["id"])):
193
201
  problems.append("/request/id is a board id")
194
202
  if r.get("state") not in REQUEST_STATES:
@@ -197,6 +205,12 @@ def receipt_problems(value: Any, op: Optional[str] = None) -> List[str]:
197
205
  problems.append("/request/effect is an effect state")
198
206
  if not _is_int(r.get("revision"), 1):
199
207
  problems.append("/request/revision is an integer ≥ 1")
208
+ if "settledBy" in r:
209
+ # DEC-0081: present only when the estate owner settled it; absent, the responder did.
210
+ if r["settledBy"] != "owner":
211
+ problems.append("/request/settledBy is owner")
212
+ if r.get("state") not in ("completed", "failed_known"):
213
+ problems.append("/request/settledBy names a settled request (completed or failed_known)")
200
214
  if "slot" in value:
201
215
  s = value["slot"]
202
216
  keys = ("project", "capability", "generation", "leaseExpiresAt")
@@ -248,6 +262,8 @@ def refusal_problems(value: Any) -> List[str]:
248
262
  problems.append("/error/message is 1 to 300 characters")
249
263
  if not isinstance(e.get("retryable"), bool):
250
264
  problems.append("/error/retryable is a boolean")
265
+ elif e.get("code") == "held" and e["retryable"] is not True:
266
+ problems.append("/error/retryable is true for held: the operator lifts a hold (DEC-0079)")
251
267
  return problems
252
268
 
253
269
 
@@ -312,14 +328,48 @@ def directory_problems(page: Any) -> List[str]:
312
328
  continue
313
329
  for c, entry in enumerate(caps):
314
330
  cat = "%s/capabilities/%d" % (at, c)
315
- if _closed(entry, cat, ("capability", "responder"), ("capability", "responder"), problems):
331
+ if _closed(entry, cat, ("capability", "responder"), ("capability", "responder", "held"), problems):
316
332
  if not (isinstance(entry.get("capability"), str) and _CAPABILITY.fullmatch(entry["capability"])):
317
333
  problems.append("%s/capability is a capability name" % cat)
318
334
  if entry.get("responder") not in ("active", "stale", "absent"):
319
335
  problems.append("%s/responder is active, stale or absent" % cat)
336
+ if "held" in entry and not isinstance(entry["held"], bool):
337
+ problems.append("%s/held is a boolean (DEC-0079)" % cat)
320
338
  return problems + ["%s %s" % (f["instancePath"], f["message"]) for f in directory_findings(page)]
321
339
 
322
340
 
341
+ def hold_refuses(tool: Any, target: Any, holds: Any) -> bool:
342
+ """DEC-0079, the contract's ``holdRefuses``: True when a board refuses ``tool`` on ``target`` with ``held`` under
343
+ ``holds``. ``target`` is what the board resolved (``thread``, ``project``, ``capability``, ``fenced``, ``repeat``);
344
+ ``holds`` is ``{threads, projects, slots: [{project, capability}]}``. Ids compare as written. Malformed input never
345
+ raises: a member of the wrong type counts as absent, and an unknown tool is never refused ``held``."""
346
+ scopes = HOLD_SCOPES.get(tool) if isinstance(tool, str) else None
347
+ if not scopes or not isinstance(target, dict):
348
+ return False
349
+ if target.get("repeat") is True:
350
+ return False
351
+ if tool == "com.reply" and target.get("fenced") is True:
352
+ return False
353
+ holds = holds if isinstance(holds, dict) else {}
354
+
355
+ def text(value: Any) -> Optional[str]:
356
+ return value if isinstance(value, str) else None
357
+
358
+ def strings(value: Any) -> set:
359
+ return {item for item in value if isinstance(item, str)} if isinstance(value, list) else set()
360
+
361
+ thread, project, capability = text(target.get("thread")), text(target.get("project")), text(target.get("capability"))
362
+ if "thread" in scopes and thread is not None and thread in strings(holds.get("threads")):
363
+ return True
364
+ if "project" in scopes and project is not None and project in strings(holds.get("projects")):
365
+ return True
366
+ slots = holds.get("slots")
367
+ if "slot" in scopes and project is not None and capability is not None and isinstance(slots, list):
368
+ return any(isinstance(slot, dict) and slot.get("project") == project and slot.get("capability") == capability
369
+ for slot in slots)
370
+ return False
371
+
372
+
323
373
  def find_project(page: Any, project_id: str, capability: Optional[str] = None) -> Optional[Dict[str, Any]]:
324
374
  """Look a Project up in one `com.directory` page (read by a Project session — a board session is not served the
325
375
  directory). The page is checked first: a page the contract would refuse, FAC-SEM-107 included, raises
@@ -630,6 +680,9 @@ class Responder:
630
680
  self.claim_keys: Dict[str, str] = {}
631
681
  self._said: Dict[str, str] = {}
632
682
  self._rr = 0
683
+ #: The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS.
684
+ self.hold_delay = POLL_SECONDS
685
+ self._on_hold: set = set()
633
686
 
634
687
  # -- events -------------------------------------------------------------------------------------------------
635
688
  def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None, **value: Any) -> None:
@@ -712,6 +765,9 @@ class Responder:
712
765
  try:
713
766
  receipt = self._receipt("com.enroll", self._call("com.enroll", args))
714
767
  except BoardError as exc:
768
+ if exc.code == "held":
769
+ self._pause(capability)
770
+ return
715
771
  if exc.code == "fenced":
716
772
  self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
717
773
  self._emit("board.slot.contested", "warn", "Another responder holds the %s slot on this Project; this service "
@@ -721,6 +777,25 @@ class Responder:
721
777
  raise
722
778
  self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
723
779
  self._said.pop("board.slot.contested", None)
780
+ self._lifted(capability)
781
+
782
+ def _pause(self, capability: str) -> None:
783
+ """The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and
784
+ never act around the hold. Accepted work in progress continues; only new work waits."""
785
+ slot = self.slots.get(capability) or {}
786
+ pauses = slot.get("pauses", 0) + 1 if slot.get("state") == "paused" else 1
787
+ wait = min(HOLD_RETRY_MAX_SECONDS, self.hold_delay * 2 ** (pauses - 1))
788
+ self.slots[capability] = {"state": "paused", "pauses": pauses, "retry_at": self.now() + wait, "renewed_at": self.now()}
789
+ if capability not in self._on_hold: # once per hold, per capability
790
+ self._on_hold.add(capability)
791
+ self._emit("board.slot.held", "info", "The operator holds new work for %s on this Project; the service waits and "
792
+ "asks again later. Work already accepted continues." % capability, capability=capability)
793
+
794
+ def _lifted(self, capability: str) -> None:
795
+ if capability in self._on_hold:
796
+ self._on_hold.discard(capability)
797
+ self._emit("board.slot.released", "info", "The operator's hold on %s was lifted; the service takes new work "
798
+ "again." % capability, capability=capability)
724
799
 
725
800
  # -- restart: reconcile and transfer -------------------------------------------------------------------------
726
801
  def _read_state(self, entry: Dict[str, Any]) -> Optional[str]:
@@ -750,10 +825,17 @@ class Responder:
750
825
  transfer.setdefault(entry["capability"], []).append(entry)
751
826
  else: # in_progress (lost result once a new generation holds the slot) or outcome_unknown
752
827
  reconcile.append(entry)
753
- generations = {r.get("capability"): r.get("generation", 0) for r in status.get("responders") or [] if isinstance(r, dict)}
828
+ responders = [r for r in status.get("responders") or [] if isinstance(r, dict)]
829
+ generations = {r.get("capability"): r.get("generation", 0) for r in responders}
830
+ on_hold = {r.get("capability") for r in responders if r.get("held") is True} # DEC-0079
754
831
  for capability in capabilities:
755
832
  held = transfer.get(capability)
756
- if held:
833
+ if capability in on_hold:
834
+ for entry in held or []:
835
+ entry["step"] = "lapsed" # taken back by a transfer once the hold is lifted
836
+ self.checkpoint.put(entry)
837
+ self._pause(capability)
838
+ elif held:
757
839
  self._replace(capability, held, generations.get(capability, 0))
758
840
  else:
759
841
  self._enroll(capability)
@@ -767,8 +849,18 @@ class Responder:
767
849
  args = {"idempotency": self._idem(self._key("replace")), "capability": capability, "expectedGeneration": generation,
768
850
  "transfer": {"requests": [e["request"] for e in entries][:64],
769
851
  "checkpoint": {"summary": "Resumed by the same service after a restart; no effect had begun."}}}
770
- receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
852
+ try:
853
+ receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
854
+ except BoardError as exc:
855
+ if exc.code != "held":
856
+ raise
857
+ for entry in entries:
858
+ entry["step"] = "lapsed" # nothing moved: taken back by a transfer once the hold is lifted
859
+ self.checkpoint.put(entry)
860
+ self._pause(capability)
861
+ return
771
862
  self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
863
+ self._lifted(capability)
772
864
  moved = {item["request"]: item for item in receipt.get("transferred") or []}
773
865
  for entry in entries:
774
866
  item = moved.get(entry["request"])
@@ -848,6 +940,10 @@ class Responder:
848
940
  self._emit("board.access.needed", "warn", "This service may not answer requests for this Project: the operator "
849
941
  "has to allow it in Fabric (Agents & access). Nothing is claimed until then.", notify=True,
850
942
  episode="access", reason=code)
943
+ elif code == "held":
944
+ self.state = "paused"
945
+ self._emit("board.held", "info", "The operator holds new work on this Project; the responder waits and asks again "
946
+ "with a growing pause.", episode="held", reason=code)
851
947
  elif code in ("no-epoch", "schema-unsupported", "checkpoint", "receipt-shape"):
852
948
  self.state = "down"
853
949
  self._drop_session()
@@ -887,6 +983,10 @@ class Responder:
887
983
  self._renew(work)
888
984
  held_by_work = work.entry["capability"] if work is not None and self.current is not None else None
889
985
  for capability, slot in list(self.slots.items()):
986
+ if slot.get("state") == "paused":
987
+ if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0):
988
+ self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again
989
+ continue
890
990
  if capability != held_by_work and self.now() - slot.get("renewed_at", 0) >= RENEW_SECONDS - 0.5:
891
991
  self._enroll(capability) # the holder's enroll renews its lease (no event); a lapsed one re-enrolls
892
992
  if self.current is None and not self.stopping:
@@ -937,6 +1037,9 @@ class Responder:
937
1037
  if exc.code == "fenced":
938
1038
  self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
939
1039
  continue
1040
+ if exc.code == "held":
1041
+ self._pause(capability)
1042
+ continue
940
1043
  raise
941
1044
  if "request" not in answer:
942
1045
  continue # nothing claimable; the same key polls again
@@ -992,7 +1095,18 @@ class Responder:
992
1095
  key = entry["keys"].get("accept") or self._key("accept")
993
1096
  entry["keys"]["accept"], entry["step"] = key, "accepting"
994
1097
  self.checkpoint.put(entry)
995
- receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
1098
+ try:
1099
+ receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
1100
+ except BoardError as exc:
1101
+ if exc.code != "held":
1102
+ raise
1103
+ # A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request
1104
+ # waits on the board for the hold to be lifted.
1105
+ self.checkpoint.drop(entry["request"])
1106
+ self._emit("board.request.waiting", "info", "A claimed request was not accepted: the operator holds it. Nothing "
1107
+ "was done; it waits on the board.", request=entry["request"], capability=entry["capability"])
1108
+ self._pause(entry["capability"])
1109
+ return False
996
1110
  if receipt is None:
997
1111
  return False # the claim was lost before it was accepted; nothing was done
998
1112
  entry["revision"], entry["step"] = receipt["request"]["revision"], "accepted"
@@ -1087,14 +1201,30 @@ class Responder:
1087
1201
  if summary:
1088
1202
  args["summary"] = " ".join(str(summary).split())[:SUMMARY_MAX]
1089
1203
  receipt = None
1090
- for attempt in range(3): # a lost answer: the same key again tells whether it was stored
1204
+ lost, delay = 0, self.hold_delay
1205
+ while True: # a lost answer: the same key again tells whether it was stored
1091
1206
  try:
1092
1207
  receipt = self._fenced("com.effect_begin", entry, args)
1093
1208
  break
1094
1209
  except BoardError as exc:
1095
- if exc.code != "unreachable" or attempt == 2:
1210
+ if exc.code == "held":
1211
+ # The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick
1212
+ # renews it; com.renew is never held), and ask again with the same key.
1213
+ entry["effect"], entry["step"] = False, "effect-held"
1214
+ self.checkpoint.put(entry)
1215
+ self._emit("board.request.waiting", "info", "The operator holds this request before its effect: the "
1216
+ "service waits and does nothing outside it until the hold is lifted.", episode=entry["request"],
1217
+ request=entry["request"], capability=entry["capability"])
1218
+ if ctx.stopped.wait(delay):
1219
+ raise Stopped() from None
1220
+ delay = min(HOLD_RETRY_MAX_SECONDS, delay * 2)
1221
+ entry["effect"], entry["step"] = True, "effect-beginning"
1222
+ self.checkpoint.put(entry)
1223
+ continue
1224
+ if exc.code != "unreachable" or lost == 2:
1096
1225
  raise Stopped() from None
1097
- time.sleep(attempt + 1)
1226
+ lost += 1
1227
+ time.sleep(lost)
1098
1228
  if receipt is None:
1099
1229
  raise Stopped()
1100
1230
  entry["revision"], entry["step"] = receipt["request"]["revision"], "in-progress"
@@ -5,9 +5,9 @@ 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.31.1"
8
+ version: "0.32.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
10
+ contract-commit: "1636fa97262f4a26e2086572d540c2ee701e0010"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -123,7 +123,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
123
123
  The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
124
124
  does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
125
125
 
126
- Pin exactly contract `0.1.0` at commit `f00a7685e3861c27f7899c033fb850a145a1708f` and
126
+ Pin exactly contract `0.1.0` at commit `1636fa97262f4a26e2086572d540c2ee701e0010` and
127
127
  read the pinned guide before implementing protocol details. If this skill is installed
128
128
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
129
129
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
@@ -19,7 +19,7 @@ compatibility: >-
19
19
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
20
20
  metadata:
21
21
  author: PassionCode.ai
22
- version: "0.31.1"
22
+ version: "0.32.0"
23
23
  ---
24
24
 
25
25
  # Working with Fabric
@@ -46,6 +46,26 @@ capability (a plain question may be `kind: "message"`), in a new thread whose pa
46
46
  SendMessage between sessions. agent-sync leases guard files; they carry no asks.
47
47
 
48
48
  A request the board stored has not been carried out. Only its state (`completed`, `failed_known`) says what happened.
49
+ `settledBy: "owner"` on a request (DEC-0081) means the estate owner settled it after its responder was gone: read it as
50
+ the owner's attestation, not the responder's.
51
+
52
+ **`held` (DEC-0079).** The operator holds that thread, that Project or that capability, and the board refuses new
53
+ work there with `held`, which is always retryable. Tell the person what is held. Retry later, the pause growing, with
54
+ the **same idempotency key and arguments**. Never post the ask into another thread or to another Project to get around
55
+ the hold. Reads, cancels and replies to work already started are never held.
56
+
57
+ **A problem for another Project: a structured finding (DEC-0080).** Send one only when the running Fabric's
58
+ `com.submit` input schema lists `finding`; otherwise send a plain message. A finding carries:
59
+
60
+ - `severity`: `info`, `warning` or `blocking`;
61
+ - `consequence`: one sentence on what it breaks;
62
+ - `affects`: the backlog rows (`{repository, path, id}`) or host tasks it touches;
63
+ - a `dedupeKey` that stays the same for the same problem.
64
+
65
+ Its idempotency key is at least 128 random bits (for example 32 hex characters from a cryptographic generator), kept
66
+ on every retry. A receipt with `repeated: true` and a fresh key means that finding already stands on the board: its
67
+ thread may not be the one you named, and nothing new was posted. Never put a `{redacted: true}` item into a finding you
68
+ send.
49
69
 
50
70
  ## The project directory
51
71
 
@@ -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.31.1"
63
+ VERSION = "0.32.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