@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +21 -0
  3. package/README.md +89 -2
  4. package/dist/bin/mcp-stdio.js +196 -0
  5. package/dist/control/debug-log.js +67 -0
  6. package/dist/control/mcp/debug-transport.js +48 -0
  7. package/dist/control/mcp/messaging-tools.js +278 -0
  8. package/dist/control/mcp/ping.js +43 -0
  9. package/dist/control/mcp/server.js +153 -0
  10. package/dist/core/authorization.js +32 -0
  11. package/dist/core/reference.js +32 -0
  12. package/dist/core/rules.js +468 -0
  13. package/dist/endpoints/pi/contract.js +2 -0
  14. package/dist/endpoints/pi/session-host.js +39 -0
  15. package/dist/host/bound-endpoint.js +176 -0
  16. package/dist/host/endpoint-host.js +839 -0
  17. package/dist/host/mcp-binding-config.js +209 -0
  18. package/dist/host/ownership-recovery.js +257 -0
  19. package/dist/index.js +11 -0
  20. package/dist/protocol/capabilities.js +46 -0
  21. package/dist/protocol/endpoint-capabilities.js +15 -0
  22. package/dist/protocol/entities.js +185 -0
  23. package/dist/protocol/formats.js +13 -0
  24. package/dist/protocol/idempotency.js +93 -0
  25. package/dist/protocol/identity.js +130 -0
  26. package/dist/protocol/index.js +8 -0
  27. package/dist/protocol/lifecycle.js +125 -0
  28. package/dist/protocol/outcomes.js +55 -0
  29. package/dist/runtime/desktop-manifest.js +65 -0
  30. package/dist/runtime/diagnostics.js +34 -0
  31. package/dist/runtime/executables.js +222 -0
  32. package/dist/storage/capability.js +221 -0
  33. package/dist/storage/connection.js +94 -0
  34. package/dist/storage/creation-request.js +176 -0
  35. package/dist/storage/creation.js +553 -0
  36. package/dist/storage/current-schema.js +397 -0
  37. package/dist/storage/delivery-ack.js +177 -0
  38. package/dist/storage/errors.js +9 -0
  39. package/dist/storage/filesystem.js +116 -0
  40. package/dist/storage/inbox-discovery.js +135 -0
  41. package/dist/storage/messaging.js +156 -0
  42. package/dist/storage/migration.js +258 -0
  43. package/dist/storage/ownership.js +789 -0
  44. package/dist/storage/participant-identity.js +127 -0
  45. package/dist/storage/participant.js +700 -0
  46. package/dist/storage/recovery.js +99 -0
  47. package/dist/storage/runtime.js +113 -0
  48. package/dist/storage/schema.js +556 -0
  49. package/dist/storage/session-state.js +75 -0
  50. package/dist/storage/store.js +450 -0
  51. package/dist/storage/transaction.js +199 -0
  52. package/dist/storage/trust-root.js +164 -0
  53. package/dist/tunnel/background-manager.js +1727 -0
  54. package/dist/tunnel/codes.js +79 -0
  55. package/dist/tunnel/health.js +75 -0
  56. package/dist/tunnel/keychain.js +526 -0
  57. package/dist/tunnel/managed-binding.js +454 -0
  58. package/dist/tunnel/manager-panel.js +249 -0
  59. package/dist/tunnel/manager-ui.js +341 -0
  60. package/dist/tunnel/manager.js +372 -0
  61. package/dist/tunnel/messaging-activation-ui.js +85 -0
  62. package/dist/tunnel/messaging-config.js +246 -0
  63. package/dist/tunnel/multi-session-admission.js +31 -0
  64. package/dist/tunnel/multi-session-policy.js +314 -0
  65. package/dist/tunnel/offline-client.js +104 -0
  66. package/dist/tunnel/profile.js +319 -0
  67. package/dist/tunnel/redact.js +9 -0
  68. package/dist/tunnel/secure-input.js +318 -0
  69. package/dist/tunnel/session-process-mutex.js +338 -0
  70. package/dist/tunnel/setup-ui.js +443 -0
  71. package/dist/tunnel/setup.js +734 -0
  72. package/dist/tunnel/state.js +127 -0
  73. package/docs/architecture.md +170 -0
  74. package/docs/chatgpt-installation.md +149 -0
  75. package/docs/delivery-ack-contract.md +86 -0
  76. package/docs/independent-install.md +156 -0
  77. package/docs/multi-session-usage.md +77 -0
  78. package/docs/ownership-contract.md +429 -0
  79. package/docs/protocol-v2.md +269 -0
  80. package/docs/releases/0.2.0.md +59 -0
  81. package/docs/storage-contract.md +1241 -0
  82. package/docs/storage-security.md +138 -0
  83. package/docs/tunnel-manager-background.md +169 -0
  84. package/docs/tunnel-setup.md +214 -0
  85. package/native/tunnel-keychain.swift +432 -0
  86. package/package.json +77 -4
  87. package/pi-extension/README.md +125 -0
  88. package/pi-extension/allowlist.example.json +11 -0
  89. package/pi-extension/index.ts +2312 -0
  90. package/pi-extension/multi-session-commands.ts +93 -0
  91. package/pi-extension/multi-session-runtime.ts +75 -0
  92. package/pi-extension/tui-keys.ts +40 -0
  93. package/scripts/reconcile-session-process-mutex.mjs +262 -0
  94. package/scripts/recover-stale-ownership.mjs +91 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Generic exchange entity schemas (frozen v2).
