@cotal-ai/core 0.85.0 → 0.87.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.
Files changed (204) hide show
  1. package/dist/agent-file.d.ts.map +1 -1
  2. package/dist/agent-file.js +10 -3
  3. package/dist/agent-file.js.map +1 -1
  4. package/dist/base64url.d.ts +5 -0
  5. package/dist/base64url.d.ts.map +1 -0
  6. package/dist/base64url.js +14 -0
  7. package/dist/base64url.js.map +1 -0
  8. package/dist/browser-control.d.ts +149 -0
  9. package/dist/browser-control.d.ts.map +1 -0
  10. package/dist/browser-control.js +167 -0
  11. package/dist/browser-control.js.map +1 -0
  12. package/dist/browser-validators-build.d.ts +23 -0
  13. package/dist/browser-validators-build.d.ts.map +1 -0
  14. package/dist/browser-validators-build.js +149 -0
  15. package/dist/browser-validators-build.js.map +1 -0
  16. package/dist/canonical-json.d.ts +14 -0
  17. package/dist/canonical-json.d.ts.map +1 -0
  18. package/dist/canonical-json.js +121 -0
  19. package/dist/canonical-json.js.map +1 -0
  20. package/dist/canonical.d.ts +2 -10
  21. package/dist/canonical.d.ts.map +1 -1
  22. package/dist/canonical.js +2 -108
  23. package/dist/canonical.js.map +1 -1
  24. package/dist/channels.d.ts +4 -0
  25. package/dist/channels.d.ts.map +1 -1
  26. package/dist/channels.js +6 -2
  27. package/dist/channels.js.map +1 -1
  28. package/dist/checkpoint-answer.d.ts.map +1 -1
  29. package/dist/checkpoint-answer.js +3 -4
  30. package/dist/checkpoint-answer.js.map +1 -1
  31. package/dist/connector-setup.d.ts +10 -1
  32. package/dist/connector-setup.d.ts.map +1 -1
  33. package/dist/digest-id.d.ts +4 -0
  34. package/dist/digest-id.d.ts.map +1 -0
  35. package/dist/digest-id.js +7 -0
  36. package/dist/digest-id.js.map +1 -0
  37. package/dist/endpoint-action.d.ts +2 -2
  38. package/dist/endpoint-action.d.ts.map +1 -1
  39. package/dist/endpoint-action.js +6 -44
  40. package/dist/endpoint-action.js.map +1 -1
  41. package/dist/endpoint-auth-digest.d.ts +7 -0
  42. package/dist/endpoint-auth-digest.d.ts.map +1 -0
  43. package/dist/endpoint-auth-digest.js +13 -0
  44. package/dist/endpoint-auth-digest.js.map +1 -0
  45. package/dist/endpoint-call.d.ts +176 -0
  46. package/dist/endpoint-call.d.ts.map +1 -0
  47. package/dist/endpoint-call.js +490 -0
  48. package/dist/endpoint-call.js.map +1 -0
  49. package/dist/endpoint-caller.d.ts +113 -0
  50. package/dist/endpoint-caller.d.ts.map +1 -0
  51. package/dist/endpoint-caller.js +260 -0
  52. package/dist/endpoint-caller.js.map +1 -0
  53. package/dist/endpoint-checkpoint.d.ts.map +1 -1
  54. package/dist/endpoint-checkpoint.js +5 -14
  55. package/dist/endpoint-checkpoint.js.map +1 -1
  56. package/dist/endpoint-envelope.d.ts +0 -6
  57. package/dist/endpoint-envelope.d.ts.map +1 -1
  58. package/dist/endpoint-envelope.js +17 -18
  59. package/dist/endpoint-envelope.js.map +1 -1
  60. package/dist/endpoint-goal-follow.d.ts +37 -0
  61. package/dist/endpoint-goal-follow.d.ts.map +1 -0
  62. package/dist/endpoint-goal-follow.js +328 -0
  63. package/dist/endpoint-goal-follow.js.map +1 -0
  64. package/dist/endpoint-grants.d.ts +0 -4
  65. package/dist/endpoint-grants.d.ts.map +1 -1
  66. package/dist/endpoint-grants.js +1 -7
  67. package/dist/endpoint-grants.js.map +1 -1
  68. package/dist/endpoint-guard.d.ts.map +1 -1
  69. package/dist/endpoint-guard.js +2 -10
  70. package/dist/endpoint-guard.js.map +1 -1
  71. package/dist/endpoint-invoke.d.ts +37 -143
  72. package/dist/endpoint-invoke.d.ts.map +1 -1
  73. package/dist/endpoint-invoke.js +60 -605
  74. package/dist/endpoint-invoke.js.map +1 -1
  75. package/dist/endpoint-publish-denial.d.ts +1 -1
  76. package/dist/endpoint-publish-denial.d.ts.map +1 -1
  77. package/dist/endpoint-publish-denial.js +1 -1
  78. package/dist/endpoint-publish-denial.js.map +1 -1
  79. package/dist/endpoint-receipt.d.ts.map +1 -1
  80. package/dist/endpoint-receipt.js +2 -16
  81. package/dist/endpoint-receipt.js.map +1 -1
  82. package/dist/endpoint-signing.d.ts +1 -1
  83. package/dist/endpoint-signing.d.ts.map +1 -1
  84. package/dist/endpoint-signing.js +1 -1
  85. package/dist/endpoint-signing.js.map +1 -1
  86. package/dist/endpoint-subjects.d.ts +4 -0
  87. package/dist/endpoint-subjects.d.ts.map +1 -1
  88. package/dist/endpoint-subjects.js +6 -0
  89. package/dist/endpoint-subjects.js.map +1 -1
  90. package/dist/endpoint-traits.d.ts.map +1 -1
  91. package/dist/endpoint-traits.js +3 -20
  92. package/dist/endpoint-traits.js.map +1 -1
  93. package/dist/endpoint-verbs.d.ts +18 -151
  94. package/dist/endpoint-verbs.d.ts.map +1 -1
  95. package/dist/endpoint-verbs.js +4 -500
  96. package/dist/endpoint-verbs.js.map +1 -1
  97. package/dist/endpoint-work.d.ts.map +1 -1
  98. package/dist/endpoint-work.js +2 -17
  99. package/dist/endpoint-work.js.map +1 -1
  100. package/dist/endpoint.d.ts +113 -24
  101. package/dist/endpoint.d.ts.map +1 -1
  102. package/dist/endpoint.js +330 -78
  103. package/dist/endpoint.js.map +1 -1
  104. package/dist/goal-result-fact.d.ts +13 -0
  105. package/dist/goal-result-fact.d.ts.map +1 -0
  106. package/dist/goal-result-fact.js +40 -0
  107. package/dist/goal-result-fact.js.map +1 -0
  108. package/dist/index.d.ts +5 -0
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +5 -0
  111. package/dist/index.js.map +1 -1
  112. package/dist/issued-accepted.d.ts +27 -0
  113. package/dist/issued-accepted.d.ts.map +1 -0
  114. package/dist/issued-accepted.js +62 -0
  115. package/dist/issued-accepted.js.map +1 -0
  116. package/dist/issued-authority.d.ts +3 -18
  117. package/dist/issued-authority.d.ts.map +1 -1
  118. package/dist/issued-authority.js +4 -55
  119. package/dist/issued-authority.js.map +1 -1
  120. package/dist/jetstream-missing.d.ts +4 -0
  121. package/dist/jetstream-missing.d.ts.map +1 -1
  122. package/dist/jetstream-missing.js +6 -0
  123. package/dist/jetstream-missing.js.map +1 -1
  124. package/dist/kv-scan.d.ts +15 -0
  125. package/dist/kv-scan.d.ts.map +1 -1
  126. package/dist/kv-scan.js +32 -0
  127. package/dist/kv-scan.js.map +1 -1
  128. package/dist/launch-material.d.ts.map +1 -1
  129. package/dist/launch-material.js +6 -2
  130. package/dist/launch-material.js.map +1 -1
  131. package/dist/lease.d.ts +50 -0
  132. package/dist/lease.d.ts.map +1 -1
  133. package/dist/lease.js +71 -0
  134. package/dist/lease.js.map +1 -1
  135. package/dist/lifecycle-state.d.ts +3 -0
  136. package/dist/lifecycle-state.d.ts.map +1 -1
  137. package/dist/lifecycle-state.js +7 -0
  138. package/dist/lifecycle-state.js.map +1 -1
  139. package/dist/manager-caller.d.ts.map +1 -1
  140. package/dist/manager-caller.js +2 -1
  141. package/dist/manager-caller.js.map +1 -1
  142. package/dist/one-line.d.ts +6 -0
  143. package/dist/one-line.d.ts.map +1 -0
  144. package/dist/one-line.js +8 -0
  145. package/dist/one-line.js.map +1 -0
  146. package/dist/peer-label.d.ts +16 -0
  147. package/dist/peer-label.d.ts.map +1 -0
  148. package/dist/peer-label.js +15 -0
  149. package/dist/peer-label.js.map +1 -0
  150. package/dist/permission-denied.d.ts +8 -0
  151. package/dist/permission-denied.d.ts.map +1 -0
  152. package/dist/permission-denied.js +15 -0
  153. package/dist/permission-denied.js.map +1 -0
  154. package/dist/presence-text.d.ts +6 -0
  155. package/dist/presence-text.d.ts.map +1 -0
  156. package/dist/presence-text.js +25 -0
  157. package/dist/presence-text.js.map +1 -0
  158. package/dist/private-tool-results.d.ts +253 -0
  159. package/dist/private-tool-results.d.ts.map +1 -0
  160. package/dist/private-tool-results.js +141 -0
  161. package/dist/private-tool-results.js.map +1 -0
  162. package/dist/provision.d.ts +5 -0
  163. package/dist/provision.d.ts.map +1 -1
  164. package/dist/provision.js +40 -16
  165. package/dist/provision.js.map +1 -1
  166. package/dist/remote-manager-authority.d.ts +17 -0
  167. package/dist/remote-manager-authority.d.ts.map +1 -1
  168. package/dist/remote-manager-authority.js +40 -1
  169. package/dist/remote-manager-authority.js.map +1 -1
  170. package/dist/resolve.d.ts +2 -13
  171. package/dist/resolve.d.ts.map +1 -1
  172. package/dist/resolve.js +2 -12
  173. package/dist/resolve.js.map +1 -1
  174. package/dist/run-driver-grants.d.ts +5 -0
  175. package/dist/run-driver-grants.d.ts.map +1 -1
  176. package/dist/run-driver-grants.js +16 -4
  177. package/dist/run-driver-grants.js.map +1 -1
  178. package/dist/run-journal.d.ts.map +1 -1
  179. package/dist/run-journal.js +1 -6
  180. package/dist/run-journal.js.map +1 -1
  181. package/dist/run-migration.d.ts +21 -0
  182. package/dist/run-migration.d.ts.map +1 -1
  183. package/dist/run-migration.js +2 -27
  184. package/dist/run-migration.js.map +1 -1
  185. package/dist/run-notice.d.ts +20 -0
  186. package/dist/run-notice.d.ts.map +1 -1
  187. package/dist/run-notice.js +3 -28
  188. package/dist/run-notice.js.map +1 -1
  189. package/dist/schema-bounds.d.ts +24 -0
  190. package/dist/schema-bounds.d.ts.map +1 -0
  191. package/dist/schema-bounds.js +74 -0
  192. package/dist/schema-bounds.js.map +1 -0
  193. package/dist/schema-profile.d.ts +11 -24
  194. package/dist/schema-profile.d.ts.map +1 -1
  195. package/dist/schema-profile.js +16 -73
  196. package/dist/schema-profile.js.map +1 -1
  197. package/dist/subjects.d.ts +17 -0
  198. package/dist/subjects.d.ts.map +1 -1
  199. package/dist/subjects.js +30 -2
  200. package/dist/subjects.js.map +1 -1
  201. package/dist/turn-contract.d.ts.map +1 -1
  202. package/dist/turn-contract.js +14 -4
  203. package/dist/turn-contract.js.map +1 -1
  204. package/package.json +14 -1
