@zieglar/pi-chat-exchange 0.0.0-stage → 0.2.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 +70 -0
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/dist/bin/mcp-stdio.js +196 -0
- package/dist/control/debug-log.js +67 -0
- package/dist/control/mcp/debug-transport.js +48 -0
- package/dist/control/mcp/messaging-tools.js +278 -0
- package/dist/control/mcp/ping.js +43 -0
- package/dist/control/mcp/server.js +153 -0
- package/dist/core/authorization.js +32 -0
- package/dist/core/reference.js +32 -0
- package/dist/core/rules.js +468 -0
- package/dist/endpoints/pi/contract.js +2 -0
- package/dist/endpoints/pi/session-host.js +39 -0
- package/dist/host/bound-endpoint.js +176 -0
- package/dist/host/endpoint-host.js +839 -0
- package/dist/host/mcp-binding-config.js +209 -0
- package/dist/host/ownership-recovery.js +257 -0
- package/dist/index.js +11 -0
- package/dist/protocol/capabilities.js +46 -0
- package/dist/protocol/endpoint-capabilities.js +15 -0
- package/dist/protocol/entities.js +185 -0
- package/dist/protocol/formats.js +13 -0
- package/dist/protocol/idempotency.js +93 -0
- package/dist/protocol/identity.js +130 -0
- package/dist/protocol/index.js +8 -0
- package/dist/protocol/lifecycle.js +125 -0
- package/dist/protocol/outcomes.js +55 -0
- package/dist/runtime/desktop-manifest.js +65 -0
- package/dist/runtime/diagnostics.js +34 -0
- package/dist/runtime/executables.js +222 -0
- package/dist/storage/capability.js +221 -0
- package/dist/storage/connection.js +94 -0
- package/dist/storage/creation-request.js +176 -0
- package/dist/storage/creation.js +553 -0
- package/dist/storage/current-schema.js +397 -0
- package/dist/storage/delivery-ack.js +177 -0
- package/dist/storage/errors.js +9 -0
- package/dist/storage/filesystem.js +116 -0
- package/dist/storage/inbox-discovery.js +135 -0
- package/dist/storage/messaging.js +156 -0
- package/dist/storage/migration.js +258 -0
- package/dist/storage/ownership.js +789 -0
- package/dist/storage/participant-identity.js +127 -0
- package/dist/storage/participant.js +700 -0
- package/dist/storage/recovery.js +99 -0
- package/dist/storage/runtime.js +113 -0
- package/dist/storage/schema.js +556 -0
- package/dist/storage/session-state.js +75 -0
- package/dist/storage/store.js +450 -0
- package/dist/storage/transaction.js +199 -0
- package/dist/storage/trust-root.js +164 -0
- package/dist/tunnel/background-manager.js +1727 -0
- package/dist/tunnel/codes.js +79 -0
- package/dist/tunnel/health.js +75 -0
- package/dist/tunnel/keychain.js +526 -0
- package/dist/tunnel/managed-binding.js +454 -0
- package/dist/tunnel/manager-panel.js +249 -0
- package/dist/tunnel/manager-ui.js +341 -0
- package/dist/tunnel/manager.js +372 -0
- package/dist/tunnel/messaging-activation-ui.js +85 -0
- package/dist/tunnel/messaging-config.js +246 -0
- package/dist/tunnel/multi-session-admission.js +31 -0
- package/dist/tunnel/multi-session-policy.js +314 -0
- package/dist/tunnel/offline-client.js +104 -0
- package/dist/tunnel/profile.js +319 -0
- package/dist/tunnel/redact.js +9 -0
- package/dist/tunnel/secure-input.js +318 -0
- package/dist/tunnel/session-process-mutex.js +338 -0
- package/dist/tunnel/setup-ui.js +443 -0
- package/dist/tunnel/setup.js +734 -0
- package/dist/tunnel/state.js +127 -0
- package/docs/architecture.md +170 -0
- package/docs/chatgpt-installation.md +149 -0
- package/docs/delivery-ack-contract.md +86 -0
- package/docs/independent-install.md +156 -0
- package/docs/multi-session-usage.md +77 -0
- package/docs/ownership-contract.md +429 -0
- package/docs/protocol-v2.md +269 -0
- package/docs/releases/0.2.0.md +59 -0
- package/docs/storage-contract.md +1241 -0
- package/docs/storage-security.md +138 -0
- package/docs/tunnel-manager-background.md +169 -0
- package/docs/tunnel-setup.md +214 -0
- package/native/tunnel-keychain.swift +432 -0
- package/package.json +77 -4
- package/pi-extension/README.md +125 -0
- package/pi-extension/allowlist.example.json +11 -0
- package/pi-extension/index.ts +2312 -0
- package/pi-extension/multi-session-commands.ts +93 -0
- package/pi-extension/multi-session-runtime.ts +75 -0
- package/pi-extension/tui-keys.ts +40 -0
- package/scripts/reconcile-session-process-mutex.mjs +262 -0
- package/scripts/recover-stale-ownership.mjs +91 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core exchange rules (frozen v2): pure invariant helpers over the protocol
|
|
3
|
+
* contracts. No I/O, no storage, no timers, no adapter/MCP/Pi/product
|
|
4
|
+
* semantics — every function takes its evidence as data and returns new
|
|
5
|
+
* values or throws. The durable host (out of scope for this phase) must call
|
|
6
|
+
* these rules inside its transactions; they are the single legality authority.
|
|
7
|
+
*/
|
|
8
|
+
import { Value } from "@sinclair/typebox/value";
|
|
9
|
+
import { canonicalDigest } from "../protocol/idempotency.js";
|
|
10
|
+
import { assertAuthorized } from "./authorization.js";
|
|
11
|
+
import { ExchangeSchema, MessageSchema, ExchangeDispositionSchema, ResultSchema, RuntimeEvidenceSchema, DeliveryAttemptSchema, LeaseSchema, ReconciliationEvidenceSchema, } from "../protocol/entities.js";
|
|
12
|
+
import { FINISHED_MESSAGE_STATES, TRANSITION_REQUIREMENTS, isLegalTransition, } from "../protocol/lifecycle.js";
|
|
13
|
+
export class IllegalTransitionError extends Error {
|
|
14
|
+
code = "ILLEGAL_TRANSITION";
|
|
15
|
+
from;
|
|
16
|
+
to;
|
|
17
|
+
constructor(from, to) {
|
|
18
|
+
super(`illegal message state transition: ${from} -> ${to}`);
|
|
19
|
+
this.name = "IllegalTransitionError";
|
|
20
|
+
this.from = from;
|
|
21
|
+
this.to = to;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The move exists in the state machine but the presented evidence fails. */
|
|
25
|
+
export class TransitionEvidenceError extends Error {
|
|
26
|
+
code = "TRANSITION_EVIDENCE_REJECTED";
|
|
27
|
+
from;
|
|
28
|
+
to;
|
|
29
|
+
reason;
|
|
30
|
+
constructor(from, to, reason) {
|
|
31
|
+
super(`transition evidence rejected (${from} -> ${to}): ${reason}`);
|
|
32
|
+
this.name = "TransitionEvidenceError";
|
|
33
|
+
this.from = from;
|
|
34
|
+
this.to = to;
|
|
35
|
+
this.reason = reason;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export class CloseRejectedError extends Error {
|
|
39
|
+
code = "EXCHANGE_CLOSE_REJECTED";
|
|
40
|
+
reason;
|
|
41
|
+
constructor(reason) {
|
|
42
|
+
super(`exchange close rejected: ${reason}`);
|
|
43
|
+
this.name = "CloseRejectedError";
|
|
44
|
+
this.reason = reason;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class ResultConflictError extends Error {
|
|
48
|
+
code = "RESULT_CONFLICT";
|
|
49
|
+
resultId;
|
|
50
|
+
constructor(resultId) {
|
|
51
|
+
super(`result already published with different content: ${resultId}`);
|
|
52
|
+
this.name = "ResultConflictError";
|
|
53
|
+
this.resultId = resultId;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export class InvalidResultError extends Error {
|
|
57
|
+
code = "INVALID_RESULT";
|
|
58
|
+
constructor(detail) {
|
|
59
|
+
super(`result violates the frozen schema: ${detail}`);
|
|
60
|
+
this.name = "InvalidResultError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate one message state transition against correlated evidence.
|
|
65
|
+
* Returns `to` when legal; throws {@link IllegalTransitionError} for moves
|
|
66
|
+
* the state machine does not license, or {@link TransitionEvidenceError}
|
|
67
|
+
* when authority, lease fencing, attempt correlation, or evidence fails.
|
|
68
|
+
*/
|
|
69
|
+
export function assertMessageTransition(message, to, guard) {
|
|
70
|
+
assertNotArchived(message.exchangeId, guard.disposition);
|
|
71
|
+
const requirement = TRANSITION_REQUIREMENTS[`${message.state}>${to}`];
|
|
72
|
+
if (requirement === undefined || !isLegalTransition(message.state, to)) {
|
|
73
|
+
throw new IllegalTransitionError(message.state, to);
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isFinite(Date.parse(guard.now))) {
|
|
76
|
+
throw new TransitionEvidenceError(message.state, to, "invalid observation time");
|
|
77
|
+
}
|
|
78
|
+
assertTransitionAuthorization(requirement, guard, message, to);
|
|
79
|
+
if (message.state === "QUEUED" && to === "CANCELLED" && guard.attempt !== undefined && (!Value.Check(DeliveryAttemptSchema, guard.attempt) ||
|
|
80
|
+
guard.attempt.messageId !== message.messageId || !deepEqual(guard.attempt.endpoint, message.to) ||
|
|
81
|
+
guard.attempt.dispatch !== "not_attempted" || guard.attempt.transport !== "pending")) {
|
|
82
|
+
throw new TransitionEvidenceError(message.state, to, "queued cancellation contradicts presented dispatch evidence");
|
|
83
|
+
}
|
|
84
|
+
const attempt = assertAttemptCorrelation(requirement, message, to, guard);
|
|
85
|
+
if (attempt !== undefined) {
|
|
86
|
+
assertLeaseEvidence(requirement, attempt, guard, message.state, to);
|
|
87
|
+
assertEvidence(requirement, attempt, guard, message.state, to);
|
|
88
|
+
}
|
|
89
|
+
return to;
|
|
90
|
+
}
|
|
91
|
+
function assertTransitionAuthorization(requirement, guard, message, to) {
|
|
92
|
+
const actions = [...requirement.actions];
|
|
93
|
+
if (requirement.evidence === "cancel_confirmed" && guard.attempt?.dispatch === "started") {
|
|
94
|
+
actions.push("settlement_report");
|
|
95
|
+
}
|
|
96
|
+
for (const action of actions) {
|
|
97
|
+
let scope;
|
|
98
|
+
if (action === "exchange_send") {
|
|
99
|
+
scope = { kind: "exchange", exchangeId: message.exchangeId };
|
|
100
|
+
}
|
|
101
|
+
else if (action === "exchange_cancel") {
|
|
102
|
+
scope = { kind: "message", exchangeId: message.exchangeId, messageId: message.messageId };
|
|
103
|
+
}
|
|
104
|
+
else if (action === "message_claim") {
|
|
105
|
+
scope = { kind: "destination", exchangeId: message.exchangeId, messageId: message.messageId, endpoint: message.to };
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
if (guard.attempt === undefined || !Value.Check(DeliveryAttemptSchema, guard.attempt))
|
|
109
|
+
throw new TransitionEvidenceError(message.state, to, "a valid correlated delivery attempt is required");
|
|
110
|
+
scope = { kind: "attempt", exchangeId: message.exchangeId, messageId: message.messageId, attemptId: guard.attempt.attemptId, endpoint: message.to };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
assertAuthorized(guard.authorization, action, scope);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new TransitionEvidenceError(message.state, to, `scoped authorization required: ${action}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function assertAttemptCorrelation(requirement, message, to, guard) {
|
|
121
|
+
if (requirement.lease === "none")
|
|
122
|
+
return undefined;
|
|
123
|
+
const { attempt } = guard;
|
|
124
|
+
if (attempt === undefined) {
|
|
125
|
+
throw new TransitionEvidenceError(message.state, to, "a correlated delivery attempt is required");
|
|
126
|
+
}
|
|
127
|
+
if (!Value.Check(DeliveryAttemptSchema, attempt)) {
|
|
128
|
+
throw new TransitionEvidenceError(message.state, to, "invalid attempt record");
|
|
129
|
+
}
|
|
130
|
+
if (guard.reconciliation !== undefined && !Value.Check(ReconciliationEvidenceSchema, guard.reconciliation)) {
|
|
131
|
+
throw new TransitionEvidenceError(message.state, to, "invalid reconciliation record");
|
|
132
|
+
}
|
|
133
|
+
if (attempt.messageId !== message.messageId) {
|
|
134
|
+
throw new TransitionEvidenceError(message.state, to, `attempt ${attempt.attemptId} belongs to message ${attempt.messageId}, not ${message.messageId}`);
|
|
135
|
+
}
|
|
136
|
+
if (!deepEqual(attempt.endpoint, message.to)) {
|
|
137
|
+
throw new TransitionEvidenceError(message.state, to, `attempt ${attempt.attemptId} cites a different destination endpoint than message ${message.messageId}`);
|
|
138
|
+
}
|
|
139
|
+
if (guard.runtime !== undefined && (!Value.Check(RuntimeEvidenceSchema, guard.runtime) ||
|
|
140
|
+
guard.runtime.exchangeId !== message.exchangeId ||
|
|
141
|
+
guard.runtime.messageId !== message.messageId ||
|
|
142
|
+
guard.runtime.attemptId !== attempt.attemptId ||
|
|
143
|
+
!deepEqual(guard.runtime.endpoint, message.to) ||
|
|
144
|
+
Date.parse(guard.runtime.observedAt) > Date.parse(guard.now))) {
|
|
145
|
+
throw new TransitionEvidenceError(message.state, to, "uncorrelated or invalid runtime evidence");
|
|
146
|
+
}
|
|
147
|
+
if (guard.result !== undefined && (guard.result.exchangeId !== message.exchangeId ||
|
|
148
|
+
guard.result.messageId !== message.messageId ||
|
|
149
|
+
guard.result.attemptId !== attempt.attemptId ||
|
|
150
|
+
!deepEqual(guard.result.source, message.to))) {
|
|
151
|
+
throw new TransitionEvidenceError(message.state, to, "uncorrelated result evidence");
|
|
152
|
+
}
|
|
153
|
+
return attempt;
|
|
154
|
+
}
|
|
155
|
+
function assertLeaseEvidence(requirement, attempt, guard, from, to) {
|
|
156
|
+
if (!Value.Check(LeaseSchema, guard.lease)) {
|
|
157
|
+
throw new TransitionEvidenceError(from, to, "invalid guard lease");
|
|
158
|
+
}
|
|
159
|
+
if (requirement.lease === "current" || requirement.evidence === "not_attempted") {
|
|
160
|
+
if (guard.currentLease === undefined || !Value.Check(LeaseSchema, guard.currentLease) || !deepEqual(guard.currentLease, attempt.lease)) {
|
|
161
|
+
throw new TransitionEvidenceError(from, to, "attempt no longer holds the authoritative fenced claim");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!deepEqual(guard.lease, attempt.lease)) {
|
|
165
|
+
throw new TransitionEvidenceError(from, to, "guard lease does not match the lease recorded on the attempt");
|
|
166
|
+
}
|
|
167
|
+
if (requirement.lease === "current" && Date.parse(guard.now) >= Date.parse(attempt.lease.expiresAt)) {
|
|
168
|
+
throw new TransitionEvidenceError(from, to, "lease is expired for this mutation");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function assertEvidence(requirement, attempt, guard, from, to) {
|
|
172
|
+
const reject = (reason) => {
|
|
173
|
+
throw new TransitionEvidenceError(from, to, reason);
|
|
174
|
+
};
|
|
175
|
+
switch (requirement.evidence) {
|
|
176
|
+
case "none":
|
|
177
|
+
return;
|
|
178
|
+
case "not_attempted":
|
|
179
|
+
if (attempt.dispatch !== "not_attempted" || attempt.transport !== "pending") {
|
|
180
|
+
reject(`safe requeue/release requires a pending not-attempted record, found (${attempt.dispatch}, ${attempt.transport})`);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
case "dispatch_started":
|
|
184
|
+
if (attempt.dispatch !== "started") {
|
|
185
|
+
reject(`entering DISPATCHING requires the durable dispatch-start marker, found "${attempt.dispatch}"`);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
case "transport_delivered":
|
|
189
|
+
if (attempt.dispatch !== "started" || attempt.transport !== "delivered" || guard.runtime?.outcome !== "accepted") {
|
|
190
|
+
reject("acceptance requires both transport delivery and correlated runtime acceptance evidence");
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
case "transport_ambiguous":
|
|
194
|
+
if (attempt.dispatch !== "started" || attempt.transport !== "ambiguous") {
|
|
195
|
+
reject(`quarantine requires a started attempt with transport "ambiguous", found (${attempt.dispatch}, ${attempt.transport})`);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
case "transport_failed":
|
|
199
|
+
if (attempt.dispatch !== "started" || attempt.transport !== "failed") {
|
|
200
|
+
reject(`definitive failure requires a started attempt with transport "failed", found (${attempt.dispatch}, ${attempt.transport})`);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
case "runtime_progress":
|
|
204
|
+
if (attempt.dispatch !== "started" || attempt.transport !== "delivered" || guard.runtime === undefined) {
|
|
205
|
+
reject("runtime progress requires a delivered attempt and correlated runtime evidence");
|
|
206
|
+
}
|
|
207
|
+
if (to === "RUNNING" ? guard.runtime?.outcome !== "running" : !isSettlement(guard.runtime?.outcome)) {
|
|
208
|
+
reject("runtime evidence does not prove the requested state");
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
case "result_published":
|
|
212
|
+
if (attempt.dispatch !== "started" || attempt.transport !== "delivered" || guard.result === undefined) {
|
|
213
|
+
reject("availability requires a delivered attempt and correlated immutable published result");
|
|
214
|
+
}
|
|
215
|
+
if (guard.result !== undefined)
|
|
216
|
+
assertResultShape(guard.result);
|
|
217
|
+
return;
|
|
218
|
+
case "cancel_confirmed":
|
|
219
|
+
if (attempt.dispatch === "not_attempted" && attempt.transport === "pending")
|
|
220
|
+
return;
|
|
221
|
+
if (attempt.dispatch !== "started" || attempt.transport !== "delivered" || guard.runtime?.outcome !== "cancelled") {
|
|
222
|
+
reject("cancellation requires no-attempt proof or correlated runtime cancellation confirmation");
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
case "reconciled_delivered":
|
|
226
|
+
if (guard.reconciliation === undefined) {
|
|
227
|
+
reject("late correlated acceptance requires explicit reconciliation evidence, not lease reuse");
|
|
228
|
+
}
|
|
229
|
+
if (attempt.dispatch !== "started" || attempt.transport !== "delivered" || guard.runtime?.outcome !== "accepted") {
|
|
230
|
+
reject("reconciled acceptance requires transport delivery and correlated runtime acceptance");
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
case "reconciled_not_executed":
|
|
234
|
+
if (guard.reconciliation === undefined) {
|
|
235
|
+
reject("confirming non-execution requires explicit reconciliation evidence, not lease reuse");
|
|
236
|
+
}
|
|
237
|
+
if (attempt.transport !== "failed") {
|
|
238
|
+
reject(`reconciled non-execution requires transport "failed", found "${attempt.transport}"`);
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
export class ExchangeDispositionError extends Error {
|
|
244
|
+
code = "EXCHANGE_DISPOSITION_REJECTED";
|
|
245
|
+
constructor(detail) {
|
|
246
|
+
super(`administrative disposition rejected: ${detail}`);
|
|
247
|
+
this.name = "ExchangeDispositionError";
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Admission precondition for the future host's every protocol mutation. */
|
|
251
|
+
export function assertExchangeActive(exchange, disposition) {
|
|
252
|
+
assertNotArchived(exchange.exchangeId, disposition);
|
|
253
|
+
if (exchange.state !== "OPEN")
|
|
254
|
+
throw new ExchangeDispositionError("exchange is closed");
|
|
255
|
+
}
|
|
256
|
+
function assertNotArchived(exchangeId, disposition) {
|
|
257
|
+
if (disposition === undefined)
|
|
258
|
+
return;
|
|
259
|
+
assertDispositionShape(disposition);
|
|
260
|
+
if (disposition.exchangeId !== exchangeId)
|
|
261
|
+
throw new ExchangeDispositionError("foreign disposition guard");
|
|
262
|
+
throw new ExchangeDispositionError("ABANDONED_UNRESOLVED prohibits further protocol activity");
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Administrative termination, NOT runtime cancellation or normal CLOSED.
|
|
266
|
+
* The authenticated host must load the complete message set, each current
|
|
267
|
+
* unresolved attempt, and any existing disposition and commit atomically.
|
|
268
|
+
* Owner/reconciliation text records intent only; unknown facts are unchanged.
|
|
269
|
+
*/
|
|
270
|
+
export function archiveUnresolvedExchange(exchange, messages, attempts, intent, authorization, existing) {
|
|
271
|
+
const reject = (detail) => { throw new ExchangeDispositionError(detail); };
|
|
272
|
+
try {
|
|
273
|
+
assertAuthorized(authorization, "exchange_archive", { kind: "owner", exchangeId: exchange.exchangeId, ownerId: intent.ownerId });
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
reject("scoped administrative owner authorization is required");
|
|
277
|
+
}
|
|
278
|
+
if (!Value.Check(ExchangeSchema, exchange) || exchange.state !== "OPEN")
|
|
279
|
+
reject("exchange must be open");
|
|
280
|
+
const seenMessages = new Set();
|
|
281
|
+
for (const message of messages) {
|
|
282
|
+
if (!Value.Check(MessageSchema, message) || message.exchangeId !== exchange.exchangeId)
|
|
283
|
+
reject("invalid or foreign message");
|
|
284
|
+
if (seenMessages.has(message.messageId))
|
|
285
|
+
reject("duplicate message");
|
|
286
|
+
seenMessages.add(message.messageId);
|
|
287
|
+
if (message.state !== "DELIVERY_UNKNOWN" && !FINISHED_MESSAGE_STATES.includes(message.state))
|
|
288
|
+
reject("in-flight activity must first be reconciled or quarantined");
|
|
289
|
+
}
|
|
290
|
+
const unresolvedMessages = messages.filter((message) => message.state === "DELIVERY_UNKNOWN");
|
|
291
|
+
if (unresolvedMessages.length === 0 || attempts.length !== unresolvedMessages.length)
|
|
292
|
+
reject("one active attempt per unresolved message is required");
|
|
293
|
+
const seenAttempts = new Set();
|
|
294
|
+
const seenAttemptMessages = new Set();
|
|
295
|
+
for (const attempt of attempts) {
|
|
296
|
+
if (!Value.Check(DeliveryAttemptSchema, attempt))
|
|
297
|
+
reject("invalid active attempt");
|
|
298
|
+
const message = unresolvedMessages.find((entry) => entry.messageId === attempt.messageId);
|
|
299
|
+
if (message === undefined || !deepEqual(message.to, attempt.endpoint))
|
|
300
|
+
reject("uncorrelated active attempt");
|
|
301
|
+
if (attempt.dispatch !== "started" || attempt.transport !== "ambiguous")
|
|
302
|
+
reject("original unknown/ambiguous evidence is required");
|
|
303
|
+
if (seenAttempts.has(attempt.attemptId) || seenAttemptMessages.has(attempt.messageId))
|
|
304
|
+
reject("duplicate active attempt");
|
|
305
|
+
seenAttempts.add(attempt.attemptId);
|
|
306
|
+
seenAttemptMessages.add(attempt.messageId);
|
|
307
|
+
}
|
|
308
|
+
const candidate = {
|
|
309
|
+
...intent,
|
|
310
|
+
exchangeId: exchange.exchangeId,
|
|
311
|
+
status: "ABANDONED_UNRESOLVED",
|
|
312
|
+
unresolved: [...unresolvedMessages].sort((a, b) => a.messageId.localeCompare(b.messageId)).map((message) => ({
|
|
313
|
+
messageId: message.messageId,
|
|
314
|
+
originalState: "DELIVERY_UNKNOWN",
|
|
315
|
+
transport: "ambiguous",
|
|
316
|
+
unresolvedSideEffects: true,
|
|
317
|
+
attempt: attempts.find((attempt) => attempt.messageId === message.messageId),
|
|
318
|
+
})),
|
|
319
|
+
};
|
|
320
|
+
assertDispositionShape(candidate);
|
|
321
|
+
const recordedAt = Date.parse(candidate.recordedAt);
|
|
322
|
+
if (Date.parse(exchange.createdAt) > recordedAt || messages.some((message) => Date.parse(message.createdAt) > recordedAt))
|
|
323
|
+
reject("disposition predates the aggregate");
|
|
324
|
+
if (existing !== undefined) {
|
|
325
|
+
assertDispositionShape(existing);
|
|
326
|
+
if (!deepEqual(existing, candidate))
|
|
327
|
+
reject("immutable disposition conflicts with existing history");
|
|
328
|
+
}
|
|
329
|
+
return deepFreeze(clone(existing ?? candidate));
|
|
330
|
+
}
|
|
331
|
+
function assertDispositionShape(disposition) {
|
|
332
|
+
try {
|
|
333
|
+
canonicalDigest(disposition);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
throw new ExchangeDispositionError("disposition must be plain JSON");
|
|
337
|
+
}
|
|
338
|
+
if (!Value.Check(ExchangeDispositionSchema, disposition))
|
|
339
|
+
throw new ExchangeDispositionError("invalid disposition schema");
|
|
340
|
+
const seenMessages = new Set();
|
|
341
|
+
const seenAttempts = new Set();
|
|
342
|
+
for (const entry of disposition.unresolved) {
|
|
343
|
+
if (entry.attempt.messageId !== entry.messageId || entry.attempt.dispatch !== "started" || entry.attempt.transport !== "ambiguous" || seenMessages.has(entry.messageId) || seenAttempts.has(entry.attempt.attemptId)) {
|
|
344
|
+
throw new ExchangeDispositionError("invalid original unresolved snapshot");
|
|
345
|
+
}
|
|
346
|
+
if (Date.parse(entry.attempt.createdAt) > Date.parse(disposition.recordedAt))
|
|
347
|
+
throw new ExchangeDispositionError("disposition predates the attempt");
|
|
348
|
+
seenMessages.add(entry.messageId);
|
|
349
|
+
seenAttempts.add(entry.attempt.attemptId);
|
|
350
|
+
}
|
|
351
|
+
if (Date.parse(disposition.reconciliation.resolvedAt) > Date.parse(disposition.recordedAt))
|
|
352
|
+
throw new ExchangeDispositionError("future reconciliation metadata");
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Explicitly close an open exchange with a scoped owner close grant,
|
|
356
|
+
* and only when EVERY message of the exchange is finished
|
|
357
|
+
* (RESULT_AVAILABLE / CANCELLED / FAILED): nothing in flight, nothing
|
|
358
|
+
* quarantined, no pending cancellation. Reading results never contributes to
|
|
359
|
+
* closability. Returns a new closed exchange; the input is not mutated.
|
|
360
|
+
*/
|
|
361
|
+
export function closeExchange(exchange, messages, authorization, disposition) {
|
|
362
|
+
assertNotArchived(exchange.exchangeId, disposition);
|
|
363
|
+
try {
|
|
364
|
+
assertAuthorized(authorization, "exchange_close", { kind: "owner", exchangeId: exchange.exchangeId, ownerId: authorization?.principalId });
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
throw new CloseRejectedError("scoped owner close authorization is required");
|
|
368
|
+
}
|
|
369
|
+
if (exchange.state !== "OPEN") {
|
|
370
|
+
throw new CloseRejectedError(`exchange ${exchange.exchangeId} is already ${exchange.state}`);
|
|
371
|
+
}
|
|
372
|
+
for (const message of messages) {
|
|
373
|
+
if (message.exchangeId !== exchange.exchangeId) {
|
|
374
|
+
throw new CloseRejectedError(`message ${message.messageId} belongs to exchange ${message.exchangeId}, not ${exchange.exchangeId}`);
|
|
375
|
+
}
|
|
376
|
+
if (!FINISHED_MESSAGE_STATES.includes(message.state)) {
|
|
377
|
+
throw new CloseRejectedError(`message ${message.messageId} is still ${message.state}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return { ...exchange, state: "CLOSED" };
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Read a Result: a non-mutating, repeatable observation. Returns a deeply
|
|
384
|
+
* frozen snapshot; the input is never modified and no lifecycle state
|
|
385
|
+
* changes anywhere. Reads are not consumption.
|
|
386
|
+
*/
|
|
387
|
+
export function readResult(result) {
|
|
388
|
+
assertResultShape(result);
|
|
389
|
+
return deepFreeze(clone(result));
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Publish-once contract for the immutable Result: the first publication is
|
|
393
|
+
* accepted, an exactly equal republication is replayed, and any different
|
|
394
|
+
* content for the same result id is a conflict. Never mutates inputs.
|
|
395
|
+
*/
|
|
396
|
+
export function assertResultPublication(existing, candidate, authorization, disposition) {
|
|
397
|
+
assertNotArchived(candidate.exchangeId, disposition);
|
|
398
|
+
assertResultShape(candidate);
|
|
399
|
+
assertAuthorized(authorization, "result_publish", {
|
|
400
|
+
kind: "attempt", exchangeId: candidate.exchangeId, messageId: candidate.messageId,
|
|
401
|
+
attemptId: candidate.attemptId, endpoint: candidate.source,
|
|
402
|
+
});
|
|
403
|
+
if (existing === undefined) {
|
|
404
|
+
return deepFreeze(clone(candidate));
|
|
405
|
+
}
|
|
406
|
+
assertResultShape(existing);
|
|
407
|
+
if (!deepEqual(existing, candidate)) {
|
|
408
|
+
throw new ResultConflictError(candidate.resultId);
|
|
409
|
+
}
|
|
410
|
+
return deepFreeze(clone(existing));
|
|
411
|
+
}
|
|
412
|
+
function isSettlement(outcome) {
|
|
413
|
+
return outcome !== undefined && ["settled", "runtime_error", "cancelled", "interrupted"].includes(outcome);
|
|
414
|
+
}
|
|
415
|
+
function assertResultShape(result) {
|
|
416
|
+
try {
|
|
417
|
+
// Wire evidence is opaque JSON, not arbitrary mutable JavaScript objects.
|
|
418
|
+
canonicalDigest(result);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
throw new InvalidResultError("result must contain only finite, acyclic plain JSON data");
|
|
422
|
+
}
|
|
423
|
+
if (!isSettlement(result.runtime)) {
|
|
424
|
+
throw new InvalidResultError("result requires settlement-grade runtime evidence");
|
|
425
|
+
}
|
|
426
|
+
if (!Value.Check(ResultSchema, result)) {
|
|
427
|
+
const errors = [...Value.Errors(ResultSchema, result)];
|
|
428
|
+
const first = errors[0];
|
|
429
|
+
throw new InvalidResultError(first ? `${first.path}: ${first.message}` : "unknown violation");
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function clone(value) {
|
|
433
|
+
if (Array.isArray(value) || (typeof value === "object" && value !== null)) {
|
|
434
|
+
return structuredClone(value);
|
|
435
|
+
}
|
|
436
|
+
return value;
|
|
437
|
+
}
|
|
438
|
+
function deepFreeze(value) {
|
|
439
|
+
if (Array.isArray(value) || (typeof value === "object" && value !== null)) {
|
|
440
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
441
|
+
const child = value[key];
|
|
442
|
+
if (child !== null && typeof child === "object")
|
|
443
|
+
deepFreeze(child);
|
|
444
|
+
}
|
|
445
|
+
Object.freeze(value);
|
|
446
|
+
}
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
function deepEqual(a, b) {
|
|
450
|
+
if (Object.is(a, b))
|
|
451
|
+
return true;
|
|
452
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
const aKeys = Reflect.ownKeys(a);
|
|
456
|
+
const bKeys = Reflect.ownKeys(b);
|
|
457
|
+
if (aKeys.length !== bKeys.length)
|
|
458
|
+
return false;
|
|
459
|
+
for (const key of aKeys) {
|
|
460
|
+
if (!Reflect.has(b, key))
|
|
461
|
+
return false;
|
|
462
|
+
if (!deepEqual(a[key], b[key])) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
//# sourceMappingURL=rules.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CE-0002 B3-P3B — Pi-type-free host integration seam (compatibility surface).
|
|
3
|
+
*
|
|
4
|
+
* HANDOFF-853274 moved the endpoint-neutral host authority chain into
|
|
5
|
+
* `src/host/endpoint-host.ts` without changing it. This module keeps the
|
|
6
|
+
* original Pi import path, names, and policy shape:
|
|
7
|
+
*
|
|
8
|
+
* - `CeSessionHost` is the shared host fixed to the Pi adapter. Its constructor
|
|
9
|
+
* still refuses a non-Pi adapter and an empty `piSessionId` exactly as before.
|
|
10
|
+
* - Pi session ids remain untrusted lookup labels; they authorize nothing.
|
|
11
|
+
* - Lease lifecycle (`renew`, `end`) and `requireUnowned` exist on the shared
|
|
12
|
+
* host but are opt-in; the Pi extension does not call them, so Pi Session
|
|
13
|
+
* binding, restart recovery (`resume`), and lease-expiry fail-closed behavior
|
|
14
|
+
* are unchanged.
|
|
15
|
+
* - Cross-adapter recipients are available only when the operator lists them
|
|
16
|
+
* explicitly in `recipients`; without that list Pi sends exactly as before.
|
|
17
|
+
* - No automatic takeover recovery: expired Pi authority still fails closed; the
|
|
18
|
+
* separate operator-only recovery caller is not reachable from this wrapper.
|
|
19
|
+
*/
|
|
20
|
+
import { PI_ADAPTER_KIND } from "./contract.js";
|
|
21
|
+
import { CeEndpointHost, CeHostRefusal, } from "../../host/endpoint-host.js";
|
|
22
|
+
export { CeHostRefusal, } from "../../host/endpoint-host.js";
|
|
23
|
+
/**
|
|
24
|
+
* The trusted Pi host adapter. One instance per Pi process; one authority row
|
|
25
|
+
* per allowlisted Endpoint.
|
|
26
|
+
*/
|
|
27
|
+
export class CeSessionHost extends CeEndpointHost {
|
|
28
|
+
constructor(store, policy) {
|
|
29
|
+
if (policy.adapter !== PI_ADAPTER_KIND)
|
|
30
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
31
|
+
for (const entry of policy.allowlist) {
|
|
32
|
+
if (typeof entry.piSessionId !== "string" || entry.piSessionId.length === 0) {
|
|
33
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
super(store, policy);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=session-host.js.map
|