@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,429 @@
1
+ # B3-P2 Ownership Contract
2
+
3
+ Status: **clarified by CE-0002-B3-P2A-R1** (this document is the versioned
4
+ record of the P2 ownership design decisions). Scope: the private durable
5
+ storage plane only. No delivery, adapter, or Pi runtime surface is defined
6
+ here.
7
+
8
+ ## Provenance
9
+
10
+ P2A shipped without an accepted B3-D0 design record in the repository; the
11
+ independent review (CE-0002-B3-P2A-INDEPENDENT-REVIEW-1) found the lease time
12
+ model unsafe (MAJOR-1) and five undecided design questions (Q1–Q5). R1 records
13
+ the decisions below. Each entry distinguishes **accepted existing behavior**
14
+ (from B2/P1A/P1B/P2A as implemented and reviewed) from the **newly clarified
15
+ decision** that governs from R1 onward.
16
+
17
+ ## Q1 — Owner replacement is takeover
18
+
19
+ **Decision (new):** Replacing *any* existing Owner — including one whose lease
20
+ has expired — belongs to the P2B `takeover` scope. `attach` may acquire
21
+ ownership only when **no ownership row exists** for the Participant.
22
+
23
+ - An existing live row refuses acquisition with `OWNERSHIP_HELD` (accepted P2A
24
+ behavior, unchanged).
25
+ - An existing expired row — including a legacy v2 row, which is expired at
26
+ every instant — refuses acquisition with `OWNERSHIP_TAKEOVER_REQUIRED`
27
+ (new). No fencing token is allocated by a refused acquisition.
28
+ - `takeover` remains `CAPABILITY_SCOPE_UNSUPPORTED`; no takeover implementation
29
+ exists in R1.
30
+ - Consequence: after a lease expires, the Participant has a durable,
31
+ fenced, non-authorizing ownership record until P2B takeover exists. This is
32
+ deliberate fail-closed behavior, not an oversight.
33
+
34
+ ## Q2 — Resume authority is Session-bound
35
+
36
+ **Decision (new):** `resume` authority must ultimately be Session-bound. R1
37
+ implements the minimum safe foundation:
38
+
39
+ - Schema v3 adds `participant_capability.session_id` (nullable; NULL for all
40
+ non-resume scopes and for legacy v2 rows).
41
+ - Issuing a `resume` capability requires `sessionId`, and the Session must be
42
+ currently bound to the Participant at issuance (checked in the same
43
+ transaction). `sessionId` is refused for every other scope.
44
+ - Every `resume` authorization compares the durable binding with the presented
45
+ Session inside the mutation's transaction; a legacy unbound row, a missing
46
+ presentation, or a mismatch classifies `SESSION_MISMATCH` and refuses.
47
+ - Revoking a `resume` capability requires presenting its bound Session.
48
+ - **Non-goal:** the Session ID is not a credential. No operation is authorized
49
+ by a Session ID alone; the bearer token, Participant binding, scope, trust
50
+ generation, revocation, and expiry checks are all unchanged.
51
+
52
+ P2A's Participant-wide resume tokens do not survive: on a migrated store, a
53
+ legacy (NULL) resume capability can never authorize. The host issues new
54
+ Session-bound resume capabilities. Fail closed, no silent widening.
55
+
56
+ ## Q3 — Lease term boundaries are durably persisted
57
+
58
+ **Decision (new):** the lease term start is persisted
59
+ (`participant_ownership.lease_started_at`), written at acquisition and updated
60
+ at every renewal. Default TTL 120 s; allowed TTL 30–3600 s (unchanged from
61
+ P2A).
62
+
63
+ - Lease evaluation compares the operation clock against the persisted
64
+ boundaries `[leaseStartedAt, expiresAt)` only. A request TTL is used solely
65
+ to compute a *new* expiry at acquisition or renewal; it never reinterprets a
66
+ stored term. (This remediates R1-finding MAJOR-1: the P2A model derived the
67
+ term start as `expiresAt − requestTTL`, which falsely refused valid Owners
68
+ when TTLs differed between calls and allowed a rolled-back clock to extend a
69
+ lease by widening the presented TTL.)
70
+ - Renewal never shortens the term: a new expiry earlier than the current one
71
+ is refused (`LEASE_TERM_SHORTENED`).
72
+ - Exact-expiry refusal is preserved: `now >= expiresAt` is `LEASE_EXPIRED`.
73
+ - **Known limit (unchanged, documented):** a wall-clock rollback that lands
74
+ *inside* the current term cannot be distinguished from a valid instant
75
+ without a persisted monotonic observation. R1 does not claim detection of
76
+ every rollback. Refusals are zero-write.
77
+ - Legacy v2 ownership rows (NULL `lease_started_at`) are treated as expired at
78
+ every instant: no lease start is fabricated from an untrusted TTL, and no v3
79
+ operation authorizes against them.
80
+
81
+ ## Q4 — Expired Owners cannot release
82
+
83
+ **Decision (confirmed existing P2A behavior, now contractual):** release
84
+ requires a live lease. An expired Owner's release refuses `LEASE_EXPIRED`; the
85
+ row persists (fenced, non-authorizing) until takeover.
86
+
87
+ ## Q5 — A current Owner's Session binding cannot be deleted directly
88
+
89
+ **Decision (confirmed existing P2A behavior, now contractual):** P2A/P2A-R1
90
+ has no detach operation. A detached Session that is the current Owner is
91
+ inconsistent durable state (`STORAGE_OWNERSHIP_CORRUPT`, refused, never
92
+ repaired). Detach semantics (including the detach-versus-ownership ordering
93
+ rule) are deferred to P2B.
94
+
95
+ ## Accepted existing behavior carried into R1 unchanged
96
+
97
+ - Session identity: opaque, non-secret `ses_<ULID>`; never a Participant
98
+ identity, never a credential. A Session belongs to exactly one Participant
99
+ (now also enforced by the durable `participant_session_unique_session_id`
100
+ index); a Participant may hold many historical Sessions. Binding never
101
+ confers ownership; Participant creation never creates Sessions or ownership.
102
+ - Fencing: first token 1; strictly monotonic high-water per Participant;
103
+ survives restart, expiry, and release; never reused; allocation past
104
+ `Number.MAX_SAFE_INTEGER` refuses (`FENCING_EXHAUSTED`); fencing and the
105
+ ownership write are one atomic unit; an ownership row must carry exactly the
106
+ current high-water token and a bound Session or the state is corrupt.
107
+ - Capability plane: bearer tokens, host-keyed digests, trust-generation
108
+ binding, revocation, expiry; one opaque `CAPABILITY_REJECTED`
109
+ classification; trust root re-validated per authorization.
110
+ - Transaction discipline: owned BEGIN IMMEDIATE transactions; BUSY /
111
+ ROLLED_BACK / INDETERMINATE classification; refusal over repair; zero-write
112
+ refusals; privacy boundaries (fixed sanitized details).
113
+
114
+ ## P2B prerequisites recorded by R1
115
+
116
+ 1. Takeover: replace an existing (expired or legacy) Owner under a `takeover`
117
+ capability, allocating the next fencing token and a fresh term; classify
118
+ and pin the takeover races. **Implemented by CE-0002-B3-P2B** — see below.
119
+ 2. Detach: define the ordering rule — a current Owner's binding cannot be
120
+ detached while the ownership row references it (Q5) — and the fate of
121
+ Session-bound resume capabilities at detach.
122
+ 3. Lease policy location: if TTL policy must outlive a single request (e.g. a
123
+ store-fixed policy), decide whether it belongs in durable metadata.
124
+
125
+ ## P2B — Takeover implemented (CE-0002-B3-P2B)
126
+
127
+ Status: **implemented**. The Q1 decision is realized without changing any R1
128
+ boundary: `acquireOwnership` still refuses every existing row; replacement is
129
+ exclusively `takeoverOwnership` under the `takeover` scope.
130
+
131
+ - **Authority.** The `takeover` scope is operational and Session-bound to the
132
+ prospective new Owner Session, consistent with the accepted resume model
133
+ (Q2): issuance requires `sessionId` naming a currently bound Session of the
134
+ Participant, classification compares the durable binding inside the
135
+ mutation's transaction, and revocation requires presenting the bound
136
+ Session. A Session ID alone never grants takeover authority. `send`, `ack`,
137
+ and `read` remain inactive.
138
+ - **Eligibility.** Only an existing EXPIRED row — including a legacy v2 row
139
+ (NULL `lease_started_at`, expired at every instant) — may be taken over. A
140
+ live Owner refuses (`OWNERSHIP_HELD`, evaluated against the persisted term;
141
+ exact expiry is the boundary), a clock-regressed lease refuses
142
+ (`LEASE_CLOCK_REGRESSION`), and an absent row refuses
143
+ (`OWNERSHIP_TAKEOVER_INELIGIBLE`): initial acquisition remains the `attach`
144
+ operation. No force takeover of a live Owner exists in this phase.
145
+ - **Atomicity.** Eligibility, fencing allocation (exactly high-water + 1,
146
+ compare-and-set, `FENCING_EXHAUSTED` at the ceiling), and the Owner
147
+ replacement are one owned IMMEDIATE transaction guarded by the exact
148
+ pre-read values: exactly one winner, no partial fencing allocation, no
149
+ partial replacement, no token reuse, zero-write refusals. A takeover racing
150
+ a renewal of a live lease can never win (the lease is live at every instant
151
+ of the race).
152
+ - **Supersession.** The previous Session immediately loses current ownership:
153
+ its fencing token is stale and its `resume` capability cannot renew,
154
+ release, or verify the new Owner (`OWNERSHIP_NOT_CURRENT` /
155
+ `FENCING_STALE`). Historical Session bindings, Participant identity,
156
+ Endpoint binding, Exchange membership, join boundaries, and audit history
157
+ are untouched; capability rows are never mutated by a takeover.
158
+ - **Recovery.** `reconcileTakeover` is a zero-write, fail-closed
159
+ reconciliation read over the evidence the caller presents (Participant,
160
+ target Session, the exact persisted term boundaries, optional fencing
161
+ token): `COMMITTED` (exact durable match — do not retry), `NO_TRACE` (the
162
+ durable state carries no trace of the presented evidence — a fresh attempt
163
+ is freshly authorized and eligibility-checked), or `EVIDENCE_CONFLICT` (the
164
+ target Session is current under different evidence — operator review, never
165
+ auto-retry). All three are effect-level classifications of durable state,
166
+ never operation-level attribution of one invocation: `COMMITTED` is not an
167
+ exactly-once claim, and `NO_TRACE` is not proof that the original invocation
168
+ never committed (a committed takeover later superseded also reports
169
+ `NO_TRACE`). Documented limitations, deliberately not papered over with
170
+ exactly-once semantics: a refused and a rolled-back takeover leave identical
171
+ durable state; a committed-then-superseded takeover reports `NO_TRACE`; a
172
+ committed retry with identical parameters is the same logical request. No
173
+ background recovery workers.
174
+ - **Evidence availability (clarified, no behavior change).** The operation
175
+ clock is sampled inside the owned transaction (see B3-P2B-R1 below), so the
176
+ sampled instant is not an ordinary caller-supplied request field:
177
+ `leaseStartedAt` is that sample and `expiresAt` is `sample + ttl`. The exact
178
+ term normally reaches the caller only through a successful return, or when
179
+ the caller itself supplies and records the operation clock seam; a
180
+ default-clock caller whose call ended INDETERMINATE does not know it, and
181
+ pre-call wall-clock sampling does not reconstruct it. `reconcileTakeover`
182
+ currently requires `participantId`, `sessionId`, `leaseStartedAt`, and
183
+ `expiresAt`, with `fencingToken` optional, and callers must not fabricate,
184
+ estimate, or round term boundaries to satisfy that shape. INDETERMINATE is
185
+ not ROLLED_BACK: the invalidated connection must not be reused, inspection
186
+ requires a freshly opened store, and a lost acknowledgement never authorizes
187
+ a blind retry. When exact term evidence is unavailable the outcome simply
188
+ stays unknown to the caller — there is no UNKNOWN input and no UNKNOWN
189
+ disposition in the API. A pre-call `readFencingHighWater` snapshot that is
190
+ unchanged on a later valid read of the same Participant shows that no
191
+ fencing allocation committed for that Participant between the two reads; an
192
+ increased high-water identifies no invocation, proves nothing about this
193
+ caller's takeover, and never establishes a durable lease. The operator-only
194
+ recovery caller resolving CE-TAKEOVER-RECOVERY-CALLER-001 deliberately does
195
+ not call `reconcileTakeover`: before writing it requires exact endpoint and
196
+ old-owner evidence and an expired durable lease; it then binds a fresh
197
+ recovery Session, uses the existing Session-bound `takeover` primitive,
198
+ releases that lease, and detaches the recovery Session. A successful caller
199
+ returns a capability-free receipt plus durable fencing/session evidence. Any
200
+ INDETERMINATE stage remains unknown and is never retried automatically.
201
+ `reconcileTakeover` still has no production caller because a default-clock
202
+ caller cannot reconstruct exact term boundaries after losing acknowledgement
203
+ (CE-TAKEOVER-RECONCILIATION-EVIDENCE-001). Full statement:
204
+ `docs/storage-contract.md` §11 "Evidence availability after an uncertain
205
+ outcome".
206
+ - **Legacy ownership.** A migrated v2 row never authorizes and is never
207
+ repaired; it is takeover-eligible, and takeover replaces it with a fully
208
+ persisted v3 term at a fencing token strictly greater than the legacy token.
209
+ - Q4 and Q5 are unchanged: an expired Owner cannot release, and there is no
210
+ detach operation (P2B did not take detach; it remains prerequisite 2).
211
+
212
+
213
+ ## B3-P2B-R1 — transaction-time clock sampling (remediation)
214
+
215
+ Status: **implemented**. Remediates the independently confirmed MAJOR-1 of
216
+ CE-0002-B3-P2B-INDEPENDENT-REVIEW-1 (stale pre-lock clock).
217
+
218
+ **The defect.** P2B sampled each operation's clock *before* `BEGIN
219
+ IMMEDIATE`. An operation that waited for the SQLite writer lock evaluated
220
+ authorization and lease state against the pre-lock instant: a renewal
221
+ initiated before expiry but serialized after it committed from the stale
222
+ sample, reviving an expired lease and locking out a legitimately eligible
223
+ takeover (reproduced by the reviewer with real processes and real lock
224
+ contention). A capability that expired during the lock wait was likewise
225
+ accepted.
226
+
227
+ **The decision (new, contractual):** every owned mutating transaction has
228
+ exactly one authoritative operation-time sampling point — *inside* the owned
229
+ transaction, after `BEGIN IMMEDIATE` has acquired the writer lock:
230
+ `BEGIN IMMEDIATE → sample now → validate authority → evaluate current state
231
+ → mutate → COMMIT`. All checks within one mutation use this one consistent
232
+ instant. This applies to `bindSession`, `acquireOwnership`, `renewLease`,
233
+ `releaseOwnership`, `verifyOwnership`, `takeoverOwnership` (ownership plane)
234
+ and to `createParticipant` (sponsor authorization), `bindEndpointRef`,
235
+ `issueCapability`, `revokeCapability`, and `joinExchange` (capability/membership
236
+ plane). Lease start and expiry derive from the transaction-time clock;
237
+ capability expiry is evaluated at the transaction-time clock, so a capability
238
+ that expired during a lock wait can never be accepted. Request validation
239
+ (field shape, TTL range, identities) still precedes the transaction and
240
+ performs no state-dependent check.
241
+
242
+ **Preserved semantics.** Exact-expiry behavior is unchanged (`now >=
243
+ expiresAt` is expired); clock-regression refusal is unchanged (a pre-term
244
+ instant refuses `LEASE_CLOCK_REGRESSION`); the injected-clock test seam is
245
+ unchanged (it is simply invoked at the transaction-time sampling point); no
246
+ scheduler and no global clock is introduced. Audit of the remaining storage
247
+ mutation entry points (corrected by CE-0002-B3-P2C-A): B2 Exchange/Message
248
+ creation samples its clock during candidate generation, *before* `BEGIN
249
+ IMMEDIATE`; its timestamps are recorded creation metadata that authorize
250
+ nothing, so the accepted B2 contract is unchanged (its invalid-clock behavior
251
+ remains the accepted precedent). Migration/recovery/initialization
252
+ `appliedAt` ledger timestamps likewise authorize nothing and are unchanged.
253
+
254
+ **Lock-wait semantics (now defined).** Authorization and eligibility are
255
+ decided at the serialization instant, not the initiation instant. A renewal
256
+ initiated before expiry but serialized after it refuses `LEASE_EXPIRED`; a
257
+ takeover initiated before expiry but serialized after it is eligible and may
258
+ win; a takeover serialized while the lease is still live refuses
259
+ `OWNERSHIP_HELD`; a capability that expired during the wait refuses
260
+ `CAPABILITY_REJECTED`. Every such refusal is zero-write and never allocates
261
+ a fencing token.
262
+
263
+ **Remaining wall-clock limitations (unchanged, no new monotonicity claim).**
264
+ A wall-clock rollback that lands *inside* the current term remains
265
+ indistinguishable from a valid instant without a persisted monotonic
266
+ observation (Q3); transaction-time sampling decides each operation at its
267
+ own serialization instant and does not create a cross-transaction monotonic
268
+ clock. Refusals remain zero-write.
269
+
270
+ **Regression pins.** `test/storage/lease-lock-wait.test.ts` exercises the
271
+ renewal/takeover/capability lock-wait boundaries with real OS processes, a
272
+ real writer lock, observable initiation markers, and deadline waits against
273
+ durable expiries (no sleep-only assertions, no fake-clock simulation of
274
+ contention).
275
+ ## P2C prerequisites recorded by P2B
276
+
277
+ 1. Detach semantics (Q5 ordering rule and the fate of Session-bound
278
+ capabilities at detach).
279
+ 2. Lease policy location (prerequisite 3 above, unchanged).
280
+ 3. `send`/`ack`/`read` scopes remain for the delivery phases.
281
+
282
+ ## B3-P2C-A — Irreversible Session lifecycle and schema v4 (CE-0002-B3-P2C-A)
283
+
284
+ Status: **implemented** (structural foundation only). Realizes the accepted
285
+ CE-0002-B3-P2C-D0 design decisions D1, D2, D5 (as a schema guard), D9 (read),
286
+ D14, and D15. **No detach operation exists yet**; capability scope semantics
287
+ are unchanged (no `detach` scope; `resume` is not yet widened to
288
+ self-detach). P3 is not started.
289
+
290
+ - **States.** A Session row is exactly ATTACHED (`detached_at IS NULL`,
291
+ `private_revision = 1`) or DETACHED (canonical `detached_at` not earlier
292
+ than `bound_at`, `private_revision = 2`). Every other combination is
293
+ `STORAGE_OWNERSHIP_CORRUPT`: refused, zero-write, never repaired. DETACHED
294
+ is terminal; a detached Session is never a current binding and never
295
+ regains authority.
296
+ - **Schema v4 (triggers only).** Session rows are born ATTACHED, never
297
+ deleted, and keep immutable Participant ID, Session ID, and `bound_at`; the
298
+ only permitted UPDATE is the one-way ATTACHED r1 → DETACHED r2 transition;
299
+ the current Owner Session cannot be detached; an ownership row can never
300
+ reference a detached Session. Session IDs are therefore never reused. No
301
+ table rebuild, no new column, no new capability scope.
302
+ R1: the transition also requires a canonical `detached_at` no earlier than
303
+ a canonical `bound_at`, enforced by SQLite itself — see
304
+ `docs/storage-contract.md` §12 for the exact SQLite-versus-application
305
+ validation boundary, the `recursive_triggers` requirement, and the
306
+ privileged-writer trust limits.
307
+ - **Ownership interaction (Q5 formalized).** The current Owner — live,
308
+ expired, legacy, or clock-regressed — cannot be detached while the
309
+ ownership row references it; the way out is the existing release or
310
+ takeover. Detach never deletes or replaces ownership and never touches the
311
+ fencing high-water.
312
+ - **Validation.** `issueCapability` (Session-bound scopes), `bindSession`,
313
+ `acquireOwnership`, `takeoverOwnership`, `renewLease`, `releaseOwnership`,
314
+ `verifyOwnership`, and every Session read strictly validate the row: a
315
+ DETACHED Session refuses `SESSION_NOT_BOUND`; a malformed row refuses
316
+ `STORAGE_OWNERSHIP_CORRUPT`; re-binding a detached Session ID refuses as
317
+ before (`SESSION_ALREADY_BOUND` / `SESSION_BOUND_TO_OTHER_PARTICIPANT`).
318
+ - **Lifecycle read.** `readSessionLifecycle` is a private, host-local,
319
+ zero-write read over one snapshot returning `ATTACHED` / `DETACHED` /
320
+ `NOT_FOUND` (a foreign pair is `NOT_FOUND`); it validates the
321
+ Participant's ownership state in the same snapshot and authorizes nothing.
322
+ - **Remaining P2C work (P2C-B): implemented** by CE-0002-B3-P2C-B — see the
323
+ next section. P3 (send/ack/read, delivery) is not started.
324
+
325
+ ## B3-P2C-B — Authorized Session Detach (CE-0002-B3-P2C-B)
326
+
327
+ Status: **implemented**. Realizes the accepted P2C-D0 design decisions D4,
328
+ D7, D10, and D11 without changing any accepted boundary: schema v4 stays
329
+ frozen (no DDL, no new migration, no checksum change), no `detach` capability
330
+ scope exists, and Disconnect is not Detach — Detach is a durable binding
331
+ lifecycle fact of the private storage plane, never a transport event.
332
+
333
+ - **Operation (D4).** `detachSession(store, request)` performs the one-way
334
+ ATTACHED → DETACHED transition under the Session's own Session-bound
335
+ `resume` capability — self only. The capability must authorize exactly its
336
+ bound Session; a Session ID alone is never a credential. Inside one owned
337
+ BEGIN IMMEDIATE transaction, in order: transaction-time clock (sampled
338
+ after the writer lock is acquired), capability validation (trust
339
+ generation, expiry, revocation, Session binding), Participant identity,
340
+ Session binding, complete ownership-state validation, the
341
+ current-Owner refusal, the clock-regression refusal, and the guarded
342
+ `detached_at IS NULL AND private_revision = 1` → `detached_at = now,`
343
+ `private_revision = 2` update with exactly one changed row.
344
+ - **Ownership interaction (Q5).** The current Owner — live, expired, legacy
345
+ (NULL `lease_started_at`), or clock-regressed — refuses
346
+ `SESSION_IS_CURRENT_OWNER` while the ownership row references its Session,
347
+ regardless of lease expiry. The way out is the existing release (live
348
+ Owners only; Q4 preserved: an expired Owner cannot release) or an
349
+ authorized takeover, after which the superseded former Owner detaches
350
+ freely. Detach never modifies the ownership row, never touches the fencing
351
+ high-water, never deletes the Session row, and never mutates capability
352
+ rows. Concurrent release / takeover / renew outcomes are derived from the
353
+ serialized durable state; the real-process lock-wait scenarios F and G pin
354
+ the serialized boundaries.
355
+ - **Capability lifecycle (D7).** Session lifecycle validation is centralized
356
+ in capability classification: a Session-bound capability whose bound
357
+ Session is DETACHED is unusable for every presenter. The persisted
358
+ `detached_at` is the lifecycle source of truth — read-time invalidation
359
+ only, no bulk revocation, no capability-row rewrites. Caller-visible
360
+ refusals remain the one opaque `CAPABILITY_REJECTED`; the host-local
361
+ status read identifies `SESSION_DETACHED`. The future Session-bound
362
+ scopes (`send`, `ack`, `read` — not implemented in this phase) inherit the
363
+ check unchanged when they join `SESSION_BOUND_CAPABILITY_SCOPES`. A
364
+ Session-bound capability whose binding row is missing or malformed refuses
365
+ `STORAGE_OWNERSHIP_CORRUPT` — never adopted, never repaired. Deliberate
366
+ error-precedence change (documented, pinned): for a DETACHED Session, the
367
+ capability-plane refusal now outranks the operation-level
368
+ `SESSION_NOT_BOUND` that `renewLease` / `releaseOwnership` /
369
+ `verifyOwnership` / `takeoverOwnership` previously surfaced; `attach`
370
+ (not Session-bound) still refuses `SESSION_NOT_BOUND` at the operation.
371
+ Revoking a detached Session's capability refuses exactly like revoking an
372
+ expired one.
373
+ - **Recovery.** `reconcileDetach(store, evidence)` is the private, read-based,
374
+ zero-write, fail-closed reconciliation after an uncertain outcome (failure
375
+ before or after COMMIT, lost response, BUSY, ROLLED_BACK, INDETERMINATE):
376
+ COMMITTED / NO_TRACE / EVIDENCE_CONFLICT classified from the durable
377
+ Session state in one snapshot, with ownership coherence validated in the
378
+ same snapshot (concurrent takeovers and releases are simply part of the
379
+ observed state). The evidence `detachedAt` is optional: with it, an exact
380
+ match is COMMITTED and any mismatch is EVIDENCE_CONFLICT (operator
381
+ review); without it, a DETACHED row is COMMITTED because the terminal
382
+ durable effect exists. The durable effect is idempotent; the API response
383
+ is not — a retry of a committed detach refuses `CAPABILITY_REJECTED` — so
384
+ the discipline is reconcile first, never blindly retry, and a fresh
385
+ attempt after NO_TRACE is a new full authorization, not a replay. No
386
+ exactly-once execution is claimed. `reconcileTakeover` is unchanged.
387
+ - **Lease policy (D10/D11).** The host owns lease policy: default TTL 120 s,
388
+ accepted range 30–3600 s, renewal recommendation 30 s. An adapter may
389
+ REQUEST a TTL per operation (`leaseTtlSeconds`); the request only computes
390
+ the new expiry at acquisition or renewal and never reinterprets a stored
391
+ term. No policy is persisted — there is no policy table and none is
392
+ planned for this phase. Hosts should schedule renewal well before expiry
393
+ using the `expiresAt` returned by acquisition and renewal (and re-derive
394
+ the schedule from each renewal's returned `expiresAt`), because an
395
+ operation serialized after expiry refuses `LEASE_EXPIRED` and cannot
396
+ revive the term; the persisted `lease_started_at` / `expires_at` boundaries
397
+ always decide.
398
+
399
+ ## B3-P3A — Scoped current-Owner authority
400
+
401
+ Status: **implemented for private storage send/read only**. The historical P2
402
+ sections above remain the record of their phase boundaries; where they say
403
+ `send`/`read` were inactive, this P3A amendment supersedes that status.
404
+
405
+ `send` and `read` are now operational and are Session-bound exactly like
406
+ `resume`/`takeover`: issuance requires a durable ATTACHED Session binding,
407
+ classification binds Participant + exact scope + trust generation + Session,
408
+ revocation presents that Session, and terminal detach makes the token unusable
409
+ without rewriting capability rows. `ack` remains inactive.
410
+
411
+ The shared current-Owner primitive defaults to `resume` for all accepted P2
412
+ renew/release/verification callers, preserving their API and refusal precedence.
413
+ P3A callers explicitly request only `send` or `read`; neither a resume token nor
414
+ a takeover token authorizes those operations. Inside the caller-owned IMMEDIATE
415
+ transaction, capability classification precedes Participant/Session checks, then
416
+ the durable ownership row establishes the current Owner, the presented fencing
417
+ token must equal the persisted monotonic token/high-water state, and the
418
+ persisted lease term must be active without clock regression.
419
+
420
+ P3A samples authority time only after the writer lock is acquired. A send racing
421
+ release serializes either before release and may commit, or after release and
422
+ refuses `OWNERSHIP_NOT_CURRENT`. A near-expiry send racing takeover similarly
423
+ commits only before replacement or refuses as the former Owner afterward; it
424
+ cannot commit under stale fencing. Independent-process tests pin both outcomes.
425
+
426
+ This amendment does not broaden takeover recovery, define Pi Session lifecycle
427
+ outside the existing durable binding, or grant authority from a Session ID,
428
+ Participant ID, Worker/Advisor label, endpoint label, or fencing token alone.
429
+ ACK and external delivery remain outstanding P3 work.