@@ -1,171 +1,55 @@
1
- /**
2
- * The GENERIC caller path: describe an endpoint, fetch its
3
- * registered contracts from the §13.7 content store, recompile the digest-matching validators,
4
- * and invoke a named command — WITHOUT the caller compiling the endpoint's schemas ahead of time.
5
- * This is what a `cotal describe`/`cotal invoke` CLI and every migrated control consumer ride, so
6
- * a consumer no longer hand-imports the manager's contract module.
7
- *
8
- * The trust chain is the §13.7 one, end to end:
9
- * - `describe` (the reserved, authorization-scoped command every endpoint serves) answers the
10
- * caller's VISIBLE command set + the registered CLUSTER closure digests;
11
- * - each cluster document is fetched from the store at its closure digest and VERIFIED
12
- * (two-stage manifest→root, content-addressed) — the command's input/output CLOSURE digests
13
- * come from those verified bytes, never a caller assertion;
14
- * - each schema closure is fetched + PROFILE-recompiled; the recompiled contract's closureDigest
15
- * MUST equal the registered digest (a store that served the wrong bytes fails here);
16
- * - the invoke pins those digests, so the responder's digest-bound serve boundary honors exactly
17
- * the schema the caller validated against.
18
- */
19
- import { randomBytes, randomUUID } from "node:crypto";
20
- import { PermissionViolationError } from "@nats-io/transport-node";
21
- import { openPublishDenialWatch } from "./endpoint-publish-denial.js";
22
1
  import { jetstreamManager } from "@nats-io/jetstream";
