@astrosheep/square 0.3.72 → 0.3.74

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 (59) hide show
  1. package/dist/artifact.d.ts +0 -3
  2. package/dist/artifact.js +5 -59
  3. package/dist/boundary-presentation.d.ts +8 -1
  4. package/dist/boundary-presentation.js +8 -1
  5. package/dist/catch-decisions.d.ts +7 -1
  6. package/dist/catch-decisions.js +21 -8
  7. package/dist/claude-delivery.d.ts +1 -1
  8. package/dist/claude-delivery.js +7 -3
  9. package/dist/cli/context.d.ts +1 -1
  10. package/dist/cli/ledger-command.d.ts +12 -0
  11. package/dist/cli/ledger-command.js +68 -0
  12. package/dist/cli/registry.js +2 -0
  13. package/dist/help.js +7 -2
  14. package/dist/host-ledger-file-adapter.d.ts +4 -10
  15. package/dist/host-ledger-file-adapter.js +238 -198
  16. package/dist/host-ledger-migration.d.ts +26 -0
  17. package/dist/host-ledger-migration.js +227 -0
  18. package/dist/host-ledger-sqlite.d.ts +62 -0
  19. package/dist/host-ledger-sqlite.js +341 -0
  20. package/dist/host-ledger.d.ts +13 -4
  21. package/dist/inbox.d.ts +2 -0
  22. package/dist/inbox.js +64 -22
  23. package/dist/ledger-maintenance.d.ts +30 -0
  24. package/dist/ledger-maintenance.js +110 -0
  25. package/dist/mcp.d.ts +2 -1
  26. package/dist/mcp.js +51 -26
  27. package/dist/model.js +5 -2
  28. package/dist/open-square.d.ts +12 -2
  29. package/dist/open-square.js +42 -4
  30. package/dist/participant-host.js +6 -2
  31. package/dist/ports.d.ts +17 -1
  32. package/dist/runtime.d.ts +2 -1
  33. package/dist/runtime.js +12 -6
  34. package/dist/sqlite-transaction.d.ts +5 -0
  35. package/dist/sqlite-transaction.js +72 -0
  36. package/dist/square-actions.d.ts +3 -1
  37. package/dist/square-actions.js +26 -13
  38. package/dist/square-application.d.ts +4 -0
  39. package/dist/square-application.js +41 -27
  40. package/dist/square-facade.d.ts +26 -22
  41. package/dist/square-migration.js +2 -1
  42. package/dist/square-sqlite.d.ts +2 -2
  43. package/dist/square-sqlite.js +15 -4
  44. package/dist/views.d.ts +3 -1
  45. package/dist/views.js +37 -29
  46. package/guides/application-demand-optimization.md +50 -0
  47. package/guides/catch-cold-path-optimization.md +56 -0
  48. package/guides/catch-contention-performance.md +157 -0
  49. package/guides/catch-lock-investigation.md +81 -0
  50. package/guides/change-waits.md +1 -1
  51. package/guides/host-ledger.md +53 -6
  52. package/guides/indexed-host-ledger-performance.md +39 -0
  53. package/guides/mcp-connection-lifetime.md +22 -0
  54. package/guides/pi-boundary-pending-reuse.md +13 -0
  55. package/package.json +1 -1
  56. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  57. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  58. package/plugins/paseo/client/square-message.tsx +35 -19
  59. package/plugins/pi/square-pi.js +4 -8
package/dist/views.js CHANGED
@@ -28,7 +28,7 @@ export function parseRequiredActivityId(id) {
28
28
  return index;
29
29
  }
30
30
  /** Compile the public query directly to typed selectors; only search hydrates unselected bodies. */