3
+ *
4
+ * Payload bodies and result bodies are expected data and may be persisted by
5
+ * the host, but they are sensitive: keep them out of diagnostic logs and
6
+ * correlate via digests (IDs, digests, lengths, typed outcomes) instead.
7
+ * Never persist credentials or environment secrets; endpoint identity is
8
+ * opaque routing identity only.
9
+ */
10
+ import { Type } from "@sinclair/typebox";
11
+ import "./formats.js";
12
+ import { ATTEMPT_ID_PATTERN, EVENT_ID_PATTERN, EXCHANGE_ID_PATTERN, MESSAGE_ID_PATTERN, RECEIPT_ID_PATTERN, RESULT_ID_PATTERN, } from "./identity.js";
13
+ import { MESSAGE_STATES } from "./lifecycle.js";
14
+ import { AttemptTransportSchema, DispatchMarkerSchema, RuntimeOutcomeSchema, TransportOutcomeSchema } from "./outcomes.js";
15
+ export const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
16
+ const Sha256Schema = Type.String({
17
+ pattern: SHA256_HEX_PATTERN.source,
18
+ description: "SHA-256 hex digest. Correlate via digest, never log the body.",
19
+ });
20
+ const idSchema = (pattern, description) => Type.String({ pattern: pattern.source, description });
21
+ /**
22
+ * Opaque, non-secret routing identity for one adapter endpoint.
23
+ *
24
+ * - `adapter` names the adapter kind that understands `endpointId`;
25
+ * - `endpointId` is the DURABLE endpoint identity (survives restarts);
26
+ * - `instanceId` is the EPHEMERAL process incarnation, when one is live.
27
+ *
28
+ * EndpointRef deliberately carries no free-form routing metadata and no
29
+ * credentials: anything secret or adapter-specific beyond these three fields
30
+ * stays adapter-local. `additionalProperties: false` enforces this.
31
+ */
32
+ export const EndpointRefSchema = Type.Object({
33
+ adapter: Type.String({ minLength: 1, maxLength: 64 }),
34
+ endpointId: Type.String({ minLength: 1, maxLength: 256 }),
35
+ instanceId: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
36
+ }, { additionalProperties: false });
37
+ /**
38
+ * Exchange aggregate root: OPEN governs correlation and message creation;
39
+ * CLOSED is an explicit owner action (core `closeExchange`), never implied by
40
+ * reads. Legacy `ho_` identity prefix retained from v1 (documented
41
+ * compatibility; no id migration).
42
+ */
43
+ export const ExchangeSchema = Type.Object({
44
+ exchangeId: idSchema(EXCHANGE_ID_PATTERN, "Opaque immutable exchange identity: ho_ followed by a ULID (legacy v1 prefix, retained)."),
45
+ state: Type.Union([Type.Literal("OPEN"), Type.Literal("CLOSED")]),
46
+ createdAt: Type.String({ format: "date-time" }),
47
+ closedAt: Type.Optional(Type.String({ format: "date-time" })),
48
+ }, { additionalProperties: false });
49
+ /** A Message is an independently lifecycle-tracked entity inside one Exchange. */
50
+ export const MessageSchema = Type.Object({
51
+ messageId: idSchema(MESSAGE_ID_PATTERN, "Opaque immutable message identity: msg_ followed by a ULID (additive v2)."),
52
+ exchangeId: idSchema(EXCHANGE_ID_PATTERN, "Owning exchange identity."),
53
+ from: EndpointRefSchema,
54
+ to: EndpointRefSchema,
55
+ state: Type.Union(MESSAGE_STATES.map((state) => Type.Literal(state))),
56
+ payloadDigest: Sha256Schema,
57
+ payloadLength: Type.Integer({ minimum: 0 }),
58
+ createdAt: Type.String({ format: "date-time" }),
59
+ idempotencyKey: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
60
+ }, { additionalProperties: false });
61
+ /** Consumer lease: claim ownership with a fencing token and expiry. */
62
+ export const LeaseSchema = Type.Object({
63
+ consumerId: Type.String({ minLength: 1, maxLength: 128 }),
64
+ instanceId: Type.String({ minLength: 1, maxLength: 256 }),
65
+ fencingToken: Type.Integer({ minimum: 1 }),
66
+ expiresAt: Type.String({ format: "date-time" }),
67
+ }, { additionalProperties: false });
68
+ /**
69
+ * DeliveryAttempt: the durable dispatch-evidence record. `dispatch` is marked
70
+ * `started` durably BEFORE any adapter side effect; `transport` records the
71
+ * generic outcome afterwards. The recorded `lease` is the fencing authority
72
+ * for every mutation that cites this attempt.
73
+ */
74
+ export const DeliveryAttemptSchema = Type.Object({
75
+ attemptId: idSchema(ATTEMPT_ID_PATTERN, "Opaque immutable attempt identity: att_ followed by a ULID (additive v2)."),
76
+ messageId: idSchema(MESSAGE_ID_PATTERN, "Correlated message identity."),
77
+ endpoint: EndpointRefSchema,
78
+ lease: LeaseSchema,
79
+ dispatch: DispatchMarkerSchema,
80
+ transport: AttemptTransportSchema,
81
+ createdAt: Type.String({ format: "date-time" }),
82
+ }, { additionalProperties: false });
83
+ /** Adapter-observed runtime evidence, correlated independently of transport. */
84
+ export const RuntimeEvidenceSchema = Type.Object({
85
+ exchangeId: idSchema(EXCHANGE_ID_PATTERN, "Correlated exchange identity."),
86
+ messageId: idSchema(MESSAGE_ID_PATTERN, "Correlated message identity."),
87
+ attemptId: idSchema(ATTEMPT_ID_PATTERN, "Correlated attempt identity."),
88
+ endpoint: EndpointRefSchema,
89
+ outcome: RuntimeOutcomeSchema,
90
+ observedAt: Type.String({ format: "date-time" }),
91
+ }, { additionalProperties: false });
92
+ /**
93
+ * Immutable Result with full correlation (exchange, message, attempt) and
94
+ * layered outcome discriminants. `applicationEvidence` is opaque JSON the
95
+ * protocol never interprets. Publication is once: exact replay or conflict.
96
+ */
97
+ export const ResultSchema = Type.Object({
98
+ resultId: idSchema(RESULT_ID_PATTERN, "Opaque immutable result identity: hr_ followed by a ULID (unchanged from v1)."),
99
+ exchangeId: idSchema(EXCHANGE_ID_PATTERN, "Correlated exchange identity."),
100
+ messageId: idSchema(MESSAGE_ID_PATTERN, "Correlated message identity."),
101
+ attemptId: idSchema(ATTEMPT_ID_PATTERN, "Correlated attempt identity."),
102
+ source: EndpointRefSchema,
103
+ transport: TransportOutcomeSchema,
104
+ runtime: RuntimeOutcomeSchema,
105
+ body: Type.String({ maxLength: 1_000_000 }),
106
+ resultDigest: Sha256Schema,
107
+ settledAt: Type.String({ format: "date-time" }),
108
+ applicationEvidence: Type.Optional(Type.Unknown()),
109
+ }, { additionalProperties: false });
110
+ /** Non-mutating observation receipts (reads never change lifecycle state). */
111
+ export const ReceiptSchema = Type.Object({
112
+ receiptId: idSchema(RECEIPT_ID_PATTERN, "Opaque immutable receipt identity: rcpt_ followed by a ULID (additive v2)."),
113
+ exchangeId: idSchema(EXCHANGE_ID_PATTERN, "Correlated exchange identity."),
114
+ messageId: idSchema(MESSAGE_ID_PATTERN, "Correlated message identity."),
115
+ endpoint: EndpointRefSchema,
116
+ kind: Type.Union([
117
+ Type.Literal("read"),
118
+ Type.Literal("delivery_ack"),
119
+ Type.Literal("duplicate_suppressed"),
120
+ ]),
121
+ createdAt: Type.String({ format: "date-time" }),
122
+ }, { additionalProperties: false });
123
+ /** Registered consumer (claim principal). Labels are non-secret diagnostics. */
124
+ export const ConsumerSchema = Type.Object({
125
+ consumerId: Type.String({ minLength: 1, maxLength: 128 }),
126
+ label: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
127
+ createdAt: Type.String({ format: "date-time" }),
128
+ lastSeenAt: Type.Optional(Type.String({ format: "date-time" })),
129
+ }, { additionalProperties: false });
130
+ export const EVENT_KINDS = [
131
+ "exchange_opened",
132
+ "exchange_closed",
133
+ "message_created",
134
+ "message_state_changed",
135
+ "attempt_recorded",
136
+ "result_published",
137
+ "receipt_recorded",
138
+ ];
139
+ /**
140
+ * Bounded diagnostic event: IDs, digests, lengths, and typed outcomes only.
141
+ * Raw payload bodies are structurally rejected (no `payload` field exists).
142
+ */
143
+ export const EventSchema = Type.Object({
144
+ eventId: idSchema(EVENT_ID_PATTERN, "Opaque immutable event identity: evt_ followed by a ULID (additive v2)."),
145
+ exchangeId: idSchema(EXCHANGE_ID_PATTERN, "Correlated exchange identity."),
146
+ sequence: Type.Integer({ minimum: 1 }),
147
+ kind: Type.Union(EVENT_KINDS.map((kind) => Type.Literal(kind))),
148
+ subjectId: Type.String({ minLength: 1, maxLength: 256 }),
149
+ createdAt: Type.String({ format: "date-time" }),
150
+ digest: Type.Optional(Sha256Schema),
151
+ length: Type.Optional(Type.Integer({ minimum: 0 })),
152
+ outcome: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
153
+ }, { additionalProperties: false });
154
+ /**
155
+ * Explicit reconciliation evidence for quarantined (DELIVERY_UNKNOWN)
156
+ * resolution. Late correlated acceptance or a confirmed no-effect proof must
157
+ * travel with this record — an expired consumer lease is never sufficient.
158
+ */
159
+ export const ReconciliationEvidenceSchema = Type.Object({
160
+ resolvedAt: Type.String({ format: "date-time" }),
161
+ basis: Type.String({ minLength: 1, maxLength: 512 }),
162
+ }, { additionalProperties: false });
163
+ /**
164
+ * Separate administrative archive, never an execution state or Result.
165
+ * OPEN/CLOSED semantics are unchanged. A disposition permanently disables
166
+ * protocol activity but preserves each original unresolved delivery fact.
167
+ * Owner identity must come from the authenticated host, not an adapter label.
168
+ */
169
+ export const ExchangeDispositionSchema = Type.Object({
170
+ exchangeId: idSchema(EXCHANGE_ID_PATTERN, "Administratively archived exchange identity."),
171
+ status: Type.Literal("ABANDONED_UNRESOLVED"),
172
+ ownerId: Type.String({ minLength: 1, maxLength: 256 }),
173
+ recordedAt: Type.String({ format: "date-time" }),
174
+ reason: Type.String({ minLength: 1, maxLength: 1024 }),
175
+ // This is investigation/owner-intent metadata, not execution proof.
176
+ reconciliation: ReconciliationEvidenceSchema,
177
+ unresolved: Type.Array(Type.Object({
178
+ messageId: idSchema(MESSAGE_ID_PATTERN, "Original unresolved message identity."),
179
+ originalState: Type.Literal("DELIVERY_UNKNOWN"),
180
+ transport: Type.Literal("ambiguous"),
181
+ unresolvedSideEffects: Type.Literal(true),
182
+ attempt: DeliveryAttemptSchema,
183
+ }, { additionalProperties: false }), { minItems: 1 }),
184
+ }, { additionalProperties: false });
185
+ //# sourceMappingURL=entities.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shared JSON Schema format validators for frozen protocol schemas.
3
+ *
4
+ * TypeBox's `Value.Check` rejects `format` keywords that have no registered
5
+ * validator, so the `date-time` formats used across the protocol are
6
+ * registered exactly once, here. Validation is strict ISO 8601 with an
7
+ * explicit zone designator (Z or ±hh:mm); the protocol always serializes
8
+ * timestamps in UTC `Z` form.
9
+ */
10
+ import { FormatRegistry } from "@sinclair/typebox";
11
+ const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
12
+ FormatRegistry.Set("date-time", (value) => typeof value === "string" && DATE_TIME_PATTERN.test(value));
13
+ //# sourceMappingURL=formats.js.map
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Idempotency contract (frozen v2; strengthened from v1).
3
+ *
4
+ * A retried create (e.g. after a caller-side timeout) must never silently
5
+ * produce a second entity. Keys are scoped to an explicit NAMESPACE, so
6
+ * distinct callers cannot collide on the same key string:
7
+ *
8
+ * record(ns, key=A, digest=D) -> entity 1
9
+ * record(ns, key=A, digest=D) -> entity 1 (same record replayed)
10
+ * record(ns, key=A, digest=E) -> IDEMPOTENCY_KEY_CONFLICT (never a new id)
11
+ * record(otherNs, key=A, ...) -> independent record
12
+ *
13
+ * The store contract is keyed by (namespace, key) and returns IMMUTABLE
14
+ * records. This module defines the contract and the canonical digest; the
15
+ * in-memory REFERENCE implementation lives in core (`core/reference.ts`) —
16
+ * it is a semantics reference, not a durable store. A durable host must
17
+ * preserve these semantics transactionally.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ export const IDEMPOTENCY_KEY_MAX_LENGTH = 256;
21
+ export const NAMESPACE_MAX_LENGTH = 256;
22
+ export class IdempotencyKeyConflictError extends Error {
23
+ code = "IDEMPOTENCY_KEY_CONFLICT";
24
+ idempotencyKey;
25
+ constructor(namespace, idempotencyKey) {
26
+ super(`idempotency key conflict: ${namespace}:${idempotencyKey}`);
27
+ this.name = "IdempotencyKeyConflictError";
28
+ this.idempotencyKey = idempotencyKey;
29
+ }
30
+ }
31
+ /** Canonical-digest input is unsupported (or cyclic) JSON-ish data. */
32
+ export class DigestInputError extends Error {
33
+ code = "DIGEST_INPUT_UNSUPPORTED";
34
+ constructor(detail) {
35
+ super(`canonical digest input rejected: ${detail}`);
36
+ this.name = "DigestInputError";
37
+ }
38
+ }
39
+ /**
40
+ * Deterministic content digest (SHA-256 hex) over a canonicalized JSON form:
41
+ * object keys are sorted recursively so logically equal requests produce
42
+ * equal digests regardless of key order. Unsupported values (undefined,
43
+ * functions, symbols, bigints, non-finite numbers) and cyclic structures are
44
+ * rejected with {@link DigestInputError} instead of silently degrading.
45
+ */
46
+ export function canonicalDigest(value) {
47
+ return createHash("sha256").update(canonicalJson(value, new Set())).digest("hex");
48
+ }
49
+ function canonicalJson(value, ancestors) {
50
+ if (value === null)
51
+ return "null";
52
+ const type = typeof value;
53
+ if (type === "string")
54
+ return JSON.stringify(value);
55
+ if (type === "number") {
56
+ if (!Number.isFinite(value)) {
57
+ throw new DigestInputError("numbers must be finite");
58
+ }
59
+ return JSON.stringify(value);
60
+ }
61
+ if (type === "boolean")
62
+ return value ? "true" : "false";
63
+ if (type === "undefined" || type === "function" || type === "symbol" || type === "bigint") {
64
+ throw new DigestInputError(`unsupported value of type ${type}`);
65
+ }
66
+ if (ancestors.has(value)) {
67
+ throw new DigestInputError("cyclic structure");
68
+ }
69
+ ancestors.add(value);
70
+ try {
71
+ if (Array.isArray(value)) {
72
+ if (Object.keys(value).length !== value.length || Reflect.ownKeys(value).length !== value.length + 1) {
73
+ throw new DigestInputError("sparse arrays or extra array properties are unsupported");
74
+ }
75
+ return `[${value.map((item) => canonicalJson(item, ancestors)).join(",")}]`;
76
+ }
77
+ const prototype = Object.getPrototypeOf(value);
78
+ if (prototype !== Object.prototype && prototype !== null) {
79
+ throw new DigestInputError("only plain JSON objects are supported");
80
+ }
81
+ if (Reflect.ownKeys(value).length !== Object.keys(value).length) {
82
+ throw new DigestInputError("symbol or non-enumerable properties are unsupported");
83
+ }
84
+ const record = value;
85
+ const keys = Object.keys(record).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
86
+ const entries = keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key], ancestors)}`);
87
+ return `{${entries.join(",")}}`;
88
+ }
89
+ finally {
90
+ ancestors.delete(value);
91
+ }
92
+ }
93
+ //# sourceMappingURL=idempotency.js.map
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Exchange entity identity (frozen v2 contract; encoder frozen since v1).
3
+ *
4
+ * Identity prefixes (opaque, unique, immutable, non-secret, safe to log):
5
+ *
6
+ * - `ho_<ULID>` — Exchange. Legacy prefix retained from protocol v1 for
7
+ * documented compatibility: the v1 "handoff" entity becomes the Exchange
8
+ * aggregate root and no existing id is migrated.
9
+ * - `hr_<ULID>` — Result (unchanged from v1).
10
+ * - `msg_`, `att_`, `rcpt_`, `evt_` — additive Message, DeliveryAttempt,
11
+ * Receipt, and Event identities introduced by protocol v2.
12
+ *
13
+ * Every id is a Crockford Base32 ULID: 48-bit millisecond timestamp + 80-bit
14
+ * CSPRNG randomness, encoded losslessly by {@link encodeUlid}.
15
+ *
16
+ * Human-friendly shortened display IDs may exist for UI convenience, but they
17
+ * are never authoritative (see {@link displayId}).
18
+ */
19
+ import { randomBytes } from "node:crypto";
20
+ /** Legacy v1 name retained: handoff ids are Exchange ids (`ho_`). */
21
+ export const HANDOFF_ID_PREFIX = "ho_";
22
+ export const HANDOFF_ID_PATTERN = /^ho_[0-9A-HJKMNP-TV-Z]{26}$/;
23
+ export const EXCHANGE_ID_PREFIX = HANDOFF_ID_PREFIX;
24
+ export const EXCHANGE_ID_PATTERN = HANDOFF_ID_PATTERN;
25
+ export const MESSAGE_ID_PREFIX = "msg_";
26
+ export const MESSAGE_ID_PATTERN = /^msg_[0-9A-HJKMNP-TV-Z]{26}$/;
27
+ export const ATTEMPT_ID_PREFIX = "att_";
28
+ export const ATTEMPT_ID_PATTERN = /^att_[0-9A-HJKMNP-TV-Z]{26}$/;
29
+ export const RESULT_ID_PREFIX = "hr_";
30
+ export const RESULT_ID_PATTERN = /^hr_[0-9A-HJKMNP-TV-Z]{26}$/;
31
+ export const RECEIPT_ID_PREFIX = "rcpt_";
32
+ export const RECEIPT_ID_PATTERN = /^rcpt_[0-9A-HJKMNP-TV-Z]{26}$/;
33
+ export const EVENT_ID_PREFIX = "evt_";
34
+ export const EVENT_ID_PATTERN = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
35
+ export const ENTITY_ID_PREFIXES = [
36
+ EXCHANGE_ID_PREFIX,
37
+ MESSAGE_ID_PREFIX,
38
+ ATTEMPT_ID_PREFIX,
39
+ RESULT_ID_PREFIX,
40
+ RECEIPT_ID_PREFIX,
41
+ EVENT_ID_PREFIX,
42
+ ];
43
+ const CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
44
+ const TIME_BITS = 48;
45
+ const RANDOM_BITS = 80;
46
+ const ENCODED_LENGTH = 26;
47
+ export function isHandoffId(value) {
48
+ return typeof value === "string" && HANDOFF_ID_PATTERN.test(value);
49
+ }
50
+ export function assertHandoffId(value) {
51
+ if (!isHandoffId(value)) {
52
+ throw new Error(`invalid handoff id: expected ${HANDOFF_ID_PATTERN}`);
53
+ }
54
+ return value;
55
+ }
56
+ /**
57
+ * Encode a ULID from a millisecond timestamp and 10 random bytes, per the
58
+ * ULID specification (Crockford Base32, big-endian, first char 0-7).
59
+ */
60
+ export function encodeUlid(timestampMs, random) {
61
+ if (!Number.isInteger(timestampMs) || timestampMs < 0 || timestampMs >= 2 ** TIME_BITS) {
62
+ throw new Error(`timestamp out of range for ULID: ${timestampMs}`);
63
+ }
64
+ if (random.length !== RANDOM_BITS / 8) {
65
+ throw new Error(`ULID requires ${RANDOM_BITS / 8} random bytes, got ${random.length}`);
66
+ }
67
+ const chars = new Array(ENCODED_LENGTH);
68
+ // 48-bit time, 5 bits per char: chars 0..9 (char 0 masked to 3 bits below).
69
+ let remainder = timestampMs;
70
+ for (let i = 9; i >= 1; i -= 1) {
71
+ chars[i] = alphabetChar(remainder & 31);
72
+ remainder = Math.floor(remainder / 32);
73
+ }
74
+ // After nine divisions the top 3 bits remain for the masked first char.
75
+ chars[0] = alphabetChar(remainder & 7);
76
+ // Consume all ten bytes as one 80-bit big-endian value, then emit sixteen
77
+ // disjoint five-bit groups. Pairing two chars per byte overlaps bits and
78
+ // silently discards the final two bytes.
79
+ let entropy = 0n;
80
+ for (const byte of random)
81
+ entropy = (entropy << 8n) | BigInt(byte);
82
+ for (let i = 25; i >= 10; i -= 1) {
83
+ chars[i] = alphabetChar(Number(entropy & 31n));
84
+ entropy >>= 5n;
85
+ }
86
+ return chars.join("");
87
+ }
88
+ function alphabetChar(index) {
89
+ const char = CROCKFORD_ALPHABET[index];
90
+ if (char === undefined) {
91
+ throw new Error(`Crockford alphabet index out of range: ${index}`);
92
+ }
93
+ return char;
94
+ }
95
+ /** Generate a new exchange id (`ho_`). `now` is injectable for deterministic tests. */
96
+ export function generateHandoffId(now = Date.now()) {
97
+ return assertHandoffId(HANDOFF_ID_PREFIX + encodeUlid(now, randomBytes(RANDOM_BITS / 8)));
98
+ }
99
+ /** Generate a new entity id for any declared prefix. Additive v2 ids use this. */
100
+ export function generateEntityId(prefix, now = Date.now()) {
101
+ return prefix + encodeUlid(now, randomBytes(RANDOM_BITS / 8));
102
+ }
103
+ /** Structural check for any declared entity id prefix. */
104
+ export function isEntityId(prefix, value) {
105
+ return typeof value === "string" && entityIdPattern(prefix).test(value);
106
+ }
107
+ export function entityIdPattern(prefix) {
108
+ switch (prefix) {
109
+ case "ho_":
110
+ return EXCHANGE_ID_PATTERN;
111
+ case "msg_":
112
+ return MESSAGE_ID_PATTERN;
113
+ case "att_":
114
+ return ATTEMPT_ID_PATTERN;
115
+ case "hr_":
116
+ return RESULT_ID_PATTERN;
117
+ case "rcpt_":
118
+ return RECEIPT_ID_PATTERN;
119
+ case "evt_":
120
+ return EVENT_ID_PATTERN;
121
+ }
122
+ }
123
+ /**
124
+ * Non-authoritative short label for display. Derived from the immutable id,
125
+ * but must never be used for persistence, correlation, or API identity.
126
+ */
127
+ export function displayId(handoffId) {
128
+ return handoffId.slice(-8);
129
+ }
130
+ //# sourceMappingURL=identity.js.map
@@ -0,0 +1,8 @@
1
+ export * from "./identity.js";
2
+ export * from "./lifecycle.js";
3
+ export * from "./outcomes.js";
4
+ export * from "./entities.js";
5
+ export * from "./capabilities.js";
6
+ export * from "./idempotency.js";
7
+ export * from "./endpoint-capabilities.js";
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Message lifecycle state machine (frozen v2 contract).
3
+ *
4
+ * PH-0002 correction: a Message is an independent entity inside an Exchange
5
+ * aggregate. Its lifecycle tracks delivery and runtime settlement; it does
6
+ * NOT track reads. There is deliberately no CONSUMED state: reading a Result
7
+ * is a non-mutating observation (see core `readResult`), never a lifecycle
8
+ * event, and "no read" never means "not done".
9
+ *
10
+ * Outcome layers are never collapsed:
11
+ * - transport: did the payload reach the adapter boundary of the destination?
12
+ * (generic trichotomy delivered | ambiguous | failed)
13
+ * - runtime: did the destination invocation accept, run, and settle?
14
+ * - application: is the requested task semantically correct? The protocol
15
+ * never decides this (opaque `applicationEvidence` on the Result).
16
+ *
17
+ * Fail-closed rules (frozen):
18
+ * - Entering DISPATCHING requires the durable dispatch-start evidence on the
19
+ * attempt record BEFORE any adapter side effect can be attempted.
20
+ * - Any potentially attempted dispatch that cannot be confirmed quarantines
21
+ * the message as DELIVERY_UNKNOWN. DELIVERY_UNKNOWN has NO transition back
22
+ * to QUEUED/CLAIMED/DISPATCHING and no automatic retry: a duplicated
23
+ * exchange payload may cause irreversible effects at the destination.
24
+ * - Safe requeue (CLAIMED → QUEUED) is legal only with a validated
25
+ * definitely-not-attempted attempt record — evidence, not assertion.
26
+ * - Cancellation is split: a request (CANCEL_REQUESTED) is never a
27
+ * confirmation. After dispatch may have begun, independently authorized
28
+ * runtime evidence must confirm CANCELLED. Owner abandonment is a separate administrative
29
+ * disposition; it never resolves execution ambiguity or cancels a runtime.
30
+ *
31
+ * Transitions split into two groups:
32
+ * - evidence-free moves, licensed by state shape alone ({@link canTransition});
33
+ * - evidence-gated moves, listed in {@link TRANSITION_REQUIREMENTS} and
34
+ * enforced by the pure core rules, which correlate the exact message,
35
+ * attempt, endpoint, and lease.
36
+ */
37
+ export const MESSAGE_STATES = [
38
+ "CREATED",
39
+ "QUEUED",
40
+ "CLAIMED",
41
+ "DISPATCHING",
42
+ "DELIVERY_UNKNOWN",
43
+ "ACCEPTED",
44
+ "RUNNING",
45
+ "SETTLED",
46
+ "RESULT_AVAILABLE",
47
+ "CANCEL_REQUESTED",
48
+ "CANCELLED",
49
+ "FAILED",
50
+ ];
51
+ /**
52
+ * Finished (terminal) message states: nothing further may happen to the
53
+ * message and an Exchange may close over it. RESULT_AVAILABLE is reached by
54
+ * publishing the immutable Result, never by reading it.
55
+ */
56
+ export const FINISHED_MESSAGE_STATES = [
57
+ "RESULT_AVAILABLE",
58
+ "CANCELLED",
59
+ "FAILED",
60
+ ];
61
+ /** Quarantined: neither finished nor retryable; requires reconciliation. */
62
+ export const QUARANTINED_MESSAGE_STATES = ["DELIVERY_UNKNOWN"];
63
+ /**
64
+ * The single source of truth for transition legality, keyed `"FROM>TO"`.
65
+ * Core rules validate every row's evidence; the shape-level helpers below
66
+ * expose the two legality tiers.
67
+ */
68
+ export const TRANSITION_REQUIREMENTS = {
69
+ "CREATED>QUEUED": { actions: ["exchange_send"], lease: "none", evidence: "none" },
70
+ "QUEUED>CLAIMED": { actions: ["message_claim"], lease: "none", evidence: "none" },
71
+ "QUEUED>CANCELLED": { actions: ["exchange_cancel"], lease: "none", evidence: "none" },
72
+ "CLAIMED>CANCEL_REQUESTED": { actions: ["exchange_cancel"], lease: "none", evidence: "none" },
73
+ "DISPATCHING>CANCEL_REQUESTED": { actions: ["exchange_cancel"], lease: "none", evidence: "none" },
74
+ "ACCEPTED>CANCEL_REQUESTED": { actions: ["exchange_cancel"], lease: "none", evidence: "none" },
75
+ "RUNNING>CANCEL_REQUESTED": { actions: ["exchange_cancel"], lease: "none", evidence: "none" },
76
+ // Safe release/cancellation still requires proven no attempt.
77
+ "CLAIMED>QUEUED": { actions: ["lease_release"], lease: "recorded", evidence: "not_attempted" },
78
+ "CLAIMED>CANCELLED": { actions: ["cancel_confirm"], lease: "recorded", evidence: "not_attempted" },
79
+ // Pre-effect marker and live fencing remain required.
80
+ "CLAIMED>DISPATCHING": { actions: ["dispatch_mark_started"], lease: "current", evidence: "dispatch_started" },
81
+ "CLAIMED>DELIVERY_UNKNOWN": { actions: ["delivery_report"], lease: "recorded", evidence: "transport_ambiguous" },
82
+ "DISPATCHING>ACCEPTED": { actions: ["delivery_report", "activity_report"], lease: "current", evidence: "transport_delivered" },
83
+ "DISPATCHING>DELIVERY_UNKNOWN": { actions: ["delivery_report"], lease: "recorded", evidence: "transport_ambiguous" },
84
+ "DISPATCHING>FAILED": { actions: ["delivery_report"], lease: "current", evidence: "transport_failed" },
85
+ "ACCEPTED>RUNNING": { actions: ["activity_report"], lease: "current", evidence: "runtime_progress" },
86
+ "ACCEPTED>SETTLED": { actions: ["settlement_report"], lease: "current", evidence: "runtime_progress" },
87
+ "RUNNING>SETTLED": { actions: ["settlement_report"], lease: "current", evidence: "runtime_progress" },
88
+ "SETTLED>RESULT_AVAILABLE": { actions: ["result_publish"], lease: "current", evidence: "result_published" },
89
+ // Started cancellation additionally requires scoped runtime-settlement authority
90
+ // in core; proven never-attempted cancellation does not create a runtime fact.
91
+ "CANCEL_REQUESTED>CANCELLED": { actions: ["cancel_confirm"], lease: "current", evidence: "cancel_confirmed" },
92
+ "CANCEL_REQUESTED>DELIVERY_UNKNOWN": { actions: ["delivery_report"], lease: "recorded", evidence: "transport_ambiguous" },
93
+ "CANCEL_REQUESTED>FAILED": { actions: ["delivery_report"], lease: "current", evidence: "transport_failed" },
94
+ "CANCEL_REQUESTED>SETTLED": { actions: ["settlement_report"], lease: "current", evidence: "runtime_progress" },
95
+ // Factual reconciliation is never retry permission or owner abandonment.
96
+ "DELIVERY_UNKNOWN>ACCEPTED": { actions: ["ambiguity_resolve", "activity_report"], lease: "recorded", evidence: "reconciled_delivered" },
97
+ "DELIVERY_UNKNOWN>FAILED": { actions: ["ambiguity_resolve"], lease: "recorded", evidence: "reconciled_not_executed" },
98
+ };
99
+ /**
100
+ * Shape-only legality: true when the move is licensed by the state machine
101
+ * without any further evidence. Evidence-gated moves (safe requeue,
102
+ * quarantine exits, dispatch and settlement progress) return false here and
103
+ * must go through the core rule validation.
104
+ */
105
+ export function canTransition(from, to) {
106
+ const requirement = TRANSITION_REQUIREMENTS[`${from}>${to}`];
107
+ return requirement !== undefined && requirement.lease === "none" && requirement.evidence === "none";
108
+ }
109
+ /** Full legality: true when any requirement row licenses the move. */
110
+ export function isLegalTransition(from, to) {
111
+ return TRANSITION_REQUIREMENTS[`${from}>${to}`] !== undefined;
112
+ }
113
+ /** All legal targets from a state (evidence-free and evidence-gated). */
114
+ export function legalTargets(from) {
115
+ return MESSAGE_STATES.filter((to) => isLegalTransition(from, to));
116
+ }
117
+ /** Requirement table indexed by origin state (documentation convenience). */
118
+ export const MESSAGE_TRANSITIONS = Object.fromEntries(MESSAGE_STATES.map((state) => [state, legalTargets(state)]));
119
+ export function isFinishedMessageState(state) {
120
+ return FINISHED_MESSAGE_STATES.includes(state);
121
+ }
122
+ export function isQuarantinedMessageState(state) {
123
+ return QUARANTINED_MESSAGE_STATES.includes(state);
124
+ }
125
+ //# sourceMappingURL=lifecycle.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Generic outcome vocabularies (frozen v2).
3
+ *
4
+ * Transport and runtime outcomes are separate layers and never borrow each
5
+ * other's vocabulary: a transport success is not runtime acceptance, and a
6
+ * socket-level acknowledgment is never evidence of execution. Application
7
+ * outcome is not modeled here at all — it stays opaque
8
+ * (`applicationEvidence` on the Result).
9
+ */
10
+ import { Type } from "@sinclair/typebox";
11
+ /**
12
+ * Generic transport trichotomy for a dispatch attempt:
13
+ * - `delivered`: the adapter boundary positively confirmed hand-off;
14
+ * - `ambiguous`: the attempt may have taken effect (timeout, crash, unknown);
15
+ * - `failed`: the adapter certifies the attempt provably had no effect.
16
+ *
17
+ * An adapter may report `failed` ONLY when it can prove no side effect
18
+ * occurred; otherwise it must report `ambiguous`. `ambiguous` and `failed`
19
+ * both fail closed in core rules (`DELIVERY_UNKNOWN` quarantine / terminal
20
+ * FAILED with no automatic retry).
21
+ */
22
+ export const TransportOutcomeSchema = Type.Union([
23
+ Type.Literal("delivered"),
24
+ Type.Literal("ambiguous"),
25
+ Type.Literal("failed"),
26
+ ]);
27
+ /** Transport state of a DeliveryAttempt; `pending` before any outcome is known. */
28
+ export const AttemptTransportSchema = Type.Union([
29
+ Type.Literal("pending"),
30
+ TransportOutcomeSchema,
31
+ ]);
32
+ /**
33
+ * Durable dispatch marker on a DeliveryAttempt. `started` is written durably
34
+ * BEFORE any adapter side effect; `not_attempted` is the only evidence that
35
+ * licenses a safe requeue or clean release.
36
+ */
37
+ export const DispatchMarkerSchema = Type.Union([
38
+ Type.Literal("not_attempted"),
39
+ Type.Literal("started"),
40
+ ]);
41
+ /**
42
+ * Generic runtime outcome ladder of the destination invocation. `settled` and
43
+ * the failure kinds are settlement-grade; `accepted`/`running` describe
44
+ * observed progress. A published immutable Result typically carries a
45
+ * settlement-grade value.
46
+ */
47
+ export const RuntimeOutcomeSchema = Type.Union([
48
+ Type.Literal("accepted"),
49
+ Type.Literal("running"),
50
+ Type.Literal("settled"),
51
+ Type.Literal("runtime_error"),
52
+ Type.Literal("cancelled"),
53
+ Type.Literal("interrupted"),
54
+ ]);
55
+ //# sourceMappingURL=outcomes.js.map