23
- import { EpEnvelopeError, EP_UNBOUND_RESPONDER, EP_UNANSWERED, renderLifecycleBlocked, lifecycleBlockedFrom, replyRefusedBeforeEffect, replyTargetUnmapped, unansweredRequest } from "./endpoint-envelope.js";
2
+ import { EpEnvelopeError, replyRefusedBeforeEffect, replyTargetUnmapped } from "./endpoint-envelope.js";
24
3
  import { compileContract } from "./schema-profile.js";
25
- import { parseGoalResultFact, } from "./endpoint-action.js";
26
- import { contractStoreContext, fetchContractClosure, contractRefToHex, contractArtifactDigestHex, } from "./endpoint-contract-store.js";
4
+ import { parseGoalResultFact } from "./endpoint-action.js";
5
+ import { submitAndFollowGoalWith } from "./endpoint-goal-follow.js";
6
+ import { contractStoreContext, fetchContractClosure, contractRefToHex, contractArtifactDigestHex } from "./endpoint-contract-store.js";
27
7
  import { parseClusterDocument } from "./endpoint-cluster.js";
28
- import { epCall, epScatterService } from "./endpoint-verbs.js";
29
- import { epGoalProgressGrantRow } from "./endpoint-grants.js";
30
- import { epRequestSubject, epCallerReplyFilter, epPlaneTokens, parseEpSubject } from "./endpoint-subjects.js";
31
- import { parseEndpointReply } from "./endpoint-envelope.js";
32
- const dec = new TextDecoder(), enc = new TextEncoder();
33
- const nonce = () => randomBytes(24).toString("base64url");
34
- /** A describe is the reserved read-only discovery bootstrap, so an unanswered request may be
35
- * re-published within its ORIGINAL deadline. Core NATS does not retain a request sent before a
36
- * responder subscribes; without this bounded retry, a responder that registers one moment later
37
- * is invisible until the caller pays the whole deadline. Commands are never retried here. */
38
- const DESCRIBE_RETRY_MS = 250;
39
- /** Format a caught value on a path that cannot afford to throw (timer/callback). A
40
- * poisoned `Error.message` getter or `toString` must not escape the catch; the
41
- * fallback is the detail, never a reason to skip the rejection. */
42
- function caughtText(e, fallback) {
43
- try {
44
- return e instanceof Error ? e.message : String(e);
45
- }
46
- catch {
47
- return fallback;
48
- }
49
- }
50
- /** Delivery margin above an action owner's accepted readiness budget. Spawn's established generic
51
- * invariant was 40s client > 30s manager; preserve that measured 10s separation when a connector
52
- * declares a different budget instead of inventing a second timeout policy. */
53
- const GOAL_FOLLOW_MARGIN_MS = 10_000;
8
+ import { epScatterService } from "./endpoint-verbs.js";
9
+ import { BIND_SPLIT_REISSUES, dec, describeEndpoint, invokeCommand } from "./endpoint-caller.js";
10
+ export { BIND_SPLIT_REISSUES, describeEndpoint, goalFollowRefusal, invokeCommand } from "./endpoint-caller.js";
54
11
  /**
55
- * The reserved `describe` command as a raw request/reply (§13.7: describe pins NO contract, so it
56
- * carries no `op` digests — {@link epCall} always stamps digests and the serve boundary rejects a
57
- * digest-bearing describe as `contract-mismatch`, so this is a purpose-built raw path). It
58
- * REQUEST-BINDS its reply exactly as {@link epCall}'s `parseAttributedReply` does (§13.2): the
59
- * responder grant `epResponderReplyPattern` spans EVERY caller suffix, so any live responder can
60
- * publish on the caller's rail at any nonce — acceptance therefore checks the reply SUBJECT's
61
- * endpoint + nonce AND the body's echoed request id, not just "first `{ok:true}` on the rail".
62
- * A reply that fails any of these is IGNORED (not rejected: an attacker racing a wrong-nonce reply
63
- * must not be able to fail an honest describe), and the wait continues to the deadline. A signal
64
- * that aborts with a `TimeoutError` ends the wait as that deadline would.
12
+ * {@link invokeCommand} with a SPEC 13.2 bind refusal repaired rather than surfaced.
13
+ *
14
+ * An unpinned handle binds the instance that answered its describe, and the invoke is a second,
15
+ * independent trip through the same class queue, so in a multi-instance space another member
16
+ * routinely receives it and refuses before dispatching. That refusal states the command did not
17
+ * run ({@link replyRefusedBeforeEffect}), so re-resolving and re-issuing is a first attempt and is
18
+ * safe for any command, mutations included. A targeted call that reaches a member holding no
19
+ * mapping for its target ({@link replyTargetUnmapped}) is re-issued the same way, because with an
20
+ * instance-local resolver only the member hosting the target can serve it. The re-issue repeats
21
+ * until a member serves the call or {@link BIND_SPLIT_REISSUES} runs out, and then the last refusal
22
+ * surfaces unchanged, or the last no-mapping refusal when there was one.
23
+ *
24
+ * `reresolve` defaults to a fresh class resolve as the handle's caller; a caller that memoizes its
25
+ * handle passes one that drops the memo first, so later calls do not start from the refused bind.
26
+ * When it throws, the refusal surfaces, because it states that nothing ran, which the resolve
27
+ * failure raised in its place would lose.
28
+ *
29
+ * A pinned handle is never repaired: it names its instance, so a refusal from it is that instance
30
+ * answering about itself, and re-resolving on the class rail would reach another one.
65
31
  */