31
- export function selectHistory(facts, query, limit, kinds) {
31
+ export function selectHistory(facts, query, limit, kinds, presentation = true) {
32
32
  if (query.before !== undefined && query.after !== undefined)
33
33
  throw new SquareError('invalid_args', 'History cannot combine before and after cursors');
34
34
  const afterIndex = query.after === undefined ? undefined : parseRequiredActivityId(query.after);
@@ -44,14 +44,16 @@ export function selectHistory(facts, query, limit, kinds) {
44
44
  ...(query.at?.length ? { indexes: facts.contextIndexes(query.at.map(parseRequiredActivityId), query.contextBefore ?? 0, query.contextAfter ?? 0) } : {}),
45
45
  kinds: search === undefined ? kinds : ['say', 'done'],
46
46
  };
47
- let selected, totalMatches, publicBefore, publicAfter;
47
+ let selected, totalMatches, publicBefore = false, publicAfter = false;
48
48
  if (recipient === undefined && search === undefined && limit !== null && limit <= PAGE_SIZE) {
49
49
  selected = facts.activityHeads(filter, { edge: afterIndex === undefined ? 'newest' : 'oldest', limit }).items;
50
- const publicHeads = selected.filter((head) => ARCHIVE.has(head.kind));
51
- const publicFilter = { ...filter, kinds: [...ARCHIVE] };
52
50
  totalMatches = facts.activityCount(filter);
53
- publicBefore = publicHeads.length > 0 && facts.activityCount({ ...publicFilter, beforeIndex: publicHeads[0].index }) > 0;
54
- publicAfter = publicHeads.length > 0 && facts.activityCount({ ...publicFilter, afterIndex: publicHeads.at(-1).index }) > 0;
51
+ if (presentation) {
52
+ const publicHeads = selected.filter((head) => ARCHIVE.has(head.kind));
53
+ const publicFilter = { ...filter, kinds: [...ARCHIVE] };
54
+ publicBefore = publicHeads.length > 0 && facts.activityCount({ ...publicFilter, beforeIndex: publicHeads[0].index }) > 0;
55
+ publicAfter = publicHeads.length > 0 && facts.activityCount({ ...publicFilter, afterIndex: publicHeads.at(-1).index }) > 0;
56
+ }
55
57
  }
56
58
  else {
57
59
  const page = [];
@@ -68,7 +70,7 @@ export function selectHistory(facts, query, limit, kinds) {
68
70
  if (matches !== undefined && !matches.has(head.index))
69
71
  continue;
70
72
  totalMatches++;
71
- if (ARCHIVE.has(head.kind)) {
73
+ if (presentation && ARCHIVE.has(head.kind)) {
72
74
  firstPublic = Math.min(firstPublic, head.index);
73
75
  lastPublic = head.index;
74
76
  }
@@ -82,14 +84,16 @@ export function selectHistory(facts, query, limit, kinds) {
82
84
  cursor = source.items.at(-1).index;
83
85
  }
84
86
  selected = page;
85
- const publicHeads = selected.filter((head) => ARCHIVE.has(head.kind));
86
- publicBefore = publicHeads.length > 0 && firstPublic < publicHeads[0].index;
87
- publicAfter = publicHeads.length > 0 && lastPublic > publicHeads.at(-1).index;
87
+ if (presentation) {
88
+ const publicHeads = selected.filter((head) => ARCHIVE.has(head.kind));
89
+ publicBefore = publicHeads.length > 0 && firstPublic < publicHeads[0].index;
90
+ publicAfter = publicHeads.length > 0 && lastPublic > publicHeads.at(-1).index;
91
+ }
88
92
  }
89
93
  const activities = [...facts.activities(selected.map((head) => head.index))];
90
94
  if (query.order === 'desc')
91
95
  activities.reverse();
92
- const visibleActivities = activities.filter((activity) => ARCHIVE.has(activity.kind));
96
+ const visibleActivities = presentation ? activities.filter((activity) => ARCHIVE.has(activity.kind)) : [];
93
97
  const direction = afterIndex === undefined ? 'before' : 'after';
94
98
  const cursor = visibleActivities.length === 0 ? undefined : direction === 'before'
95
99
  ? Math.min(...visibleActivities.map((activity) => activity.index)) : Math.max(...visibleActivities.map((activity) => activity.index));
@@ -156,9 +160,9 @@ function classifyPresence(participants, now) {
156
160
  classificationByName: Object.fromEntries(participants.map((participant) => [participant.name, participant.state === 'done' ? 'done'
157
161
  : participant.state === 'not joined' ? 'not-joined' : hereNames.has(participant.name) ? 'here' : 'lingering'])) };
158
162
  }
159
- export function queryParticipants(facts, context) {
163
+ export function queryParticipants(facts, context, presentation = true) {
160
164
  const participants = coreParticipants(facts, context.now);
161
- return { data: { participants }, projection: { ...context, ...classifyPresence(participants, context.now),
165
+ return { data: { participants }, projection: !presentation ? undefined : { ...context, ...classifyPresence(participants, context.now),
162
166
  standingCount: inSquareCount(facts), held: facts.hold().active } };
163
167
  }
164
168
  export async function listPresentation(square, control) {
@@ -170,30 +174,34 @@ export async function listPresentation(square, control) {
170
174
  return { context: facts.header().preamble, participants, activities: facts.activityCount({ kinds: ['say'] }) };
171
175
  }, control?.signal)).value;
172
176
  }
173
- export function queryStatus(facts, context, viewer = '') {
174
- const { data: { participants }, projection } = queryParticipants(facts, context);
175
- const header = facts.header(), hold = facts.hold();
177
+ export function queryStatus(facts, context, viewer = '', presentation = true) {
178
+ const participants = coreParticipants(facts, context.now);
179
+ const header = facts.header(), hold = facts.hold(), standingCount = inSquareCount(facts);
176
180
  const latest = facts.activityHeads({ kinds: ['say', 'done'] }, { edge: 'newest', limit: 1 }).items[0];
177
181
  const latestAct = latest === undefined ? undefined : facts.activity(latest.index);
182
+ const data = {
183
+ hardCap: header.hardCap,
184
+ ...(header.throttlePerMinute === undefined ? {} : { throttlePerMinute: header.throttlePerMinute }),
185
+ activeCount: standingCount, doneCount: participants.filter((p) => p.state === 'done').length,
186
+ hold: hold.active ? {
187
+ ...(hold.actor === undefined ? {} : { by: hold.actor }),
188
+ ...(hold.reason === undefined ? {} : { reason: hold.reason }),
189
+ ...(hold.at === undefined ? {} : { at: hold.at }),
190
+ } : null,
191
+ participants: participants.filter((p) => p.state !== 'not joined'), now: context.now,
192
+ ...(latestAct === undefined || latestAct.actor === undefined || latestAct.kind === 'read' ? {} : { latestActivity: toPublicActivity(latestAct) }),
193
+ };
194
+ if (!presentation)
195
+ return { data, projection: undefined };
178
196
  const afterIndex = latestAct?.index ?? -1;
179
197
  const joins = facts.activities(facts.activityHeads({ kinds: ['join'], afterIndex }, { edge: 'newest', limit: 3 }).items.map((h) => h.index)).map((activity) => ({ activity,
180
198
  rejoined: facts.activityCount({ kinds: ['done'], actors: [activity.actor], actorMatch: 'exact', beforeIndex: activity.index }) > 0 }));
181
199
  const canonicalViewer = facts.participant(viewer)?.name ?? viewer;
182
200
  return {
183
- data: {
184
- hardCap: header.hardCap,
185
- ...(header.throttlePerMinute === undefined ? {} : { throttlePerMinute: header.throttlePerMinute }),
186
- activeCount: projection.standingCount, doneCount: participants.filter((p) => p.state === 'done').length,
187
- hold: hold.active ? {
188
- ...(hold.actor === undefined ? {} : { by: hold.actor }),
189
- ...(hold.reason === undefined ? {} : { reason: hold.reason }),
190
- ...(hold.at === undefined ? {} : { at: hold.at }),
191
- } : null,
192
- participants: participants.filter((p) => p.state !== 'not joined'), now: context.now,
193
- ...(latestAct === undefined || latestAct.actor === undefined || latestAct.kind === 'read' ? {} : { latestActivity: toPublicActivity(latestAct) }),
194
- },
201
+ data,
195
202
  projection: {
196
- ...projection, knownParticipantCount: participants.length, viewer: canonicalViewer,
203
+ ...context, ...classifyPresence(participants, context.now), standingCount, held: hold.active,
204
+ knownParticipantCount: participants.length, viewer: canonicalViewer,
197
205
  ...(canonicalViewer === '' ? {} : { participant: canonicalViewer }),
198
206
  joins, joinCount: facts.activityCount({ kinds: ['join'], afterIndex }),
199
207
  attentionNames: participants.filter((p) => canonicalViewer === '' || nameKey(p.name) === nameKey(canonicalViewer)).map((p) => p.name),
@@ -0,0 +1,50 @@
1
+ # Application presentation demand
2
+
3
+ ## Design
4
+
5
+ `createSquareApplication(context)` retains complete terminal-facing outcomes. MCP uses `createSquareApplication(context, { presentation: false })`; its outcome has the same `data` contract and `projection: undefined`.
6
+
7
+ This is one explicit result demand, not a second business implementation. The application still owns admission, retries, receipts, cancellation and delivery. Ordinary early returns and conditional expressions skip presentation work where it was previously performed; there is no deferred projector, registry, cache or resource-lifetime change. CLI projection types remain required, while the data-only factory overload exposes their absence explicitly.
8
+
9
+ MCP no longer requests join history/perceptions, the pre-expression directed feed, catch presence enrichment, listener context reads, lifecycle event rereads, or history/status/participants presentation-only annotations. Selection/counts required for results and history limits remain. For terminal expressions the directed feed is still captured once **before** landing, not after sending or on each retry. Delivery and postcommit publication remain on the original path.
10
+
11
+ Two same-operation observations are consolidated:
12
+
13
+ - Catch resolves caller and all sender filters in one typed-fact read, preserving caller-first validation, canonical error subjects, filter order and known-but-departed senders. The actual selection/receipt transaction, ownership admission and lease fences remain separate and current.
14
+ - Expression's known-name check runs inside the existing join preview via an internal `requireKnown` admission option. Unknown names still fail before claiming or mutating; departed participants still reenter once. Ordinary join may still create a name. Preview facts never replace the subsequent ownership/CAS or commit-time checks.
15
+
16
+ Long-lived MCP connections and concurrent delivery are deliberately outside this change.
17
+
18
+ ## Local MCP measurement
19
+
20
+ Baseline: `31cc1bf`. Candidate: the presentation-demand and observation changes above. Node v26.10.0. A real MCP client used the SDK's in-memory transport, not a direct call to application helpers. Each call still creates its application and opens/closes the artifact.
21
+
22
+ Three runs per variant, alternating B1/A1/B2/A2/B3/A3. Each process seeds four participants and 120 2,048-byte historical says outside measurement, joins the probe participant, then runs three warmup cycles and twenty measured cycles of repeated join, express, two empty filtered catches, history and status. Harness session keys are cleared: the probe's `reconnect` label means a repeated no-op join (`kind: joined`), not a real harness ownership reconnect. New measured expressions are small (`probe body`) and use force without pending directed feed; catches are empty. This is not a bulk-payload, ownership-contention or concurrent benchmark. No test suite, build or second benchmark ran alongside the six probes.
23
+
24
+ Wall times below are the median of three run-level p50s, each from twenty measured calls. Small local timings are illustrative, not a production SLA or the earlier 300-process catch-p95 workload. The stable transaction/statement counts establish removed work more directly.
25
+
26
+ | MCP operation | Wall p50 before → after (ms) | Artifact read transactions/call | SQL prepares/call |
27
+ | --- | ---: | ---: | ---: |
28
+ | Repeated no-op join | 1.478 → 1.036 | 3 → 2 | 30 → 15 |
29
+ | Express | 3.963 → 3.372 | 6 → 4 | 79 → 61 |
30
+ | Empty catch, one sender filter | 3.157 → 1.613 | 5 → 3 | 124 → 40 |
31
+ | Empty catch, three sender filters | 3.335 → 1.655 | 7 → 3 | 134 → 42 |
32
+ | History | 1.582 → 0.808 | 2 → 2 | 52 → 16 |
33
+ | Status | 2.344 → 2.093 | 2 → 2 | 103 → 94 |
34
+
35
+ Sender lookups still scale with filter count; **transactions do not**. No participant validation has been omitted to obtain that result. Repeated join's body-hydrating row reads fall from ten to zero per call. History still hydrates its ten result rows, and both empty catches hydrate none. Express and status each retain one body-hydrating row in this probe; not all body reads are presentation work.
36
+
37
+ SQLite counters are attributed by canonical database location. Host-ledger/lock database work is counted separately, never attributed to the artifact. A positive calibration observes one port read as one artifact read transaction and `activities([0,1])` as two hydrated rows. History moves the same hydration counters used for the zero checks. Hydrated bytes in the probe mean returned string/blob bytes, not filesystem I/O.
38
+
39
+ All six probes passed their calibration, first-join, hydration and no-error controls: 720 measured calls plus six first joins have no recorded errors. Warmup results are not retained or included in that gate. Raw per-call records retain timing/counters/errors but not complete returned payloads; each operation also retains its first measured result summary. Public-data/error parity is established by regression tests, not by those no-error benchmark controls. Recorded compiled-module hashes were checked before and after each probe; the probe script itself was not included in those historical hash manifests. Independent review recomputed the raw timing/counter statistics and confirmed the stated measurement limits.
40
+
41
+ ## Regression evidence
42
+
43
+ - New tests prove MCP avoids the extra presentation reads, while terminal-facing join/express still hydrate real presentation content and express retains its pre-send feed.
44
+ - A body-bearing catch hydrates exactly its selected page; empty catch and archive/status/participants data parity remain intact.
45
+ - One versus multiple catch filters has the same read-transaction count; canonical caller/filter errors remain distinct.
46
+ - Both new tests fail against the unchanged baseline at the intended hydration/transaction assertions.
47
+ - The existing CLI/application/MCP activity/receipt parity, never-joined refusal, departed reentry, metadata ownership, cancellation and postcommit behavior tests pass.
48
+ - Full suite: 680 passed, one opt-in Claude runtime test skipped. CLI process suite: 48 passed. Independent static review accepted correctness and control-flow clarity.
49
+
50
+ Local evidence: `/tmp/square-upper-{before,after}-{1,2,3}.{json,log,exit,sha,integrity}`. Probe: `/tmp/square-mcp-demand-bench.mjs`. Test logs: `/tmp/square-upper-{tests,cli,negative}.log`.
@@ -0,0 +1,56 @@
1
+ # Catch cold-path optimization
2
+
3
+ ## Result and scope
4
+
5
+ Under the existing 300-process Pi-session workload, first-catch API p95 falls by **60.4% / 61.4%** for histories of 1,800 / 18,000 activities. These are reductions between the medians of three run-level p95s, not a guarantee for every run or operation. Whole catch-phase p95 falls by about **56%** for both histories.
6
+
7
+ The comparison is against `90752ba`, the current production implementation plus the same instrumentation, not an older, slower implementation. The benchmark represents Pi sessions without a Paseo route; it does not establish equal gains for Codex/Paseo sessions, emoji-only names, or native notification delivery.
8
+
9
+ ## Two changes
10
+
11
+ - `model.ts`: try the existing Unicode text-name grammar before the full RGI emoji grammar. It accepts letters, numbers, marks excluding variation selectors, hyphens, underscores, and nonempty slash-separated segments. It is an exact subset of the original grammar; the original full matcher remains the fallback. `NAME_GRAPHEME_SOURCE`, also used by mention scanning, retains its original value.
12
+ - `participant-host.ts`: before discovering installed adapters, use the existing route resolver to check whether any candidate exists. No host ledger or no possible candidate means no route, without importing adapters that cannot be used. Real candidates still pass through the real capability check. Paseo precedence, Codex fallback, harness identity, and publication failure isolation remain unchanged.
13
+
14
+ CPU sampling of the baseline pointed to cold name validation inside reader transactions and optional adapter module loading during catch. The changes remove that work rather than prewarm it or move it outside measurement. The sampling and this combined experiment do not establish each change's individual contribution.
15
+
16
+ SQLite storage, DELETE journaling, FULL synchronous durability, transaction validation, ownership/epoch fences, receipts, and retry policy are unchanged. Earlier lock/backoff/cache experiments did not show the required improvement and are not included. The earlier [COMMIT-gap investigation](catch-lock-investigation.md) measured preparation inside writers; it did not bound costs inside concurrent readers or optional postcommit module loading.
17
+
18
+ ## Controlled comparison
19
+
20
+ ```sh
21
+ node --import ./test/sandbox-env.js scripts/benchmark-idle-wait.mjs --workers 300 --mode session --active --no-hints
22
+ ```
23
+
24
+ Node v26.10.0. Three runs of each variant, ordered B1, A1, B2, A2, B3, A3. Each command covers both histories. Baseline worktree: `/tmp/square-half-baseline-final`, detached at `90752ba`; candidate: the main worktree with the two changes above. Benchmark scripts were byte-identical. The changed compiled modules and benchmark script were hashed before and checked after every command. No other benchmark, test suite, build, or profile ran alongside the sequence; read-only review and test-source editing did occur. This is a local, alternating experiment, not a randomized or controlled-host latency guarantee.
25
+
26
+ The benchmark workload is unchanged: 300 processes, 2,048-byte historical bodies, a five-second idle window, bell discovery without filesystem hints, then open, recognize, first catch, empty catch, and close. First-catch timing covers the complete participant catch API. Whole-phase timing includes all those catch-phase steps; it is not one catch call.
27
+
28
+ ### Median of three run-level measurements
29
+
30
+ | Metric | Before, 1,800 | After, 1,800 | Reduction | Before, 18,000 | After, 18,000 | Reduction |
31
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: |
32
+ | First catch API p95 (ms) | 3,923.86 | 1,554.02 | 60.4% | 3,568.86 | 1,378.74 | 61.4% |
33
+ | Whole catch phase p95 (ms) | 4,354.45 | 1,924.30 | 55.8% | 3,907.48 | 1,723.48 | 55.9% |
34
+ | Empty catch API p95 (ms) | 58.49 | 56.60 | 3.2% | 82.22 | 54.84 | 33.3% |
35
+ | Aggregate catch-phase CPU (ms) | 15,643.35 | 7,184.01 | 54.1% | 15,274.12 | 6,515.09 | 57.3% |
36
+
37
+ ### Individual first-catch p95s
38
+
39
+ | History | Before B1 / B2 / B3 (ms) | After A1 / A2 / A3 (ms) |
40
+ | --- | --- | --- |
41
+ | 1,800 | 4,044.22 / 3,404.54 / 3,923.86 | 1,544.62 / 1,554.02 / 1,720.00 |
42
+ | 18,000 | 3,024.11 / 3,568.86 / 3,759.82 | 1,343.00 / 1,824.53 / 1,378.74 |
43
+
44
+ The second large-history pair improves by 48.9%, so the evidence does **not** say every paired run exceeded 50%. Both median reductions exceed 50%, and every candidate first-catch p95 is below every baseline p95 in its history group.
45
+
46
+ ## Correctness evidence
47
+
48
+ All six commands exited successfully with empty stderr. Each of the twelve scenarios had 300 ready/completed workers and discovered all 300 participants. The raw per-worker reports were separately checked, including `remaining` (which the benchmark's built-in success gate does not assert): every worker consumed exactly one activity, had zero remaining, and consumed nothing again. That is 3,600 successful worker outcomes.
49
+
50
+ Every scenario retained 300 hydrated bodies, 600 SQL writes, 300 committed artifact writes and 1,800 committed artifact reads. The recorded COMMIT execution-plus-gap invariant held. No workload or instrumentation was removed to obtain the reduction.
51
+
52
+ Focused regression coverage preserves explicit expected acceptance/rejection for Unicode text, complete emoji, slash boundaries, selectors, joiners and malformed strings. A fresh-process module-loading test verifies that no-candidate drafting imports neither adapter, and that real Codex and Pi-plus-Paseo candidates still resolve through installed adapters.
53
+
54
+ `npm test` passed (678 passed, one opt-in runtime test skipped); the CLI process suite passed all 48 tests. Running the new module-loading test against the unchanged baseline fails at its zero-import assertion (two adapters loaded), providing a negative control. Rebuilding for regression tests preserved the measured production module hashes. An independent review recomputed p95s from raw worker timings and verified the reported medians and outcomes.
55
+
56
+ Local raw evidence: `/tmp/square-half-final-{before,after}-{1,2,3}.{json,err,exit,sha,integrity}`. Summary and per-worker assertion audit: `/tmp/square-half-final-summary.{py,json}`. The earlier pilot is excluded from the table.
@@ -0,0 +1,157 @@
1
+ # Catch contention: commit retries, empty reads, and shorter writes
2
+
3
+ A busy SQLite COMMIT retains the write transaction while waiting for readers to leave. Previously it slept 25 ms between attempts, the same as contenders waiting to acquire a transaction. The retained writer also prevents those contenders from progressing.
4
+
5
+ The change retries COMMIT after 1 ms while retaining the existing 25 ms acquisition backoff. It preserves the transaction, callback result, cancellation and rollback behavior. The shared helper applies to both artifact and host-ledger transactions. No journal mode, receipt semantics, workload, cache or background service changed.
6
+
7
+ ## Measurement
8
+
9
+ The benchmark now times opening, recognition, first catch, second catch and closing separately. Both baseline and candidate used this instrumentation and the same command:
10
+
11
+ ```sh
12
+ node --import ./test/sandbox-env.js scripts/benchmark-idle-wait.mjs --workers 300 --mode session --active --no-hints
13
+ ```
14
+
15
+ Three sequential runs per implementation, each covering both history sizes. Baseline production is `f44f359`; candidate changes only COMMIT retry timing. Baselines ran before candidates, not in randomized order. These local measurements show improvement but are not confidence intervals or a universal latency guarantee.
16
+
17
+ Values below are the **median of three run-level measurements**. For latency, each measurement is the p95 across 300 workers.
18
+
19
+ | Metric | Before, 1,800 history | After, 1,800 | Before, 18,000 | After, 18,000 |
20
+ | --- | ---: | ---: | ---: | ---: |
21
+ | First catch API p95 (ms) | 6,596.63 | 4,619.84 | 7,302.29 | 4,781.38 |
22
+ | Second catch API p95 (ms) | 3,620.93 | 2,413.91 | 4,034.01 | 2,613.97 |
23
+ | Whole catch phase p95 (ms) | 7,262.52 | 5,464.51 | 8,126.17 | 5,653.63 |
24
+ | Aggregate phase CPU (ms) | 23,466.80 | 20,205.76 | 27,313.38 | 21,241.62 |
25
+ | Artifact busy observations | 47,578 | 30,824 | 53,418 | 32,265 |
26
+ | Artifact COMMIT busy observations | 95 | 138 | 85 | 117 |
27
+
28
+ First-catch p95 medians fell approximately 30% and 35%; whole-phase medians fell 25% and 30%. Aggregate CPU medians fell 14% and 22%. COMMIT retry counts increased, as expected from polling the retained writer sooner, while total busy observations and CPU medians decreased.
29
+
30
+ Run-level first-catch p95s, in execution order:
31
+
32
+ | History | Before (ms) | After (ms) |
33
+ | --- | --- | --- |
34
+ | 1,800 | 6,119.17 / 6,596.63 / 6,680.30 | 4,619.84 / 4,233.23 / 6,642.77 |
35
+ | 18,000 | 7,785.32 / 7,302.29 / 5,966.01 | 4,781.38 / 4,995.59 / 4,694.29 |
36
+
37
+ The third small-history candidate run was slower than its other two runs and overlapped the baseline range. Its whole-phase p95 was 7,602.57 ms, above all three small-history baseline runs. Contention and machine-load variability remain; this change does not remove multi-second waiting under 300 simultaneous processes.
38
+
39
+ All twelve scenarios discovered 300 participants, consumed one activity each and consumed none again. All stderr files were empty. First-catch timing covers the full awaited API call, including ownership checks and postcommit identity publication. Whole-phase timing additionally includes open, recognize, the second catch and close; percentiles of individual steps must not be added.
40
+
41
+ Raw local reports: `/tmp/square-catch-baseline-{1,2,3}.json` and `/tmp/square-catch-commit-{1,2,3}.json`. These measure isolated local storage, not native delivery or ordinary single-user latency.
42
+
43
+ ## Correctness boundaries
44
+
45
+ Existing SQLite lifecycle tests exercise real blocked COMMITs, single callback execution, event-loop progress, cancellation rollback and cross-process receipt exclusion. Cancellation tests cover precommit rollback and postcommit success. The COMMIT-only measurements above retain the empty-catch write transaction. The subsequent optimization below measures its removal separately.
46
+
47
+ ## Follow-up: empty catches and repeated observation work
48
+
49
+ Ordinary non-ID, non-leased catches now inspect pending indexes in a read transaction. An empty page returns its complete observation from that snapshot without acquiring the artifact writer or hydrating bodies. A nonempty page is selected again inside the existing receipt transaction; the probe never decides which activities get consumed. Exact-ID and leased catches keep their existing write path. Ownership checks and identity publication remain in place.
50
+
51
+ Catch observations now reuse one roster and header for standing count and terminal status. Schema validation precomputes only the expected SQL constant; actual schema, identity, version, journal mode, and header checks still run inside each transaction. These three changes were measured together, so the results do not isolate each change's contribution.
52
+
53
+ The same benchmark command ran three times on baseline `03623cf`, then three times on the candidate, covering both histories each time. These are fresh baselines, separate from the earlier COMMIT experiment. No workload ran alongside the benchmarks. As above, ordering was sequential, not randomized, and results describe this local 300-process workload.
54
+
55
+ | Metric | Before, 1,800 history | After, 1,800 | Before, 18,000 | After, 18,000 |
56
+ | --- | ---: | ---: | ---: | ---: |
57
+ | First catch API p95 (ms) | 5,681.03 | 3,729.06 | 4,967.29 | 3,654.90 |
58
+ | Second, empty catch API p95 (ms) | 2,868.83 | 79.80 | 2,608.11 | 55.08 |
59
+ | Whole catch phase p95 (ms) | 6,315.96 | 4,111.88 | 5,701.10 | 3,887.74 |
60
+ | Aggregate phase CPU (ms) | 22,441.95 | 17,401.83 | 21,456.88 | 16,184.47 |
61
+ | Artifact busy observations | 34,542 | 18,780 | 32,986 | 18,638 |
62
+ | Artifact COMMIT busy observations | 137 | 323 | 148 | 287 |
63
+
64
+ Each cell is the median of three run-level measurements. Empty-catch p95 medians fell 97% and 98%; first-catch medians fell 34% and 26%; whole-phase medians fell 35% and 32%; CPU medians fell 22% and 25%. Total busy observations fell, while COMMIT busy observations increased. The extra read probe changes contention; fewer writer acquisitions do not imply every busy counter falls.
65
+
66
+ Run-level p95s in execution order:
67
+
68
+ | History | Call | Before (ms) | After (ms) |
69
+ | --- | --- | --- | --- |
70
+ | 1,800 | First | 7,655.42 / 4,857.49 / 5,681.03 | 4,274.17 / 3,729.06 / 3,557.04 |
71
+ | 18,000 | First | 4,967.29 / 5,029.40 / 4,895.89 | 5,005.24 / 3,356.44 / 3,654.90 |
72
+ | 1,800 | Empty | 4,495.46 / 2,348.03 / 2,868.83 | 56.35 / 79.80 / 82.83 |
73
+ | 18,000 | Empty | 3,139.28 / 2,506.64 / 2,608.11 | 53.31 / 55.08 / 56.97 |
74
+
75
+ The first large-history candidate's consuming catch overlaps the baseline range. Consuming catches still wait seconds under this load; this is not a guarantee of millisecond catch latency generally. The second catch measures an empty result immediately after consumption, not a long-running idle lease.
76
+
77
+ All twelve scenarios discovered 300 participants, consumed exactly one activity per worker, and consumed none again: 3,600 successful worker results. Each scenario hydrated 300 bodies and issued 600 artifact SQL writes. Empty baseline transactions already wrote no rows; the improvement removes writer acquisition, not receipt writes. Successful artifact transactions per scenario increase from 1,800 to 2,100 because nonempty catches add a read probe. All stderr files were empty.
78
+
79
+ Focused tests exercise empty snapshot completeness, absence of writer acquisition/body hydration/receipts, a competing consumer between probe and write selection, exact-ID repetition, lease fencing, validation, cancellation, and ownership publication. The existing performance assertion now checks that an empty catch makes no hydration request.
80
+
81
+ Raw local reports: `/tmp/square-catch-empty-before-{1,2,3}.json` and `/tmp/square-catch-empty-after-{1,2,3}.json`. The final cap-first short circuit in `watchTerminalStatus()` restores its earlier read behavior; it is used by lease pulses and lies outside this benchmark's non-idle path.
82
+
83
+ ## Follow-up: shortening consuming transactions
84
+
85
+ The next candidate adds transaction-local participant counts instead of decoding the whole roster for catch/terminal summaries. Latest-say ordinals use the existing participant lifetime count when the selected activity is that actor's latest say; older activities retain the indexed prefix count. Artifact acquisition also passes its already-validated header to the transaction callback instead of reading and decoding it again. Schema validation still occurs inside every transaction. There is no persistent schema change or cross-transaction cache.
86
+
87
+ The benchmark now measures explicit transaction boundaries per connection and separates artifact from host-ledger connections:
88
+
89
+ - Acquisition: first BEGIN attempt to successful BEGIN return, including retry gaps.
90
+ - Hold: successful BEGIN return to first COMMIT attempt, including validation, decoding and off-CPU wall time.
91
+ - Commit: first COMMIT attempt to successful COMMIT return, including retries.
92
+
93
+ Only committed transactions contribute timing samples. Rollback resets the sample; unsuccessful read acquisition/validation retries are visible in counts but do not become committed timings. `BEGIN DEFERRED` returning does not imply a read lock has already been acquired. Concurrent read durations cannot be summed into a serial lock-occupancy number. Batched transaction controls without observable inner boundaries are counted separately and excluded from exact timings. Artifact SQL execution durations exclude preparation and JavaScript decoding. Bounded self-tests exercise busy BEGIN, busy COMMIT, rollback/reset and origin separation.
94
+
95
+ Fresh baseline `13d1a36` and candidate each ran three times with the same new instrumentation and the command above, both histories per run. Baselines ran first; no test suite or second benchmark ran alongside the measurements. These changes were measured together, not as independent experiments.
96
+
97
+ | Metric | Before, 1,800 history | After, 1,800 | Before, 18,000 | After, 18,000 |
98
+ | --- | ---: | ---: | ---: | ---: |
99
+ | First catch API p95 (ms) | 3,528.76 | 2,678.86 | 3,685.07 | 2,865.71 |
100
+ | Empty catch API p95 (ms) | 68.23 | 89.97 | 58.31 | 91.11 |
101
+ | Whole catch phase p95 (ms) | 3,831.22 | 2,998.22 | 4,165.47 | 3,177.27 |
102
+ | Write acquisition p95 (ms) | 2,739.46 | 1,950.57 | 2,961.04 | 2,257.01 |
103
+ | Write hold p95 (ms) | 8.33 | 5.32 | 10.05 | 5.88 |
104
+ | Write commit p95 (ms) | 4.15 | 3.81 | 4.36 | 4.24 |
105
+ | Aggregate phase CPU (ms) | 16,348.86 | 13,014.67 | 17,732.24 | 13,499.75 |
106
+ | Artifact busy observations | 16,201 | 13,081 | 20,023 | 12,508 |
107
+
108
+ Cells are medians of three run-level measurements. Each scenario has 300 successful consuming write transactions, so write percentiles span those 300 transactions. First-catch p95 medians fall 24% and 22%; whole-phase medians fall 22% and 24%; CPU falls 20% and 24%. Write acquisition falls 29% and 24%, and hold p95 falls 36% and 42%. Percentiles of these stages must not be added to reconstruct the API percentile.
109
+
110
+ Empty-catch p95 regresses by 22 ms and 33 ms (32% and 56%) in this contended workload. The faster consuming path does not improve every operation. The current measurements do not isolate the cause of this regression; empty catches still use a read transaction, and the 25 ms acquisition retry interval is unchanged. The consuming-call improvement outweighs it in whole-phase time, but the regression remains part of the result.
111
+
112
+ | History | Call | Before p95 runs (ms) | After p95 runs (ms) |
113
+ | --- | --- | --- | --- |
114
+ | 1,800 | First | 3,572.79 / 3,123.90 / 3,528.76 | 2,361.69 / 2,732.76 / 2,678.86 |
115
+ | 18,000 | First | 3,577.88 / 3,685.07 / 4,165.72 | 2,865.71 / 2,975.57 / 2,813.31 |
116
+ | 1,800 | Empty | 68.23 / 85.81 / 61.32 | 100.68 / 89.97 / 87.47 |
117
+ | 18,000 | Empty | 57.38 / 59.12 / 58.31 | 108.55 / 83.11 / 91.11 |
118
+
119
+ Every candidate first-catch p95 is below its history's baseline range, but these sequential local runs are not confidence intervals or a general latency guarantee. Acquisition still takes seconds near the tail under 300 simultaneous processes. Some individual hold/commit samples are much longer than their p95; wall-time instrumentation alone cannot attribute all of that time to SQL or process scheduling.
120
+
121
+ All twelve scenarios discovered 300 participants, consumed one activity each, and consumed none again: 3,600 successful worker results. Each scenario retains 300 hydrated bodies, 600 SQL writes, 300 committed artifact writes and 1,800 committed artifact reads. All stderr files were empty. Tests cover aggregate read-your-writes and rollback, known-but-not-standing peers, missing/done caller quorum, cap precedence, and ordinal numbering across aliases, gaps, rejoins and subsequent writes.
122
+
123
+ Raw local reports: `/tmp/square-catch-positive-before-{1,2,3}.json` and `/tmp/square-catch-positive-after-{1,2,3}.json`. Earlier reports lack these transaction timing fields and should not be mixed into this comparison.
124
+
125
+ ## Follow-up: spreading writer acquisition retries
126
+
127
+ Writer acquisition now uses a per-transaction backoff window of 25, 50, then at most 100 ms. Each busy retry independently chooses an integer delay from 1 through that window. Read acquisition and other default retries remain fixed at 25 ms; a busy COMMIT still retries after 1 ms while retaining the writer. The shared helper applies this policy to artifact and host-ledger writes. Cancellation, rollback, synchronous callbacks, receipt selection and storage framing are unchanged.
128
+
129
+ This changes both retry frequency and synchronization, not jitter alone. At the cap the mean requested delay is 50.5 ms rather than 25 ms. The intent is to reduce contender overhead, not to pretend queue waiting is idle time: serial write hold plus commit already accounts for most of the previous catch duration. Wall-time measurements cannot prove how much of the benefit comes from scheduling or filesystem contention. There is no fairness guarantee; an individual contended retry can sleep longer than before. Uncontended attempts do not sleep.
130
+
131
+ Baseline `f004621` and the isolated writer-only candidate each ran three times with the same instrumentation and command above. Execution order was baseline 1, three rejected schema-normalization experiments, baselines 2–3, one broader retry pilot, then writer-only candidates 1–3. No build, test suite or second benchmark ran alongside a benchmark. This was not a randomized comparison; prior tables are separate experiments and should not be used as its baseline. The schema-normalization fast path showed no consistent benefit and was removed. The broader pilot is excluded from the results below.
132
+
133
+ | Metric | Before, 1,800 history | After, 1,800 | Before, 18,000 | After, 18,000 |
134
+ | --- | ---: | ---: | ---: | ---: |
135
+ | First catch API p95 (ms) | 2,942.43 | 2,696.24 | 3,314.33 | 2,703.95 |
136
+ | Empty catch API p95 (ms) | 114.52 | 83.35 | 107.99 | 85.06 |
137
+ | Whole catch phase p95 (ms) | 3,281.04 | 3,089.50 | 3,797.85 | 3,008.00 |
138
+ | Write acquisition p95 (ms) | 2,460.38 | 2,044.42 | 2,441.14 | 2,114.18 |
139
+ | Write hold p95 (ms) | 6.43 | 5.46 | 7.20 | 5.39 |
140
+ | Write commit p95 (ms) | 4.72 | 3.87 | 4.64 | 3.50 |
141
+ | Aggregate phase CPU (ms) | 14,791.25 | 12,797.97 | 16,050.74 | 12,994.48 |
142
+ | Artifact busy observations | 14,311 | 8,943 | 15,203 | 8,966 |
143
+
144
+ Cells are medians of three run-level measurements. First-catch p95 medians improve 8% and 18%, whole-phase medians 6% and 21%, and CPU 13% and 19%. Empty-catch p95 medians improve 27% and 21%; total busy observations fall 38% and 41%. Median run-level maximum write acquisition falls from 2,835.81 to 2,626.85 ms and from 3,064.84 to 2,656.63 ms; this is not a bounded maximum or a starvation guarantee.
145
+
146
+ | History | Call | Before p95 runs (ms) | After p95 runs (ms) |
147
+ | --- | --- | --- | --- |
148
+ | 1,800 | First | 3,518.32 / 2,942.43 / 2,797.10 | 2,479.32 / 2,696.24 / 2,829.63 |
149
+ | 18,000 | First | 2,791.81 / 3,314.33 / 3,457.89 | 2,576.67 / 2,759.20 / 2,703.95 |
150
+ | 1,800 | Empty | 114.52 / 114.76 / 100.27 | 83.35 / 84.59 / 82.22 |
151
+ | 18,000 | Empty | 118.99 / 107.99 / 63.28 | 108.44 / 85.06 / 61.79 |
152
+
153
+ Small-history consuming and whole-phase ranges overlap; large-history consuming ranges do not. Not every tail metric improves: the large-history median of each run's maximum COMMIT duration rises from 506.88 to 920.92 ms, despite the lower COMMIT p95. These local three-run results show lower contention overhead, not a universal latency improvement. Consuming catch still takes seconds near the tail with 300 simultaneous processes.
154
+
155
+ All twelve scenarios discovered 300 participants, consumed exactly one activity each and consumed none again: 3,600 successful worker results. Each retains 300 hydrated bodies, 600 SQL writes, 300 committed artifact writes and 1,800 committed artifact reads. All stderr files were empty. A new real-lock test checks cancellation before writer acquisition and successful single-callback execution on a subsequent transaction; existing tests retain busy COMMIT cancellation, same-result retries and cross-process receipt exclusion.
156
+
157
+ Raw local reports: `/tmp/square-lock-before-{1,2,3}.json` and `/tmp/square-lock-writer-{1,2,3}.json`. Discarded experiments are `/tmp/square-lock-schema-{1,2,3}.json` and `/tmp/square-lock-jitter-1.json`.
@@ -0,0 +1,81 @@
1
+ # Catch lock investigation: preparation, COMMIT retries, and WAL
2
+
3
+ ## Question and finding
4
+
5
+ Follow-up: [catch cold-path optimization](catch-cold-path-optimization.md) removes unnecessary name-validation and adapter-loading work without adopting WAL; its separate repeated comparison meets the 50% first-catch p95 target.
6
+
7
+ Does SQL compilation explain the multi-second queue under 300 processes, or is the critical path mainly lock occupancy?
8
+
9
+ For the current DELETE-journal implementation, preparation inside committed write transactions accounts for about 6% of their cumulative hold-plus-commit time. About 84% of COMMIT wall time occurs **between** synchronous COMMIT calls following BUSY, not inside SQLite calls. Compilation is not the leading measured direct contributor to writer occupancy.
10
+
11
+ An isolated WAL + FULL experiment removes observed COMMIT BUSY and retry gaps, but does not remove the overall latency problem. Pre-COMMIT hold time increases, ranges overlap, and one large-history WAL run regresses. Do not ship a journal-mode change on this evidence alone.
12
+
13
+ ## Measurement boundaries
14
+
15
+ `scripts/benchmark-idle-wait.mjs` now records, per connection/origin and transaction mode:
16
+
17
+ - `holdPrepareMs`: synchronous prepare-call wall time after successful BEGIN and before the first COMMIT attempt, including failed prepares.
18
+ - `commitExecMs`: synchronous COMMIT-call wall time, divided into successful, BUSY, and other-error calls.
19
+ - `commitGapMs`: time between the end of a COMMIT call and the start of its next attempt.
20
+ - `commitMs = commitExecMs + commitGapMs`, within rounding.
21
+
22
+ Only committed transactions contribute samples; rollback discards the transaction sample. Global prepare counters separately retain all attempts. The synchronous measurements include off-CPU time and wrapper overhead; they are **not CPU or fsync measurements**. Retry gaps include timers, scheduling, and caller work. These measurements cannot distinguish how long readers remain active from how long the writer waits to be scheduled again. A preparation share is not an upper bound on all indirect benefits of reducing preparation in concurrent readers.
23
+
24
+ In DELETE mode, a writer holding a RESERVED lock needs existing readers to release SHARED locks before it can obtain EXCLUSIVE for commit. BUSY establishes contention; the split establishes where elapsed time occurs. It does not establish that disk synchronization itself costs the full COMMIT wall time. See [SQLite locking](https://www.sqlite.org/lockingv3.html).
25
+
26
+ ## Controlled experiment
27
+
28
+ Production storage code is `a1bd798`; the concurrent UI-only commit `446e204` does not change this path. The temporary detached worktree `/tmp/square-wal-investigation` has exactly two production-line changes: new artifact creation requests WAL, and the row validator expects WAL. The host ledger remains DELETE. Both variants retain FULL synchronous writes and the same writer retry policy. This experiment supports **fresh artifacts only**, not migration of existing artifacts.
29
+
30
+ A disposable positive control confirmed WAL, synchronous=2 (FULL), wal_autocheckpoint=1000, and real `-wal`/`-shm` files. Benchmark scripts were byte-identical in both locations; production module hashes on the baseline matched before and after measurement.
31
+
32
+ ```sh
33
+ node --import ./test/sandbox-env.js scripts/benchmark-idle-wait.mjs --workers 300 --mode session --active --no-hints
34
+ ```
35
+
36
+ Three runs per variant, each covering histories of 1,800 and 18,000 activities. Execution order was D1, W1, D2, W2, D3, W3; no second benchmark or test suite ran alongside them. An initial raw-report audit ran during D2, so this was not a completely quiescent host. The order is alternating, not randomized; three local runs do not give a general latency guarantee. Both variants used the new instrumentation, so do not compare their absolute timings directly with previous instrumentation's reports.
37
+
38
+ ## Results
39
+
40
+ Each cell is the median of three run-level measurements. Writer timings below are cumulative across 300 committed write transactions, **not p95s**. Parent and child fields overlap; independently calculated medians need not add up.
41
+
42
+ | Metric | DELETE, 1,800 | WAL, 1,800 | DELETE, 18,000 | WAL, 18,000 |
43
+ | --- | ---: | ---: | ---: | ---: |
44
+ | First catch API p95 (ms) | 3,287.06 | 2,959.93 | 3,626.62 | 3,162.81 |
45
+ | Empty catch API p95 (ms) | 85.71 | 33.84 | 83.40 | 28.47 |
46
+ | Whole catch phase p95 (ms) | 3,797.29 | 3,255.36 | 4,104.10 | 3,513.85 |
47
+ | Writer hold before COMMIT, total (ms) | 2,137.47 | 2,483.41 | 1,556.86 | 2,872.93 |
48
+ | Preparation within writer hold, total (ms) | 215.62 | 589.84 | 218.35 | 276.82 |
49
+ | Writer COMMIT wall, total (ms) | 1,340.74 | 70.33 | 2,224.91 | 710.17 |
50
+ | Writer synchronous COMMIT calls, total (ms) | 204.95 | 70.33 | 305.45 | 710.17 |
51
+ | Writer inter-COMMIT gaps, total (ms) | 1,166.90 | 0 | 1,881.57 | 0 |
52
+ | Artifact COMMIT BUSY observations | 290 | 0 | 276 | 0 |
53
+ | Aggregate catch-phase CPU (ms) | 15,107.57 | 13,469.20 | 15,766.89 | 13,891.08 |
54
+
55
+ For DELETE, calculate shares within each run before taking their median:
56
+
57
+ - Held prepare / (hold + COMMIT): **6.49% / 6.26%** for small/large histories.
58
+ - Inter-call gap / COMMIT wall: **84.07% / 84.57%**.
59
+
60
+ WAL first-catch p95 medians are about 10% and 13% lower, but individual runs matter:
61
+
62
+ | History | DELETE first-catch p95 runs (ms) | WAL first-catch p95 runs (ms) |
63
+ | --- | --- | --- |
64
+ | 1,800 | 3,052.04 / 3,788.98 / 3,287.06 | 2,959.93 / 3,560.27 / 2,272.82 |
65
+ | 18,000 | 3,626.62 / 3,449.42 / 4,044.86 | 3,162.81 / **4,963.89** / 2,626.30 |
66
+
67
+ WAL removes the measured BUSY-commit mechanism but changes I/O, checkpointing, and reader concurrency too. The larger pre-COMMIT hold and variability are observations, not proof of any one scheduling cause. The second large-history WAL run is slower than every DELETE run. These results do not justify a claim of stable overall acceleration.
68
+
69
+ All twelve scenarios discovered 300 participants, consumed exactly one activity per participant, and consumed none again: 3,600 successful worker outcomes. Each scenario retained 300 hydrated bodies, 600 SQL writes, 300 committed artifact writes and 1,800 committed artifact reads. All six commands exited successfully with empty stderr. The expanded bounded instrumentation self-test passed, including real busy COMMIT splitting, failed preparation, origin separation, and rollback/reset.
70
+
71
+ Raw reports: `/tmp/square-journal-{delete,wal}-{1,2,3}.{json,err,exit}`. Local summary: `/tmp/square-journal-summary.mjs`. The temporary WAL changes are **not** in production.
72
+
73
+ ## What remains before any WAL adoption
74
+
75
+ - Existing DELETE V2 artifacts and old readers need an explicit compatibility/migration decision; the experimental validator simply refuses them.
76
+ - WAL contains committed state until checkpointed. Backup/move/recovery, alias paths and directory permissions must account for it. Standard WAL requires processes on the same host.
77
+ - Observe long readers, WAL size, checkpoint timing and recovery. This short benchmark does not measure long-lived WAL growth or a final last-connection checkpoint; its parent artifact connection remains open. `artifactBytes` counts only the main file, not sidecars.
78
+ - `list.ts` uses main-file mtime for `lastActiveAt`; WAL commits need not update it. The existing file watcher does recognize `-wal`, but a `--no-hints` benchmark does not validate native notifications.
79
+ - Retain FULL durability. WAL + NORMAL would change power-loss guarantees and is not an equivalent performance comparison.
80
+
81
+ See [SQLite WAL](https://www.sqlite.org/wal.html) for concurrency, checkpointing, durability and operational tradeoffs. Next investigation should target why read transactions and scheduling prolong the retry gaps, or reduce redundant transaction/validation work, rather than assume compilation or a journal-mode toggle alone resolves the queue.
@@ -4,7 +4,7 @@ Square's artifact and host-ledger adapters own change detection. Notifications a
4
4
 
5
5
  - A process shares a revision reader per canonical artifact and a filesystem watcher per containing directory. Subscribers have independent baselines, deadlines, and cancellation. The final release closes the watcher, reconciliation timer, and any outstanding read.
6
6
  - Directory notifications invalidate lightweight reads. A two-second reconciliation checks revisions/ledger metadata even when notifications are missing. Missing directories, unsupported watches, and watcher failures retain that fallback; directory replacement is detected during reconciliation. Normal notification latency is not a hard guarantee: a missed hint may wait for reconciliation, plus scheduling and lock contention.
7
- - The artifact revision remains authoritative. No new sidecar, broker, or persistent cache is introduced. Changed projections read typed recipient facts and selected bodies, not the whole historical state. A missing artifact is a stable observation, not a repeated change: reconciliation remains quiet while it stays absent and detects its return without deleting its session bindings. Strict artifact reads still report absence as an error. Ledger metadata invalidates bindings and presentation projections; it is never treated as evidence of delivery.
7
+ - The artifact revision remains authoritative. No new sidecar, broker, or persistent cache is introduced. Changed projections read typed recipient facts and selected bodies, not the whole historical state. A missing artifact is a stable observation, not a repeated change: reconciliation remains quiet while it stays absent and detects its return without deleting its session bindings. Strict artifact reads still report absence as an error. Host-ledger database metadata invalidates a process-shared read of its separate presence/evidence revisions; unchanged metadata requires no SQLite open. Revisions are read together in one short snapshot and cached against metadata captured before that read, so a concurrent mutation remains detectable. Failed reads are not cached. Missing ledger storage is stable until it appears. Neither metadata nor revisions are delivery evidence.
8
8
  - Each Pi session owns a long-lived pending observer, including when it has no bindings yet. Presence and evidence changes update its subscriptions. There is no repeated thirty-second restart and no per-second binding scan. A failed injection waits for a substantive change; its own failed evidence write does not trigger a retry loop.
9
9
  - Blocking CLI catch waits for a revision or its next heartbeat/quiet deadline. A held catch keeps its lease alive and pauses quiet expiry. Core catch, NDJSON stream, and delivery-receipt waits use the same change detector. Held express also waits for changes; throttling still waits for its eligibility time.
10
10
  - Catch lease expiry, ledger retention/claim expiry, and cancellation are independent wake conditions. No filesystem write is required for a time-based eligibility change.
@@ -1,11 +1,58 @@
1
- # Host Ledger Configuration
1
+ # Host ledger
2
2
 
3
- Square uses one user-level host ledger for participant bindings, delivery evidence, and wake attempts.
3
+ Square uses one user-level `host-ledger.sqlite` for participant bindings, delivery evidence, and wake attempts. It is separate from every `.square` artifact: the ledger owns host identity, ownership epochs, discovery and delivery evidence; the artifact owns square activity and participant lifecycle.
4
4
 
5
- One wake attempt is one `evidence.ndjsonl` row: it carries the attention-wide exclusion for a recipient and activity, and the session-scoped history for that attempt. A row is `claimed` before any send and `dispatching` from the moment a send may happen; a stale `claimed` row is replaceable, while a stale `dispatching` row becomes `unknown` and forbids redispatch. Accepted and possibly-sent wake attempts stay readable at any age, and are eligible for explicit `gcEvidence` removal once their activity is no longer pending. Older Square versions kept a separate `wake-claims.ndjsonl`; that file is no longer read or written, and is left on disk untouched. Running old and new processes against the same ledger at once is unsupported: restart every host process together when upgrading.
5
+ The default root is `~/.square/host-ledger`. Tests and isolated harnesses may set `SQUARE_HOST_LEDGER_ROOT`; otherwise `SQUARE_REGISTRY` selects its containing directory. An explicit adapter `rootPath` takes precedence. These settings select a single root, not additional ledgers.
6
6
 
7
- The default root is `~/.square/host-ledger`. Tests and isolated harnesses may set `SQUARE_HOST_LEDGER_ROOT`; when it is absent, `SQUARE_REGISTRY` provides the test root. An explicit `rootPath` passed to a ledger adapter takes precedence over both environment defaults.
7
+ ## Storage and delivery
8
8
 
9
- The old `SQUARE_HOST_LEDGER_USER` and `SQUARE_HOST_LEDGER_LOCAL` settings, project-local `.square/host-ledger` fallback, scope arguments, and reconcile-based copying are removed. Existing local ledger files and directories are left untouched but are no longer read. Bindings that existed only in an old local ledger require the participant to rejoin or restart its host session.
9
+ SQLite uses DELETE journaling and FULL synchronous writes. Records retain original order and duplicates, with derived lookup indexes and separate presence/evidence revisions. Normal reads of an empty root create nothing. First mutation initializes storage only when neither legacy NDJSON file exists. Legacy-only storage refuses ordinary operation and requires explicit offline conversion; normal operation never dual-reads or automatically migrates.
10
10
 
11
- The host ledger remains separate from the `.square` artifact: it owns host identity, ownership epochs, discovery, and delivery evidence. The artifact remains authoritative for square activity and participant lifecycle.
11
+ One wake attempt is one evidence record. Acquisition creates `dispatching` directly, before a send may happen. An interrupted dispatch becomes `unknown`, which forbids redispatch; current token-fenced recovery may still accept a late native admission. Accepted and possibly-sent wake records remain readable at any age. Prepared Claude correlations also bypass age visibility and housekeeping across every outcome, including failed and released, until explicitly retired. Ordinary records retain their existing seven-day visibility horizon, with expired ordinary rows reclaimed only during actual mutations. There is no automatic maintenance GC or activity-id-only sweep; durable delivery evidence is retired only by the explicit, artifact-proven `square ledger gc` below.
12
+
13
+ ## Verify and convert offline
14
+
15
+ These commands need no participant or square location and use the resolved ledger root:
16
+
17
+ ```bash
18
+ square ledger verify
19
+ ```
20
+
21
+ Verification is read-only. It checks SQLite identity, version, schema, integrity, record codecs, derived indexes and revisions. With only legacy files, it scans both complete sources and reports accepted, ignored and rejected counts, readiness, and SHA-256 hashes of the original source bytes. Unsafe indexed records make readiness false without hiding the remaining counts or hashes; the CLI reports not ready and exits with status 2. With no files, it reports empty without creating a directory, database or lock. Output never includes record payloads, native credentials or endpoints.
22
+
23
+ To upgrade an NDJSON ledger:
24
+
25
+ 1. Stop **every** Square host using that ledger, including interactive harnesses and background agents. Do not run old and new hosts together.
26
+ 2. Keep a backup of the stopped legacy files.
27
+ 3. Run:
28
+
29
+ ```bash
30
+ square ledger verify
31
+ square ledger migrate
32
+ square ledger verify
33
+ ```
34
+
35
+ 4. Start all hosts with the updated Square version together, using the same root.
36
+
37
+ Migration does not stop or restart hosts. Its locks fence compatible writers but do not prove hosts are stopped. It acquires the ownership claim fence, then `presence.ndjsonl.lock`, then `evidence.ndjsonl.lock`.
38
+
39
+ An older SQLite ledger format is upgraded in place by the same `square ledger migrate` under the same stopped-hosts prerequisite and the same lock order. The upgrade preserves every record, duplicate, original order and both observer revisions. `square ledger verify` stays read-only: it refuses an older or unsupported database with the migrate command and never upgrades silently.
40
+
41
+ The importer preserves all ages, future records, duplicates, original order, native correlation and residual fields. It ignores malformed JSON, wrong versions and records without a numeric timestamp, as the old reader did, and reports those counts. Any valid version-1 record whose indexed fields are unsafe refuses the entire conversion rather than losing evidence. Refusal identifies only source kind, rejected count and first rejected line; no accepted subset is published. Legacy unknown wake sources are normalized once during import.
42
+
43
+ A temporary SQLite database is built and verified under the same root, then published without overwriting an existing destination. Legacy sources remain byte-for-byte untouched. A valid existing SQLite ledger is an idempotent no-op; an older SQLite ledger format is upgraded in place under the fence; invalid or unsupported destinations are refused without repair. There is no force option. On a reported failure, keep hosts stopped, resolve the cause and rerun verification/conversion. An abrupt process termination can leave an unpublished `.host-ledger-migrate-*` directory; after confirming no converter is running, it can be removed without touching the sources or published database.
44
+
45
+ Once SQLite exists, legacy files are not consulted. Keeping them does not provide a rollback path after new SQLite writes: reverting to them would lose ownership and possibly-sent evidence. For a live SQLite ledger, use SQLite backup facilities rather than copying only its main file.
46
+
47
+ ## Reclaim old evidence
48
+
49
+ ```bash
50
+ square ledger gc --dry-run
51
+ square ledger gc
52
+ ```
53
+
54
+ `gc` considers wake and presentation evidence at least seven days old whose attention is permanently closed, proven per row against the square artifact named in the row: the activity historically reached that participant, and either catch recorded an exact receipt or the participant's later rejoin moved past it. Rows still `dispatching`, lifecycle rows, rows with unusable coordinates and rows whose artifact is missing, invalid or unreadable are retained. Eligibility is a proof, not a promise: `retired` counts rows actually removed, and a row whose record changed after the proof, or whose Claude correlation cannot be retired safely, is retained instead. `--dry-run` proves the same set and changes neither the ledger nor any artifact.
55
+
56
+ Reclamation is counted in rows, not bytes. Deleted rows free storage for reuse but do not necessarily shrink the SQLite file immediately; `gc` never runs `VACUUM`. Retiring a prepared Claude correlation payload leaves only a permanent `(harness, session, token)` recognition record, without payload, endpoint, location, participant, epoch or timestamp. These records are absent from ordinary evidence and attempts and confer no admission, presentation or receipt authority. Any retired token vetoes the entire native frame, including a mixed live/retired frame. Total token count remains unbounded. There are no hooks, timers or install steps: maintenance runs only when asked.
57
+
58
+ The obsolete `SQUARE_HOST_LEDGER_USER` and `SQUARE_HOST_LEDGER_LOCAL` settings, project-local fallback and scope-based copying are not used. Older local files and `wake-claims.ndjsonl` remain untouched and are not imported. Bindings that existed only in those abandoned stores require their host to rejoin; do not discard the supported ledger to reset bindings.