66
- export async function describeEndpoint(nc, space, endpoint, caller, opts = {}) {
67
- opts.signal?.throwIfAborted();
68
- const deadlineMs = opts.deadlineMs ?? 10_000;
69
- const n = nonce();
70
- const requestId = nonce();
71
- // `--on <instance>`: PIN the describe to one instance's `inst` route so a multi-manager
72
- // space resolves the exact instance addressed, not whichever wins the class `one` queue. Default =
73
- // class anycast (mode "one"), unchanged for every existing caller.
74
- const route = opts.instanceId !== undefined ? { mode: "inst", instanceId: opts.instanceId } : { mode: "one" };
75
- const subject = epRequestSubject(space, { route, endpoint, command: "describe", caller, nonce: n });
76
- // The plane this describe rides, recorded on the unanswered marker so a surface that renders a
77
- // reachability verdict can scope it (SPEC 13.15: the legacy and versioned rails are disjoint subject spaces,
78
- // an endpoint serves both, and a caller holds rows on one of them only). Silence on the versioned rail is
79
- // therefore consistent with a responder that predates the versioned rail and serves `ep` alone.
80
- const rail = epPlaneTokens(caller).join(".");
81
- const env = {
82
- v: 1, id: requestId, op: { endpoint, command: "describe" }, class: "ephemeral",
83
- replyExpected: true, deadlineMs, from: { id: `${caller.owner}.${caller.actor}`, name: caller.actor },
84
- };
85
- let sub;
86
- let timer;
87
- let retryTimer;
88
- let denialWatch;
89
- let onAbort;
90
- const startedAt = performance.now();
91
- const unanswered = (ms) => new EpEnvelopeError("deadline-exceeded", `no describe reply from ${endpoint}${opts.instanceId !== undefined ? ` instance ${opts.instanceId}` : ""} within ${ms}ms on the ${rail} rail`, [{ kind: EP_UNANSWERED, endpoint, command: "describe", rail }]);
92
- const cancelled = new Promise((_, reject) => {
93
- onAbort = () => reject(opts.signal?.reason?.name === "TimeoutError"
94
- ? unanswered(Math.round(performance.now() - startedAt))
95
- : new EpEnvelopeError("unavailable", `describe(${endpoint}) observation cancelled`));
96
- opts.signal?.addEventListener("abort", onAbort, { once: true });
97
- });
98
- try {
99
- // REGISTER THE PERMISSION WATCH BEFORE THE PUBLISH IT IS WATCHING. See
100
- // {@link openPublishDenialWatch}: a refused publish is otherwise indistinguishable
101
- // from an unanswered describe.
102
- denialWatch = openPublishDenialWatch(nc, subject, () => new EpEnvelopeError("permission-denied", `the describe for ${endpoint} was REFUSED BY THE BROKER, not unanswered: this caller's credential does not authorize publishing to "${subject}"${opts.instanceId !== undefined ? ` (the instance rail for ${opts.instanceId}: an instance-addressed call needs a credential minted with that instance, not a class-rail one)` : ""}. The responder may be perfectly healthy; the grant is what is missing (SPEC 13.2)`, undefined, "not-executed"), "describe");
103
- const got = new Promise((resolve, reject) => {
104
- sub = nc.subscribe(epCallerReplyFilter(space, caller), {
105
- callback: (err, msg) => {
106
- if (err) {
107
- reject(new EpEnvelopeError("unavailable", `describe reply subscription failed: ${err.message}`));
108
- return;
109
- }
110
- // REQUEST-BIND off the reply SUBJECT first (§13.2): the responder triple + nonce are
111
- // broker-pinned by the serve publish grant. A reply for a DIFFERENT endpoint, or on a
112
- // nonce that is not the one we published (the rail is shared across our concurrent
113
- // requests, and a hostile responder can publish at any nonce), is NOT ours — ignore it
114
- // and keep waiting, never fail the honest describe on an injected reply.
115
- const parsed = parseEpSubject(msg.subject);
116
- if (!parsed || parsed.plane !== "reply" || parsed.endpoint !== endpoint || parsed.nonce !== n)
117
- return;
118
- // Then the body: it must parse as an EndpointReply and ECHO our request id on this
119
- // nonce-scoped rail (§13.3) — the second half of the confused-deputy binding.
120
- let reply;
121
- try {
122
- reply = parseEndpointReply(JSON.parse(dec.decode(msg.data)));
123
- }
124
- catch {
125
- return;
126
- } // a malformed body on our nonce is not a usable answer; wait for a valid one
127
- if (reply.id !== requestId)
128
- return;
129
- resolve({ body: reply, responder: { instanceId: parsed.instanceId, epoch: parsed.epoch } });
130
- },
131
- });
132
- const request = enc.encode(JSON.stringify(env));
133
- nc.publish(subject, request);
134
- // The first publish may precede the responder's subscription during startup. Re-publish the
135
- // SAME read-only describe under the SAME request binding until one answer wins or the original
136
- // deadline expires; this neither extends the budget nor retries the command being resolved.
137
- // `publish` throws synchronously after close/drain. A timer throw escapes the caller's promise
138
- // and crashes the process, so make transport loss settle this describe instead.
139
- retryTimer = setInterval(() => {
140
- try {
141
- nc.publish(subject, request);
142
- }
143
- catch (e) {
144
- reject(new EpEnvelopeError("unavailable", `the describe retry for ${endpoint} could not publish: ${caughtText(e, "unknown publish failure")}`));
145
- }
146
- }, DESCRIBE_RETRY_MS);
147
- });
148
- const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(unanswered(deadlineMs)), deadlineMs); });
149
- const { body: reply, responder } = await Promise.race([got, timeout, denialWatch.denied, cancelled]);
150
- if (reply.ok !== true) {
151
- // A responder ANSWERED with a refusal: it is rethrown under the responder's own code (which
152
- // may be `unavailable`) and deliberately without the EP_UNANSWERED marker the deadline above
153
- // carries, so a consumer keyed on that marker never reads an answering responder as absent.
154
- const e = reply.error;
155
- throw new EpEnvelopeError(e?.code ?? "unavailable", `describe(${endpoint}) failed: ${e?.message ?? "unknown"}`);
32
+ export async function invokeRepairingSplit(nc, space, service, command, args, opts, reresolve = () => resolveService(nc, space, service.endpoint, service.caller, { deadlineMs: opts.deadlineMs ?? 10_000, ...(opts.signal ? { signal: opts.signal } : {}) })) {
33
+ let handle = service;
34
+ // A member that looked the target up and found no mapping says more about it than a bind refusal,
35
+ // which says nothing, so a call that runs out of re-issues surfaces the latest such refusal.
36
+ let unmapped;
37
+ for (let reissues = 0;; reissues += 1) {
38
+ const r = await invokeCommand(nc, space, handle, command, args, opts);
39
+ if (replyTargetUnmapped(r.reply.error))
40
+ unmapped = r;
41
+ else if (r.reply.ok !== false || !replyRefusedBeforeEffect(r.reply.error))
42
+ return r;
43
+ if (handle.pinnedInstanceId !== undefined)
44
+ return r;
45
+ if (reissues === BIND_SPLIT_REISSUES)
46
+ return unmapped ?? r;
47
+ try {
48
+ handle = await reresolve();
49
+ }
50
+ catch {
51
+ return unmapped ?? r;
156
52
  }
157
- return { answer: reply.data, responder };
158
- }
159
- finally {
160
- sub?.unsubscribe();
161
- if (timer !== undefined)
162
- clearTimeout(timer);
163
- if (retryTimer !== undefined)
164
- clearInterval(retryTimer);
165
- if (onAbort)
166
- opts.signal?.removeEventListener("abort", onAbort);
167
- // Release on EVERY exit, success included. See {@link openPublishDenialWatch}.
168
- denialWatch?.release();
169
53
  }
170
54
  }
171
55
  /** Fetch + verify ONE cluster document from the store at its closure digest (two-stage §13.7:
@@ -234,7 +118,8 @@ function extractSchemaRefs(bytes) {
234
118
  * so is this one. High enough that real surfaces overlap freely, low enough to stay a bound. */
235
119
  const RESOLVE_MAX_INFLIGHT_READS = 32;
236
120
  /** Run `work` over `items` with at most `limit` in flight, preserving RESULT ORDER (the resolved
237
- * surface must not depend on read timing). Rejects like `Promise.all`: the first failure wins. */
121
+ * surface must not depend on read timing). Rejects like `Promise.all`: the first failure wins,
122
+ * and no item starts after it. */
238
123
  async function pooled(items, limit, work) {
239
124
  const out = new Array(items.length);
240
125
  let next = 0;
@@ -243,7 +128,15 @@ async function pooled(items, limit, work) {
243
128
  const i = next++;
244
129
  if (i >= items.length)
245
130
  return;
246
- out[i] = await work(items[i]);
131
+ try {
132
+ out[i] = await work(items[i]);
133
+ }
134
+ catch (e) {
135
+ // This failure rejects the pool, so a later item would spend store reads on a result
136
+ // nobody reads. Items already in flight finish on their own.
137
+ next = items.length;
138
+ throw e;
139
+ }
247
140
  }
248
141
  };
249
142
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
@@ -311,447 +204,9 @@ export async function resolveService(nc, space, endpoint, caller, opts = {}) {
311
204
  commands.set(rc.command, rc);
312
205
  return { endpoint: answer.descriptor.endpoint, owner: answer.descriptor.owner, caller, responder, commands, ...(opts.instanceId !== undefined ? { pinnedInstanceId: opts.instanceId } : {}) };
313
206
  }
314
- /** How many times a call is re-issued after a `not-executed` bind refusal. Every
315
- * re-issue is a first attempt, so this is a loop guard and not a duplication guard. Each attempt
316
- * splits with probability (m-1)/m in a space of m managers, so seventeen attempts leave a
317
- * three-manager space about 1 in 1000 where the unrepaired call failed 2 in 3 (#398). An attempt
318
- * costs an answered describe and invoke, never an elapsed deadline. */
319
- export const BIND_SPLIT_REISSUES = 16;
320
- /**
321
- * INVOKE one named command on a resolved service: validate nothing here (the compiled input
322
- * contract in {@link epCall}'s request builder gates args before publish, and the responder's
323
- * digest-bound boundary re-validates), route on the `one` rail, return the attributed reply. A
324
- * command absent from the resolved surface is `not-found` (the caller cannot see it, or it does
325
- * not exist); a targeted command needs its `target`.
326
- *
327
- * Currency: `opts.currentEpoch` (e.g. the registry-read `serviceEpochReader`) when supplied;
328
- * otherwise the DESCRIBE-BOUND default - accept exactly the incarnation that answered this
329
- * service's resolve (its broker-authenticated `instanceId`+`epoch` off the describe reply
330
- * subject) and refuse `failed-precondition` when a DIFFERENT instance wins the `one` queue
331
- * (a superseded-or-split responder; re-resolve to adopt a legitimate successor). The bind needs
332
- * no registry read grant, and it is strictly stronger than no check: two live instances of a
333
- * single-instance endpoint can never both pass one resolved handle.
334
- */
335
- export async function invokeCommand(nc, space, service, command, args, opts) {
336
- const resolved = service.commands.get(command);
337
- if (resolved === undefined)
338
- throw new EpEnvelopeError("not-found", `command "${command}" is not in ${service.endpoint}'s visible surface; describe lists ${[...service.commands.keys()].sort().join(", ") || "(none)"}`);
339
- if (resolved.targeted && opts.target === undefined)
340
- throw new EpEnvelopeError("bad-request", `command "${command}" is targeted (modes: ${resolved.modes.join(", ")}); an invoke needs its target`);
341
- if (!resolved.targeted && opts.target !== undefined)
342
- throw new EpEnvelopeError("bad-request", `command "${command}" is untargeted; an invoke must not carry a target`);
343
- const caller = service.caller;
344
- // "No args" marshals to the CONTRACT's canonical empty form: absent args ride as null on the
345
- // wire, so when this command's input rejects null but accepts the empty object (e.g. an
346
- // all-optional `{type:"object"}` input like despawn's), send `{}` — that IS the caller's
347
- // intent in that contract's vocabulary (a targeted CLI stop has nothing left after the alias
348
- // becomes the target block). Contract-derived, never a guess: an input that requires fields
349
- // accepts neither form and still refuses loud at the pre-publish validation below.
350
- let sendArgs = args;
351
- if (sendArgs === undefined && !resolved.contract.input.validate(null) && resolved.contract.input.validate({}))
352
- sendArgs = {};
353
- const describeBound = (instanceId) => {
354
- if (instanceId !== service.responder.instanceId) {
355
- // The refusal is unchanged; what it SAYS is not. One message used to cover two situations
356
- // that call for opposite responses, and it described only the rarer one.
357
- //
358
- // PINNED: the caller named an instance and a different one answered. Genuinely wrong.
359
- //
360
- // UNPINNED: the caller addressed the CLASS. The describe and the invoke are two independent
361
- // trips through the same anycast queue, so in a multi-instance space they routinely land on
362
- // different instances and this fires on an ordinary, correct request - not on a supersession.
363
- // Calling that "superseded-or-split" sends the reader hunting a restart that never happened.
364
- // The old text's remedy is also not executable by the surfaces that print it most (`stop`,
365
- // `despawn` and `attach` have no adopt path and no pin), so it advised an action the caller
366
- // could not take. Say which case this is, and stop asserting a cause that is usually wrong.
367
- // The marker, not the prose, is what stops an automatic re-invoke: reaching here means a
368
- // responder ANSWERED (executed or refused; the reply does not say which), so a retry is a
369
- // second attempt that may duplicate an effect rather than a repair. Callers that recover
370
- // from `failed-precondition` by re-resolving must consult `respondedButUnbound`.
371
- // The remedy is stated in core vocabulary (address one instance), never as a CLI flag: core
372
- // cannot know whether its caller has one, and most `invokeService` callers (a connector's
373
- // tools, a manifest deploy) do not. The CLI names its own flag when it renders this.
374
- throw new EpEnvelopeError("failed-precondition", service.pinnedInstanceId !== undefined
375
- ? `the ${service.endpoint} instance ${instanceId} answered but this handle is PINNED to ${service.pinnedInstanceId}; a pinned call names its instance and never accepts another. ${instanceId} did receive and answer the request, so if "${command}" mutates, that effect may already have landed - verify before re-issuing (SPEC 13.2)`
376
- : `the ${service.endpoint} instance ${instanceId} won the class queue but this UNPINNED handle resolved against ${service.responder.instanceId}; the describe and the invoke are separate trips through the same queue, so in a multi-instance space this is an ordinary split and not necessarily a supersession - the handle cannot currently adopt a different winner. THIS SAYS NOTHING ABOUT WHETHER THE COMMAND RAN: ${instanceId} received the request and answered it, possibly after this error was raised. For a read that is harmless and re-issuing is safe; if "${command}" mutates, verify the outcome ('ps'/'inspect'/roster) before re-issuing, because a retry that assumes failure duplicates the effect. A call that addresses one instance does not split (SPEC 13.2)`, [{ kind: EP_UNBOUND_RESPONDER, endpoint: service.endpoint, command, answeredBy: instanceId, boundTo: service.responder.instanceId, pinned: service.pinnedInstanceId !== undefined }]);
377
- }
378
- return service.responder.epoch;
379
- };
380
- // `--on`: a PINNED service routes to its exact instance's `inst` rail (the same instance the
381
- // describe resolved to, at its resolved epoch), never the class `one` queue — so the command reaches
382
- // the addressed manager in a multi-manager space. Unpinned ⇒ class anycast `one` (unchanged). The
383
- // describeBound currency check still holds: an inst-routed reply carries that instance's id.
384
- const route = service.pinnedInstanceId !== undefined
385
- ? { mode: "inst", instanceId: service.pinnedInstanceId, epoch: service.responder.epoch }
386
- : { mode: "one" };
387
- // The bind travels WITH the request, so the responder can refuse before running the command
388
- // rather than leaving `describeBound` to report the split afterwards. It is sent exactly when
389
- // this handle's own resolve is the currency reference — that is the caller saying "this
390
- // incarnation or none", and it is the same population the check below already refuses. A caller
391
- // that supplied its own `currentEpoch` is asking a REGISTRY whether the answerer is current, and
392
- // deliberately accepts any current member; binding it would refuse calls that succeed today.
393
- const bind = opts.currentEpoch === undefined
394
- ? { instanceId: service.pinnedInstanceId ?? service.responder.instanceId, epoch: service.responder.epoch }
395
- : undefined;
396
- return epCall(nc, space, route, {
397
- endpoint: service.endpoint, command, contract: resolved.contract, caller,
398
- ...(sendArgs !== undefined ? { args: sendArgs } : {}),
399
- ...(opts.target ? { target: opts.target } : {}),
400
- ...(bind !== undefined ? { bind } : {}),
401
- ...(opts.id !== undefined ? { id: opts.id } : {}),
402
- }, {
403
- deadlineMs: opts.deadlineMs ?? 10_000,
404
- signal: opts.signal,
405
- currentEpoch: opts.currentEpoch ?? describeBound,
406
- // What the currency hook returns decides how a stale-epoch refusal is worded and marked: the
407
- // describe-bound default is this handle's own bind (a responder ahead of it is a successor and
408
- // the handle is the stale side); a caller-supplied hook is a registry read by epCall's contract.
409
- currencyReference: opts.currentEpoch ? "registry" : "bind",
410
- });
411
- }
412
- /** The refusal a goal-following call meets on a handle whose endpoint serves no `goal-result`: an
413
- * accepted goal could never be read back to its terminal (SPEC 13.6), so nothing is submitted.
414
- * `undefined` when the handle can follow. */
415
- export function goalFollowRefusal(service) {
416
- if (service.commands.has("goal-result"))
417
- return undefined;
418
- return {
419
- reply: { v: 1, id: randomUUID(), ok: false, error: {
420
- code: "failed-precondition", outcome: "not-executed",
421
- message: `endpoint "${service.endpoint}" does not support "goal-result"; upgrade manager to enable durable goal following (SPEC 13.6)`,
422
- } },
423
- responder: { endpoint: service.endpoint, instanceId: service.responder.instanceId, epoch: service.responder.epoch },
424
- };
425
- }
426
- /**
427
- * {@link invokeCommand} with a SPEC 13.2 bind refusal repaired rather than surfaced.
428
- *
429
- * An unpinned handle binds the instance that answered its describe, and the invoke is a second,
430
- * independent trip through the same class queue, so in a multi-instance space another member
431
- * routinely receives it and refuses before dispatching. That refusal states the command did not
432
- * run ({@link replyRefusedBeforeEffect}), so re-resolving and re-issuing is a first attempt and is
433
- * safe for any command, mutations included. A targeted call that reaches a member holding no
434
- * mapping for its target ({@link replyTargetUnmapped}) is re-issued the same way, because with an
435
- * instance-local resolver only the member hosting the target can serve it. The re-issue repeats
436
- * until a member serves the call or {@link BIND_SPLIT_REISSUES} runs out, and then the last refusal
437
- * surfaces unchanged, or the last no-mapping refusal when there was one.
438
- *
439
- * `reresolve` defaults to a fresh class resolve as the handle's caller; a caller that memoizes its
440
- * handle passes one that drops the memo first, so later calls do not start from the refused bind.
441
- * When it throws, the refusal surfaces, because it states that nothing ran, which the resolve
442
- * failure raised in its place would lose.
443
- *
444
- * A pinned handle is never repaired: it names its instance, so a refusal from it is that instance
445
- * answering about itself, and re-resolving on the class rail would reach another one.
446
- */
447
- export async function invokeRepairingSplit(nc, space, service, command, args, opts, reresolve = () => resolveService(nc, space, service.endpoint, service.caller, { deadlineMs: opts.deadlineMs ?? 10_000, ...(opts.signal ? { signal: opts.signal } : {}) })) {
448
- let handle = service;
449
- // A member that looked the target up and found no mapping says more about it than a bind refusal,
450
- // which says nothing, so a call that runs out of re-issues surfaces the latest such refusal.
451
- let unmapped;
452
- for (let reissues = 0;; reissues += 1) {
453
- const r = await invokeCommand(nc, space, handle, command, args, opts);
454
- if (replyTargetUnmapped(r.reply.error))
455
- unmapped = r;
456
- else if (r.reply.ok !== false || !replyRefusedBeforeEffect(r.reply.error))
457
- return r;
458
- if (handle.pinnedInstanceId !== undefined)
459
- return r;
460
- if (reissues === BIND_SPLIT_REISSUES)
461
- return unmapped ?? r;
462
- try {
463
- handle = await reresolve();
464
- }
465
- catch {
466
- return unmapped ?? r;
467
- }
468
- }
469
- }
470
- /** Subscribe before a single submission, then observe its accepted goal through live progress and
471
- * mediated canonical reads. Local failures before an attributed reply throw; they never invent a
472
- * responder. Once accepted, observation failures retain that responder and never authorize retry.
473
- * A terminal other than `succeeded` keeps the acceptance as its data, so the caller still holds the
474
- * allocated identity of a goal that settled `uncertain` and may yet converge. */
475
- export async function submitAndFollowGoal(nc, space, endpoint, caller, deadlineMs, submit, opts) {
476
- if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0)
477
- throw new EpEnvelopeError("bad-request", "goal following requires a positive integer deadline", undefined, "not-executed");
478
- const acceptingCaller = { ...caller };
479
- const terminals = new Map();
480
- const progressSubject = epGoalProgressGrantRow(space, endpoint, acceptingCaller);
481
- const work = new AbortController();
482
- const timers = new Set();
483
- let activeNc = nc;
484
- let activeSub;
485
- let generation = 0;
486
- let unbindReconnect;
487
- let completed = false;
488
- let stopped = opts?.signal?.aborted === true;
489
- let submitted = false;
490
- let subError;
491
- let observationError;
492
- let wake;
493
- let abortPhase;
494
- let needsReconcile = false;
495
- let reading = false;
496
- let progressReady = false;
497
- const aborted = new Promise((resolve) => { abortPhase = resolve; });
498
- const onAbort = () => {
499
- stopped = true;
500
- work.abort();
501
- abortPhase?.();
502
- wake?.();
503
- };
504
- const phaseError = (code) => new EpEnvelopeError(code, submitted
505
- ? "the goal submission was interrupted while in flight; its outcome is unknown. It may have been accepted or executed; do NOT retry without inspecting 'ps'/'inspect' first (SPEC 13.6)"
506
- : "goal observation stopped or exceeded its deadline before submission; the manager request WAS NOT RUN", undefined, submitted ? "unknown" : "not-executed");
507
- let deadline = Date.now() + deadlineMs;
508
- const bounded = async (operation, until) => {
509
- if (stopped || completed)
510
- throw phaseError("unavailable");
511
- const remaining = until - Date.now();
512
- if (remaining <= 0)
513
- throw phaseError("deadline-exceeded");
514
- let timer;
515
- try {
516
- return await Promise.race([
517
- operation(),
518
- aborted.then(() => { throw phaseError("unavailable"); }),
519
- new Promise((_, reject) => {
520
- timer = setTimeout(() => reject(phaseError("deadline-exceeded")), Math.min(remaining, 2_147_483_647));
521
- timers.add(timer);
522
- }),
523
- ]);
524
- }
525
- finally {
526
- if (timer !== undefined) {
527
- clearTimeout(timer);
528
- timers.delete(timer);
529
- }
530
- }
531
- };
532
- const subscribe = (next) => {
533
- if (completed || stopped)
534
- return;
535
- const mine = ++generation;
536
- activeSub?.unsubscribe();
537
- activeNc = next;
538
- progressReady = false;
539
- subError = undefined;
540
- activeSub = next.subscribe(progressSubject, {
541
- callback: (err, msg) => {
542
- if (completed || stopped || mine !== generation)
543
- return;
544
- if (err) {
545
- subError ??= err;
546
- wake?.();
547
- return;
548
- }
549
- let ev;
550
- try {
551
- ev = JSON.parse(dec.decode(msg.data));
552
- }
553
- catch {
554
- return;
555
- }
556
- if (ev?.phase === "terminal" && typeof ev.goalId === "string") {
557
- terminals.set(ev.goalId, { state: String(ev.state), data: ev.data });
558
- wake?.();
559
- }
560
- },
561
- });
562
- };
563
- const replaceConnection = (next) => {
564
- if (completed || stopped)
565
- return;
566
- try {
567
- subscribe(next);
568
- }
569
- catch (err) {
570
- subError = err instanceof Error ? err : new Error(String(err));
571
- wake?.();
572
- return;
573
- }
574
- const mine = generation;
575
- void bounded(() => next.flush(), deadline).then(() => {
576
- if (completed || stopped || mine !== generation)
577
- return;
578
- progressReady = true;
579
- needsReconcile = true;
580
- wake?.();
581
- }, (err) => {
582
- if (completed || stopped || mine !== generation)
583
- return;
584
- subError = err instanceof Error ? err : new Error(String(err));
585
- wake?.();
586
- });
587
- };
588
- try {
589
- opts?.signal?.addEventListener("abort", onAbort, { once: true });
590
- if (stopped)
591
- throw phaseError("unavailable");
592
- if (opts?.prepare) {
593
- // The deadline first aborts the prepare's signal, so a describe the prepare started after other
594
- // steps reports that it drew no reply. A prepare that ignores its signal is cut off a turn later.
595
- let timer;
596
- try {
597
- await Promise.race([opts.prepare(work.signal, deadline), aborted, new Promise((resolve) => {
598
- timer = setTimeout(() => {
599
- work.abort(new DOMException("the goal follow's deadline passed", "TimeoutError"));
600
- setImmediate(resolve);
601
- }, Math.min(deadline - Date.now(), 2_147_483_647));
602
- })]);
603
- work.signal.throwIfAborted();
604
- }
605
- catch (err) {
606
- throw stopped ? phaseError("unavailable") : work.signal.aborted && !unansweredRequest(err) ? phaseError("deadline-exceeded") : err;
607
- }
608
- finally {
609
- clearTimeout(timer);
610
- }
611
- }
612
- subscribe(opts?.currentNc?.() ?? nc);
613
- unbindReconnect = opts?.onReconnect?.(replaceConnection);
614
- // A borrowed submit can publish on another connection. Confirm broker interest first,
615
- // including a replacement that arrived while the first connection's flush was pending.
616
- for (;;) {
617
- const preparing = activeNc;
618
- try {
619
- await bounded(() => preparing.flush(), deadline);
620
- }
621
- catch (err) {
622
- if (!stopped && preparing !== activeNc)
623
- continue;
624
- throw err;
625
- }
626
- if (stopped)
627
- throw phaseError("unavailable");
628
- const current = opts?.currentNc?.();
629
- if (current && current !== activeNc)
630
- subscribe(current);
631
- if (preparing === activeNc) {
632
- progressReady = true;
633
- break;
634
- }
635
- }
636
- if (stopped)
637
- throw phaseError("unavailable");
638
- submitted = true;
639
- let attributed;
640
- try {
641
- attributed = await bounded(() => submit(work.signal), deadline);
642
- }
643
- catch (err) {
644
- if (stopped)
645
- throw phaseError("unavailable");
646
- if (err instanceof EpEnvelopeError && err.outcome === "not-executed")
647
- throw err;
648
- throw new EpEnvelopeError(err instanceof EpEnvelopeError ? err.code : "unavailable", `${err instanceof Error ? err.message : String(err)}; the submission outcome is unknown. It may have been accepted or executed; do NOT retry without inspecting 'ps'/'inspect' first (SPEC 13.6)`, undefined, "unknown");
649
- }
650
- if (attributed.reply.ok !== true)
651
- return attributed;
652
- const acceptance = attributed.reply.data;
653
- const goalId = acceptance?.goalId;
654
- if (typeof goalId !== "string")
655
- return attributed;
656
- const acceptedFingerprint = typeof acceptance?.fingerprint === "string" ? acceptance.fingerprint : undefined;
657
- const readinessDeadlineMs = acceptance?.readinessDeadlineMs;
658
- const followDeadlineMs = typeof readinessDeadlineMs === "number"
659
- && Number.isSafeInteger(readinessDeadlineMs) && readinessDeadlineMs >= 0
660
- ? Math.max(deadlineMs, readinessDeadlineMs + GOAL_FOLLOW_MARGIN_MS) : deadlineMs;
661
- deadline = Date.now() + followDeadlineMs;
662
- const reconcileAt = deadline - Math.min(deadlineMs, followDeadlineMs / 2);
663
- let finalReadRequested = false;
664
- const reconcile = async () => {
665
- if (!opts?.reconcile || completed || stopped || reading || !progressReady)
666
- return;
667
- reading = true;
668
- needsReconcile = false;
669
- try {
670
- const result = await opts.reconcile(goalId, attributed, {
671
- caller: { ...acceptingCaller }, signal: work.signal, deadlineMs: Math.max(1, deadline - Date.now()),
672
- });
673
- if (completed || stopped || Date.now() >= deadline)
674
- return;
675
- if (!result || typeof result !== "object" || result.goalId !== goalId)
676
- throw new EpEnvelopeError("internal", "goal-result reply is malformed or names a different goal; it cannot authorize this outcome (SPEC 13.6)");
677
- if (result.result !== undefined) {
678
- const ref = { endpoint, caller: acceptingCaller, goalId };
679
- const fact = parseGoalResultFact(result.result, "goal-result reply", ref);
680
- if (acceptedFingerprint !== undefined && fact.fingerprint !== acceptedFingerprint)
681
- throw new EpEnvelopeError("internal", "goal result fingerprint does not match the accepted goal (SPEC 13.6)");
682
- terminals.set(goalId, { state: fact.state, data: fact.data });
683
- }
684
- }
685
- catch (err) {
686
- if (!completed && !stopped && Date.now() < deadline)
687
- observationError = err instanceof Error ? err : new Error(String(err));
688
- }
689
- finally {
690
- reading = false;
691
- if (!completed && !stopped)
692
- wake?.();
693
- }
694
- };
695
- const current = opts?.currentNc?.();
696
- if (current && current !== activeNc && !stopped)
697
- replaceConnection(current);
698
- needsReconcile = true; // Also covers a terminal lost before the acceptance arrived.
699
- while (!terminals.has(goalId) && !stopped && !observationError && !subError && Date.now() < deadline) {
700
- if (!finalReadRequested && Date.now() >= reconcileAt) {
701
- finalReadRequested = true;
702
- needsReconcile = true;
703
- }
704
- if (needsReconcile && !reading)
705
- void reconcile();
706
- if (terminals.has(goalId) || stopped || observationError || subError)
707
- break;
708
- await new Promise((resolve) => {
709
- const until = finalReadRequested ? deadline : reconcileAt;
710
- const timer = setTimeout(() => { timers.delete(timer); wake = undefined; resolve(); }, Math.max(0, Math.min(until - Date.now(), 2_147_483_647)));
711
- timers.add(timer);
712
- wake = () => { clearTimeout(timer); timers.delete(timer); wake = undefined; resolve(); };
713
- });
714
- }
715
- const terminal = terminals.get(goalId);
716
- const observationFailure = (code, reason) => ({
717
- ...attributed,
718
- reply: { ...attributed.reply, ok: false, data: undefined, error: {
719
- code, outcome: "unknown",
720
- message: `the goal "${goalId}" was accepted, but ${reason}. THE GOAL IS UNAFFECTED - it may already have succeeded; this is not evidence the goal failed. Do NOT retry: another submission could duplicate the effect. Inspect 'ps'/'inspect' first (SPEC 13.6)`,
721
- } },
722
- });
723
- if (stopped)
724
- return observationFailure("unavailable", "the endpoint was stopped while awaiting its outcome");
725
- if (observationError)
726
- return observationFailure("unavailable", `following its outcome failed: ${observationError.message}`);
727
- if (!terminal && subError)
728
- return subError instanceof PermissionViolationError
729
- ? observationFailure("permission-denied", `this caller is NOT PERMITTED to hear its outcome: the broker refused the per-goal progress subscription \"${progressSubject}\" (${subError.message}); grant this caller the per-goal progress read row`)
730
- : observationFailure("unavailable", `the per-goal progress subscription \"${progressSubject}\" FAILED (${subError.name}: ${subError.message}); this is NOT a grant refusal, so changing ACLs is the wrong remedy`);
731
- if (!terminal)
732
- return observationFailure("deadline-exceeded", `no terminal arrived within ${followDeadlineMs}ms; this is a timeout on the WAIT`);
733
- if (terminal.state === "succeeded")
734
- return { ...attributed, reply: { ...attributed.reply, ok: true, data: terminal.data, error: undefined } };
735
- const d = (terminal.data ?? {});
736
- const raw = typeof d.error === "string" ? d.error : typeof d.reason === "string" ? d.reason : `the goal settled ${terminal.state}`;
737
- const details = Array.isArray(d.details) ? d.details : undefined;
738
- const message = renderLifecycleBlocked(raw, details ? { details } : undefined);
739
- const fromAccept = lifecycleBlockedFrom(attributed.reply.error);
740
- const merged = details ?? (fromAccept ? [fromAccept] : undefined);
741
- return { ...attributed, reply: { ...attributed.reply, ok: false, error: { code: terminal.state, message, ...(merged ? { details: merged } : {}) } } };
742
- }
743
- finally {
744
- completed = true;
745
- opts?.signal?.removeEventListener("abort", onAbort);
746
- unbindReconnect?.();
747
- activeSub?.unsubscribe();
748
- for (const timer of timers)
749
- clearTimeout(timer);
750
- timers.clear();
751
- wake = undefined;
752
- work.abort();
753
- abortPhase?.();
754
- }
207
+ /** {@link submitAndFollowGoalWith} with the root's synchronous fact verifier. */
208
+ export function submitAndFollowGoal(nc, space, endpoint, caller, deadlineMs, submit, opts) {
209
+ return submitAndFollowGoalWith(parseGoalResultFact, nc, space, endpoint, caller, deadlineMs, submit, opts);
755
210
  }
756
211
  /**
757
212
  * SCATTER one untargeted command to the LIVE class (§13.5): resolve the command's contract off the