@astrosheep/square 0.3.70 → 0.3.71

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 (115) hide show
  1. package/dist/activity-feed.d.ts +4 -5
  2. package/dist/activity-feed.js +14 -10
  3. package/dist/artifact.d.ts +26 -33
  4. package/dist/artifact.js +346 -317
  5. package/dist/attention-presentation.js +2 -9
  6. package/dist/automatic-session.js +6 -6
  7. package/dist/boundary-presentation.js +3 -3
  8. package/dist/catch-decisions.d.ts +19 -12
  9. package/dist/catch-decisions.js +32 -68
  10. package/dist/claude-delivery.js +7 -7
  11. package/dist/claude-mod.js +18 -7
  12. package/dist/cli/catch-presentation.d.ts +7 -0
  13. package/dist/cli/catch-presentation.js +55 -0
  14. package/dist/cli/context.d.ts +1 -1
  15. package/dist/cli/context.js +5 -23
  16. package/dist/cli/express-presentation.d.ts +10 -0
  17. package/dist/cli/express-presentation.js +196 -0
  18. package/dist/cli/maintenance-commands.d.ts +4 -0
  19. package/dist/cli/maintenance-commands.js +29 -6
  20. package/dist/cli/observation-commands.d.ts +7 -5
  21. package/dist/cli/observation-commands.js +275 -262
  22. package/dist/cli/program.d.ts +1 -1
  23. package/dist/cli/program.js +43 -56
  24. package/dist/cli/registry.js +2 -1
  25. package/dist/cli/square-commands.d.ts +3 -9
  26. package/dist/cli/square-commands.js +134 -255
  27. package/dist/codex-queue.js +4 -1
  28. package/dist/decisions.d.ts +26 -74
  29. package/dist/decisions.js +190 -335
  30. package/dist/delivery-health.js +13 -22
  31. package/dist/delivery-operations.d.ts +0 -11
  32. package/dist/delivery-operations.js +44 -33
  33. package/dist/delivery.d.ts +33 -32
  34. package/dist/delivery.js +70 -81
  35. package/dist/help.js +6 -1
  36. package/dist/inbox.d.ts +27 -0
  37. package/dist/inbox.js +55 -18
  38. package/dist/index.d.ts +2 -4
  39. package/dist/index.js +1 -1
  40. package/dist/list.d.ts +7 -1
  41. package/dist/list.js +49 -122
  42. package/dist/mcp.d.ts +1 -1
  43. package/dist/mcp.js +7 -37
  44. package/dist/model.d.ts +4 -44
  45. package/dist/model.js +1 -3
  46. package/dist/notifications.d.ts +1 -1
  47. package/dist/notifications.js +13 -7
  48. package/dist/open-square.d.ts +16 -2
  49. package/dist/open-square.js +40 -1
  50. package/dist/perception-projection.d.ts +6 -15
  51. package/dist/perception-projection.js +6 -26
  52. package/dist/plugins/opencode/delivery.js +9 -9
  53. package/dist/plugins/opencode/index.js +3 -3
  54. package/dist/ports.d.ts +156 -11
  55. package/dist/presentation-operations.d.ts +1 -1
  56. package/dist/presentation-operations.js +35 -44
  57. package/dist/presentation.d.ts +12 -19
  58. package/dist/presentation.js +33 -54
  59. package/dist/routes.d.ts +7 -14
  60. package/dist/routes.js +33 -62
  61. package/dist/runtime.d.ts +12 -39
  62. package/dist/runtime.js +33 -164
  63. package/dist/square-actions.d.ts +11 -21
  64. package/dist/square-actions.js +171 -271
  65. package/dist/square-application.d.ts +31 -55
  66. package/dist/square-application.js +637 -141
  67. package/dist/square-core.d.ts +3 -94
  68. package/dist/square-core.js +8 -353
  69. package/dist/square-domain.d.ts +24 -0
  70. package/dist/square-domain.js +77 -0
  71. package/dist/square-facade.d.ts +360 -9
  72. package/dist/square-migration.d.ts +24 -0
  73. package/dist/square-migration.js +211 -0
  74. package/dist/square-projections.d.ts +2 -42
  75. package/dist/square-projections.js +1 -37
  76. package/dist/square-sqlite.d.ts +59 -0
  77. package/dist/square-sqlite.js +702 -0
  78. package/dist/stream.d.ts +3 -10
  79. package/dist/stream.js +81 -52
  80. package/dist/views.d.ts +27 -95
  81. package/dist/views.js +217 -87
  82. package/dist/wake-eligibility.d.ts +15 -5
  83. package/dist/wake-eligibility.js +32 -16
  84. package/dist/wakes.d.ts +3 -3
  85. package/dist/wakes.js +31 -27
  86. package/guides/application-facade-delivery-evidence.md +200 -0
  87. package/guides/application-facade-design.md +95 -0
  88. package/guides/change-waits.md +4 -4
  89. package/guides/migration.md +77 -0
  90. package/guides/primitive-catch-consolidation.md +85 -0
  91. package/guides/primitive-history-consolidation.md +76 -0
  92. package/guides/primitive-status-consolidation.md +87 -0
  93. package/guides/primitive-storage-consolidation.md +128 -0
  94. package/guides/row-runtime-delivery-evidence.md +160 -0
  95. package/guides/row-storage-design.md +567 -0
  96. package/package.json +1 -1
  97. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  98. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  99. package/plugins/opencode/delivery.ts +11 -11
  100. package/plugins/opencode/index.ts +3 -3
  101. package/plugins/paseo/client/square-message.tsx +70 -52
  102. package/plugins/paseo/shared/message.ts +15 -0
  103. package/plugins/pi/square-pi.js +3 -0
  104. package/dist/activity.d.ts +0 -14
  105. package/dist/activity.js +0 -248
  106. package/dist/square-file-adapter.d.ts +0 -26
  107. package/dist/square-file-adapter.js +0 -85
  108. package/dist/square-state.d.ts +0 -6
  109. package/dist/square-state.js +0 -138
  110. package/dist/square-storage.d.ts +0 -14
  111. package/dist/square-storage.js +0 -188
  112. package/dist/square-wiring.d.ts +0 -33
  113. package/dist/square-wiring.js +0 -109
  114. package/dist/watch.d.ts +0 -8
  115. package/dist/watch.js +0 -252
@@ -3,212 +3,150 @@ import { isSquareError, nameKey, SquareError, validateName } from './model.js';
3
3
  import { participantIdentity } from './participant-identity.js';
4
4
  import { deliverPending } from './delivery-operations.js';
5
5
  import { decideCatch } from './catch-decisions.js';
6
+ import { inSquareCount } from './runtime.js';
6
7
  import { claimSessionParticipant, claimSessionTakeover, presenceEpoch, releaseSessionParticipantClaim, readParticipantOwner } from './registry.js';
7
8
  import { assertLiveOwner, ensureLocalPresence, identityRouteDraft, processIdentity, publishIdentityRoute, retireIdentityRoute } from './participant-host.js';
8
- import { applyWakeRouteToState, dropEndedSessionWakeRoutesFromState, dropParticipantWakeRoutesFromState, dropSessionWakeRoutesFromState, sessionCanEndParticipant } from './routes.js';
9
+ import { applyWakeRoute, dropEndedSessionWakeRoutes, dropParticipantWakeRoutes, dropSessionWakeRoutes, sessionCanEndParticipant } from './routes.js';
9
10
  import { parseRequiredActivityId, toPublicActivity } from './views.js';
11
+ /** Host enrichment is best effort after an authoritative artifact commit. */
12
+ async function publishCommittedPresence(square, name, epoch) {
13
+ await ensureLocalPresence(square, name, epoch).catch(() => undefined);
14
+ await publishIdentityRoute(square, name, epoch).catch(() => undefined);
15
+ }
10
16
  function throwIfAborted(control) { if (control?.signal?.aborted)
11
17
  throw control.signal.reason ?? new Error('Operation aborted'); }
12
- function exposeCaught(activity, perception) {
13
- const result = toPublicActivity(activity);
14
- if (perception === 'full' || !('body' in result))
15
- return { ...result, perception };
16
- const { body: _body, ...withoutBody } = result;
17
- return { ...withoutBody, perception };
18
- }
19
- export async function catchUp(square, name, options = {}, project, control) {
20
- const idle = options.idle ?? 0;
21
- if (!Number.isFinite(idle) || idle < 0)
22
- throw new SquareError('invalid_args', 'Catch idle duration must be a non-negative number');
23
- const deadline = Date.now() + idle;
24
- if (control?.signal?.aborted)
25
- throw control.signal.reason ?? new Error('Operation aborted');
26
- while (true) {
27
- if (!await assertLiveOwner(square, name)) {
28
- throw new SquareError('already_joined', `✕ ${participantIdentity(name)} already stands here — another session holds the name`);
29
- }
30
- const attempt = await square.artifact.transact((state, version) => {
31
- const decision = decideCatch(state, name, options, square.clock(), project);
32
- return { ...(decision.changed ? { state } : {}), result: { version, decision } };
33
- }, control?.signal);
34
- await ensureLocalPresence(square, name);
35
- await publishIdentityRoute(square, name);
36
- if (attempt.decision.delivered.length > 0 || idle === 0) {
37
- return {
38
- activities: attempt.decision.delivered.map((activity) => exposeCaught(activity, attempt.decision.perceptions.get(activity.index) ?? 'full')),
39
- consumedThrough: attempt.decision.consumedThrough,
40
- idleExpired: false,
41
- remaining: attempt.decision.remaining,
42
- };
43
- }
44
- const remaining = deadline - Date.now();
45
- if (remaining <= 0) {
46
- return { activities: [], consumedThrough: attempt.decision.consumedThrough, idleExpired: true, remaining: 0 };
47
- }
48
- // Publication above is a semantic no-op while the route and presence stay unchanged and
49
- // unexpired, so only a real external change can move the artifact version. Establish the
50
- // wait baseline after those self-side effects; if the version moved anyway (own refresh or
51
- // a racing external commit), re-snapshot instead of waiting on a stale baseline.
52
- const baseline = (await square.artifact.read(control?.signal)).version;
53
- if (baseline !== attempt.version)
54
- continue;
55
- if (!await square.artifact.changed(baseline, remaining, control?.signal)) {
56
- return { activities: [], consumedThrough: attempt.decision.consumedThrough, idleExpired: true, remaining: 0 };
57
- }
58
- }
59
- }
60
- function storeActs(state, acts) {
61
- const stored = [];
62
- for (const act of acts) {
63
- const item = { ...act, index: state.runtime.nextActIndex };
64
- state.runtime.nextActIndex += 1;
65
- state.acts.push(item);
66
- stored.push(item);
67
- }
68
- return stored;
69
- }
70
- function committedActivity(stored, verb) {
71
- const activity = stored[0];
72
- if (activity === undefined)
73
- throw new Error(`${verb} activity did not commit`);
74
- return activity;
18
+ export async function catchUp(square, name, options = {}, control, leaseId) {
19
+ throwIfAborted(control);
20
+ if (!await assertLiveOwner(square, name))
21
+ throw new SquareError('already_joined', `${participantIdentity(name)} already stands here — another session holds the name`);
22
+ const selected = (await square.artifact.transact((facts) => {
23
+ // Replacement and selection share the receipt transaction: an old waiter cannot
24
+ // consume a later activity between its heartbeat and its selection.
25
+ if (leaseId !== undefined && facts.lease(name)?.leaseId !== leaseId)
26
+ return null;
27
+ return decideCatch(facts, name, options, square.clock());
28
+ }, control?.signal)).value;
29
+ if (selected !== null)
30
+ await publishCommittedPresence(square, selected.participant);
31
+ return selected;
75
32
  }
76
33
  export async function join(square, name, control) {
77
- // Rejected validation must not perform an ownership claim.
78
34
  throwIfAborted(control);
79
35
  validateName(name);
80
36
  const hostLedger = square.hostLedger;
81
- const preview = await square.artifact.read(control?.signal);
82
- const previewDecision = decideJoin(preview.state, name, square.clock());
83
- if (previewDecision.joinAct === undefined) {
84
- // An active artifact participant is idempotent only for its current session.
85
- // A different owner must still pass through the registry CAS so it is refused
86
- // instead of silently reconnecting as a foreign session.
87
- if (hostLedger !== undefined) {
88
- const identity = processIdentity(square.env ?? process.env);
89
- const owner = await readParticipantOwner(square.location, previewDecision.joinedName, hostLedger);
90
- if (owner === undefined) {
91
- // Keep the claim path for an active artifact whose ledger owner is not
92
- // visible yet; concurrent callers must still serialize through CAS.
93
- }
94
- else if (owner.session !== identity.session) {
95
- // Continue into the ownership claim below; it will produce the stable
96
- // already_joined error without mutating the artifact.
97
- }
98
- else {
99
- return { name: previewDecision.joinedName, activity: null };
100
- }
101
- }
102
- else {
103
- return { name: previewDecision.joinedName, activity: null };
104
- }
37
+ const preview = (await square.artifact.read((facts) => ({
38
+ ...decideJoin(facts, name, square.clock()),
39
+ participantCount: facts.roster().filter((p) => p.joined || p.done).length,
40
+ }), control?.signal)).value;
41
+ if (preview.joinAct === undefined) {
42
+ if (hostLedger === undefined)
43
+ return { name: preview.joinedName, activity: null, participantCount: preview.participantCount, reconnected: false };
44
+ const identity = processIdentity(square.env ?? process.env);
45
+ const owner = await readParticipantOwner(square.location, preview.joinedName, hostLedger);
46
+ if (owner?.session === identity.session)
47
+ return {
48
+ name: preview.joinedName, activity: null, participantCount: preview.participantCount,
49
+ reconnected: identity.channel !== 'unknown',
50
+ };
51
+ // Missing or foreign owner must pass CAS, not become a silent reconnect.
105
52
  }
106
53
  let epoch;
107
54
  let ownershipClaim;
108
55
  if (hostLedger !== undefined) {
109
- const claim = await claimSessionParticipant(square.location, name, hostLedger, square.env ?? process.env, control?.signal);
110
- ownershipClaim = claim;
111
- epoch = claim?.epoch;
56
+ ownershipClaim = await claimSessionParticipant(square.location, name, hostLedger, square.env ?? process.env, control?.signal);
57
+ epoch = ownershipClaim?.epoch;
112
58
  }
113
59
  let committed;
114
60
  try {
115
- const now = square.clock();
116
- const route = await identityRouteDraft(square, name);
61
+ const now = square.clock(), route = await identityRouteDraft(square, name);
117
62
  throwIfAborted(control);
118
- committed = await square.artifact.transact((state) => {
119
- const decision = decideJoin(state, name, now);
120
- if (decision.joinAct === undefined) {
121
- return { result: { name: decision.joinedName, stored: null } };
63
+ committed = (await square.artifact.transact((facts) => {
64
+ const decision = decideJoin(facts, name, now);
65
+ if (decision.joinAct !== undefined) {
66
+ if (square.location !== undefined && square.location !== 'memory')
67
+ dropParticipantWakeRoutes(facts, square.location, decision.joinedName);
68
+ if (route !== undefined)
69
+ applyWakeRoute(facts, route, now);
122
70
  }
123
- if (square.location !== undefined && square.location !== 'memory')
124
- dropParticipantWakeRoutesFromState(state, square.location, decision.joinedName);
125
- if (route !== undefined)
126
- applyWakeRouteToState(state, route, now);
127
- return { state, result: { name: decision.joinedName, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
128
- }, control?.signal);
71
+ const stored = decision.joinAct === undefined ? null : facts.land(decision.joinAct);
72
+ return {
73
+ name: decision.joinedName, stored,
74
+ participantCount: facts.roster().filter((p) => p.joined || p.done).length,
75
+ };
76
+ }, control?.signal)).value;
129
77
  }
130
78
  catch (error) {
131
79
  if (hostLedger !== undefined)
132
80
  await releaseSessionParticipantClaim(hostLedger, ownershipClaim).catch(() => undefined);
133
81
  throw error;
134
82
  }
135
- await ensureLocalPresence(square, committed.name, epoch);
136
- await publishIdentityRoute(square, committed.name, epoch);
137
- return { name: committed.name, activity: committed.stored === null ? null : toPublicActivity(committed.stored) };
83
+ await publishCommittedPresence(square, committed.name, epoch);
84
+ return {
85
+ name: committed.name, activity: committed.stored === null ? null : toPublicActivity(committed.stored),
86
+ participantCount: committed.participantCount, reconnected: false,
87
+ };
138
88
  }
139
- /** End the standing participant and immediately let the caller reclaim the name. */
89
+ /** Takeover holds the host CAS fence across the single done+join artifact transaction. */
140
90
  export async function takeover(square, name, _oldSessionIds = [], control) {
141
- // Rejected validation must not perform an ownership claim.
142
91
  throwIfAborted(control);
143
92
  validateName(name);
144
93
  const hostLedger = square.hostLedger;
145
94
  const commitLifecycle = async () => {
146
95
  throwIfAborted(control);
147
96
  const now = square.clock();
148
- const committed = await square.artifact.transact((state) => {
149
- const joinedName = resolveKnownName(state, name);
150
- const done = coreDone(state, joinedName, '', now);
151
- const storedDone = committedActivity(storeActs(state, [done]), 'kick');
152
- const decision = decideJoin(state, joinedName, now);
97
+ return (await square.artifact.transact((facts) => {
98
+ const joinedName = resolveKnownName(facts, name);
99
+ const storedDone = facts.land(coreDone(facts, joinedName, '', now));
100
+ const decision = decideJoin(facts, joinedName, now);
153
101
  if (decision.joinAct === undefined)
154
102
  throw new SquareError('already_joined', `${participantIdentity(joinedName)} could not be reclaimed`);
155
- const storedJoin = committedActivity(storeActs(state, [decision.joinAct]), 'join');
156
- state.routes = (state.routes ?? []).filter((route) => nameKey(route.participant) !== nameKey(joinedName));
157
- return { state, result: { name: joinedName, stored: [storedDone, storedJoin] } };
158
- }, control?.signal);
159
- return committed;
103
+ const storedJoin = facts.land(decision.joinAct);
104
+ facts.replaceRoutes(facts.routes().filter((route) => nameKey(route.participant) !== nameKey(joinedName)));
105
+ return {
106
+ name: joinedName, stored: [storedDone, storedJoin],
107
+ participantCount: facts.roster().filter((p) => p.joined || p.done).length,
108
+ };
109
+ }, control?.signal)).value;
160
110
  };
161
111
  if (hostLedger !== undefined) {
162
- const env = square.env ?? process.env;
163
- // Gate the ownership claim on the artifact's authoritative state: a takeover that cannot
164
- // commit its lifecycle (a name that never joined, or a standing participant that is no
165
- // longer joined) must not claim or remove presence. The refusals mirror the transaction.
166
- const { state } = await square.artifact.read(control?.signal);
167
- const joinedName = resolveKnownName(state, name); // invalid_args when the name never joined
168
- coreDone(state, joinedName, '', square.clock()); // already_done/not_joined when not standing
112
+ // Gate the claim on authoritative admission, so a refused takeover never claims presence.
113
+ await square.artifact.read((facts) => coreDone(facts, resolveKnownName(facts, name), '', square.clock()), control?.signal);
169
114
  const owner = await readParticipantOwner(square.location, name, hostLedger);
170
115
  throwIfAborted(control);
171
- // The claim and the artifact lifecycle are one fenced critical section: a losing or refused
172
- // takeover never mutates the winner, and no second takeover can interleave mid-commit.
173
- const outcome = await claimSessionTakeover(square.location, name, hostLedger, env, {
174
- expectedEpoch: presenceEpoch(owner),
175
- expectedSession: owner?.session ?? '',
116
+ const outcome = await claimSessionTakeover(square.location, name, hostLedger, square.env ?? process.env, {
117
+ expectedEpoch: presenceEpoch(owner), expectedSession: owner?.session ?? '',
176
118
  }, async (claim) => {
177
119
  throwIfAborted(control);
178
120
  const committed = await commitLifecycle();
179
- await ensureLocalPresence(square, committed.name, claim.epoch);
180
- await publishIdentityRoute(square, committed.name, claim.epoch);
121
+ await publishCommittedPresence(square, committed.name, claim.epoch);
181
122
  return committed;
182
123
  }, control?.signal);
183
124
  if (outcome.status === 'busy')
184
- throw new SquareError('already_joined', `✕ ${participantIdentity(name)} already stands here — another session holds the name`);
185
- return { name: outcome.result.name, activities: outcome.result.stored.map(toPublicActivity), epoch: outcome.epoch };
125
+ throw new SquareError('already_joined', `${participantIdentity(name)} already stands here — another session holds the name`);
126
+ return { name: outcome.result.name, activities: outcome.result.stored.map(toPublicActivity), participantCount: outcome.result.participantCount, epoch: outcome.epoch };
186
127
  }
187
128
  const committed = await commitLifecycle();
188
- await ensureLocalPresence(square, committed.name);
189
- await publishIdentityRoute(square, committed.name);
190
- return { name: committed.name, activities: committed.stored.map(toPublicActivity) };
129
+ await publishCommittedPresence(square, committed.name);
130
+ return { name: committed.name, activities: committed.stored.map(toPublicActivity), participantCount: committed.participantCount };
191
131
  }
192
132
  export async function implicitJoin(square, name, control) {
193
133
  throwIfAborted(control);
194
- const now = square.clock();
195
- const route = await identityRouteDraft(square, name);
134
+ const now = square.clock(), route = await identityRouteDraft(square, name);
196
135
  throwIfAborted(control);
197
- const committed = await square.artifact.transact((state) => {
198
- const decision = decideImplicitJoin(state, name, now);
136
+ const committed = (await square.artifact.transact((facts) => {
137
+ const decision = decideImplicitJoin(facts, name, now);
199
138
  if (decision.state === 'done') {
200
139
  if (square.location !== undefined && square.location !== 'memory')
201
- dropSessionWakeRoutesFromState(state, square.location, processIdentity(square.env ?? process.env).session);
202
- return { state, result: { name: decision.joinedName, state: decision.state, stored: null } };
140
+ dropSessionWakeRoutes(facts, square.location, processIdentity(square.env ?? process.env).session);
141
+ }
142
+ else if (decision.joinAct !== undefined) {
143
+ if (square.location !== undefined && square.location !== 'memory')
144
+ dropParticipantWakeRoutes(facts, square.location, decision.joinedName);
145
+ if (route !== undefined)
146
+ applyWakeRoute(facts, { ...route, participant: decision.joinedName }, now);
203
147
  }
204
- if (decision.joinAct === undefined)
205
- return { result: { name: decision.joinedName, state: decision.state, stored: null } };
206
- if (square.location !== undefined && square.location !== 'memory')
207
- dropParticipantWakeRoutesFromState(state, square.location, decision.joinedName);
208
- if (route !== undefined)
209
- applyWakeRouteToState(state, { ...route, participant: decision.joinedName }, now);
210
- return { state, result: { name: decision.joinedName, state: decision.state, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
211
- }, control?.signal);
148
+ return { name: decision.joinedName, state: decision.state, stored: decision.joinAct === undefined ? null : facts.land(decision.joinAct) };
149
+ }, control?.signal)).value;
212
150
  await ensureLocalPresence(square, committed.name);
213
151
  if (committed.state === 'done')
214
152
  await retireIdentityRoute(square, committed.name);
@@ -216,8 +154,6 @@ export async function implicitJoin(square, name, control) {
216
154
  await publishIdentityRoute(square, committed.name);
217
155
  return { name: committed.name, state: committed.state, activity: committed.stored === null ? null : toPublicActivity(committed.stored) };
218
156
  }
219
- /** Only admission failures can advertise a safe retry; storage and post-commit
220
- * failures must not be mistaken for a rejected send merely by their error code. */
221
157
  function rejectUnsentActivity(operation) {
222
158
  try {
223
159
  return operation();
@@ -229,27 +165,32 @@ function rejectUnsentActivity(operation) {
229
165
  }
230
166
  }
231
167
  export async function express(square, name, body, options = {}, control) {
232
- if (control?.signal?.aborted)
233
- throw control.signal.reason ?? new Error('Operation aborted');
234
- if (!await assertLiveOwner(square, name)) {
235
- throw new SquareError('already_joined', `✕ ${participantIdentity(name)} already stands here — another session holds the name`, { activityUnsent: true });
236
- }
168
+ throwIfAborted(control);
169
+ if (!await assertLiveOwner(square, name))
170
+ throw new SquareError('already_joined', `${participantIdentity(name)} already stands here — another session holds the name`, { activityUnsent: true });
237
171
  throwIfAborted(control);
238
172
  const now = square.clock();
239
- const reply = rejectUnsentActivity(() => options.reply === undefined ? undefined : parseRequiredActivityId(options.reply));
240
- const committed = await square.artifact.transact((state) => {
173
+ const reply = rejectUnsentActivity(() => {
174
+ try {
175
+ return options.reply === undefined ? undefined : parseRequiredActivityId(options.reply);
176
+ }
177
+ catch (error) {
178
+ if (isSquareError(error))
179
+ error.facts = { ...error.facts, reason: 'invalid_reply', activityId: options.reply };
180
+ throw error;
181
+ }
182
+ });
183
+ const committed = (await square.artifact.transact((facts) => {
184
+ // Selected-row decoding is storage work, not evidence that admission safely refused.
185
+ const proposed = decideAct(facts, { name, body, force: options.force ?? false, now, mentions: options.mentions,
186
+ ...(options.reach === undefined ? {} : { reach: options.reach }), ...(options.noMention === undefined ? {} : { noMention: options.noMention }), ...('requireExplicitReach' in options ? { requireExplicitReach: options.requireExplicitReach === true } : {}), ...(reply === undefined ? {} : { reply }) });
187
+ const holder = proposed.type === 'held' ? facts.hold().actor : undefined;
241
188
  const decision = rejectUnsentActivity(() => {
242
- const decision = decideAct(state, { name, body, force: options.force ?? false, now, mentions: options.mentions, ...(options.reach === undefined ? {} : { reach: options.reach }), ...(options.noMention === undefined ? {} : { noMention: options.noMention }), ...(reply === undefined ? {} : { reply }) });
243
- if (decision.type === 'blocked') {
244
- const pending = decision.activitySummaries.reduce((count, summary) => count + summary.count, 0);
245
- throw new SquareError('behind', `${participantIdentity(name)} has pending activity`, { pending });
246
- }
189
+ const decision = proposed;
190
+ if (decision.type === 'blocked')
191
+ throw new SquareError('behind', `${participantIdentity(name)} has pending activity`, { pending: decision.activitySummaries.reduce((count, summary) => count + summary.count, 0) });
247
192
  if (decision.type === 'held') {
248
- const holder = state.acts.filter((activity) => activity.kind === 'hold').at(-1)?.actor;
249
- throw new SquareError('held', 'The square is held', {
250
- ...(holder === undefined ? {} : { holder }),
251
- ...(decision.reason === undefined ? {} : { holdReason: decision.reason }),
252
- });
193
+ throw new SquareError('held', 'The square is held', { ...(holder === undefined ? {} : { holder }), ...(decision.reason === undefined ? {} : { holdReason: decision.reason }) });
253
194
  }
254
195
  if (decision.type === 'capped')
255
196
  throw new SquareError('capped', `${participantIdentity(name)} reached the activity cap`);
@@ -259,85 +200,69 @@ export async function express(square, name, body, options = {}, control) {
259
200
  throw new SquareError('bell_quota', `${participantIdentity(name)} cannot ring the bell yet`, { retryAfterMs: Math.max(1, decision.nextAt - now) });
260
201
  return decision;
261
202
  });
262
- const stored = committedActivity(storeActs(state, [decision.act]), 'express');
263
- return { state, result: { stored, ...(decision.replyAuthorNotHere === undefined ? {} : { replyAuthorNotHere: decision.replyAuthorNotHere }) } };
264
- }, control?.signal);
265
- await ensureLocalPresence(square, name);
266
- await publishIdentityRoute(square, name);
267
- let delivery;
268
- if (square.wakeTransport !== undefined && square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory') {
269
- delivery = await deliverPending({ artifact: square.artifact, hostLedger: square.hostLedger, transport: square.wakeTransport, location: square.location, activity: committed.stored.index, now }).catch(() => ({ attempted: 0, accepted: 0, failed: 0, unknown: 0, notCapable: 1 }));
270
- }
271
- else {
272
- delivery = { attempted: 0, accepted: 0, failed: 0, unknown: 0, notCapable: 1 };
273
- }
274
- return { activity: toPublicActivity(committed.stored), delivery, ...(committed.replyAuthorNotHere === undefined ? {} : { replyAuthorNotHere: committed.replyAuthorNotHere }) };
203
+ const stored = facts.land(decision.act);
204
+ return {
205
+ stored,
206
+ observation: {
207
+ standingCount: inSquareCount(facts), held: facts.hold().active, now,
208
+ ownActivityCount: facts.participant(name).sayCount, hardCap: facts.header().hardCap,
209
+ },
210
+ ...(decision.replyAuthorNotHere === undefined ? {} : { replyAuthorNotHere: decision.replyAuthorNotHere }),
211
+ };
212
+ }, control?.signal)).value;
213
+ await publishCommittedPresence(square, name);
214
+ const delivery = square.wakeTransport !== undefined && square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory'
215
+ ? await deliverPending({ artifact: square.artifact, hostLedger: square.hostLedger, transport: square.wakeTransport, location: square.location, activity: committed.stored.index, now })
216
+ .catch(() => ({ attempted: 0, accepted: 0, failed: 0, unknown: 0, notCapable: 1 }))
217
+ : { attempted: 0, accepted: 0, failed: 0, unknown: 0, notCapable: 1 };
218
+ return {
219
+ activity: toPublicActivity(committed.stored), delivery, observation: committed.observation,
220
+ ...(committed.replyAuthorNotHere === undefined ? {} : { replyAuthorNotHere: committed.replyAuthorNotHere }),
221
+ };
275
222
  }
276
223
  async function landListenerChange(square, verb, actor, target, control) {
277
224
  throwIfAborted(control);
278
225
  const now = square.clock();
279
- const stored = await square.artifact.transact((state) => {
280
- const act = verb === 'listen' ? coreListen(state, actor, target, now) : coreIgnore(state, actor, target, now);
281
- if (act === undefined)
282
- return { result: null };
283
- return { state, result: committedActivity(storeActs(state, [act]), verb) };
284
- }, control?.signal);
226
+ const stored = (await square.artifact.transact((facts) => {
227
+ const act = verb === 'listen' ? coreListen(facts, actor, target, now) : coreIgnore(facts, actor, target, now);
228
+ return act === undefined ? null : facts.land(act);
229
+ }, control?.signal)).value;
285
230
  return { activity: stored === null ? null : toPublicActivity(stored) };
286
231
  }
287
232
  export function listen(square, actor, target, control) { return landListenerChange(square, 'listen', actor, target, control); }
288
233
  export function ignore(square, actor, target, control) { return landListenerChange(square, 'ignore', actor, target, control); }
289
- export async function listening(square, actor, control) { const { state } = await square.artifact.read(control?.signal); return coreListening(state, actor); }
234
+ export async function listening(square, actor, control) { return (await square.artifact.read((facts) => coreListening(facts, actor), control?.signal)).value; }
290
235
  async function landCore(square, verb, actor, body = '', fence = {}, control) {
291
236
  if (verb === 'done')
292
237
  validateDoneBody(body);
293
- // Done completes ownership: the live-owner validation, the artifact commit, the ended
294
- // session's route retirement, and presence cleanup share one ownership critical section.
295
- // A takeover finalizing between validation and commit can never be completed by a stale
296
- // done, and no post-lock cleanup can remove a replacement owner's fresh rows.
297
238
  const hostLedger = verb === 'done' ? square.hostLedger : undefined;
298
- const fenced = hostLedger !== undefined;
299
- const session = fenced ? processIdentity(square.env ?? process.env).session : undefined;
239
+ const session = hostLedger === undefined ? undefined : processIdentity(square.env ?? process.env).session;
300
240
  const commitAndCleanup = async () => {
301
- if (verb === 'done' && !await assertLiveOwner(square, actor, fence.expectedEpoch)) {
302
- throw new SquareError('already_done', `✕ ${participantIdentity(actor)} already stands here — another session holds the name`);
303
- }
304
- let ownerRecords = [];
305
- if (fenced && session !== undefined) {
306
- ownerRecords = (await hostLedger.listPresence({ location: square.location, participant: actor }))
307
- .filter((row) => row.session === session);
308
- }
241
+ // Owner admission, commit and route/presence cleanup share the host claim critical section.
242
+ if (verb === 'done' && !await assertLiveOwner(square, actor, fence.expectedEpoch))
243
+ throw new SquareError('already_done', `${participantIdentity(actor)} already stands here — another session holds the name`);
244
+ const owners = hostLedger === undefined ? [] : (await hostLedger.listPresence({ location: square.location, participant: actor })).filter((row) => row.session === session);
309
245
  const now = square.clock();
310
- const stored = await square.artifact.transact((state) => {
311
- const act = verb === 'done' ? coreDone(state, actor, body, now) : verb === 'hold' ? coreHold(state, actor, body, now) : coreResume(state, actor, now);
312
- const result = committedActivity(storeActs(state, [act]), verb);
313
- if (fenced && session !== undefined) {
314
- dropEndedSessionWakeRoutesFromState(state, square.location, session, { expectedEpoch: fence.expectedEpoch, force: true, participant: actor });
315
- }
316
- return { state, result };
317
- }, control?.signal);
318
- for (const ownerRecord of ownerRecords) {
319
- await hostLedger.removePresenceIfUnchanged(ownerRecord).catch((error) => {
246
+ const stored = (await square.artifact.transact((facts) => {
247
+ const result = facts.land(verb === 'done' ? coreDone(facts, actor, body, now) : verb === 'hold' ? coreHold(facts, actor, body, now) : coreResume(facts, actor, now));
248
+ if (session !== undefined)
249
+ dropEndedSessionWakeRoutes(facts, square.location, session, { expectedEpoch: fence.expectedEpoch, force: true, participant: actor });
250
+ return result;
251
+ }, control?.signal)).value;
252
+ for (const row of owners)
253
+ await hostLedger.removePresenceIfUnchanged(row).catch((error) => {
320
254
  process.stderr.write(`! host presence cleanup degraded: ${error instanceof Error ? error.message : String(error)}\n`);
321
255
  });
322
- }
323
256
  return stored;
324
257
  };
325
- const stored = fenced
326
- ? await hostLedger.withClaimLock(commitAndCleanup, control?.signal)
327
- : await commitAndCleanup();
258
+ const stored = hostLedger === undefined ? await commitAndCleanup() : await hostLedger.withClaimLock(commitAndCleanup, control?.signal);
328
259
  return { activity: toPublicActivity(stored) };
329
260
  }
330
261
  export function done(square, name, body = '', fence = {}, control) { return landCore(square, 'done', name, body, fence, control); }
331
262
  function liveClaimedParticipants(rows, sessionId) {
332
- const claimed = new Set();
333
- const foreign = new Set();
334
- for (const row of rows) {
335
- const key = nameKey(row.participant);
336
- if (row.session === sessionId)
337
- claimed.add(key);
338
- else
339
- foreign.add(key);
340
- }
263
+ const claimed = new Set(), foreign = new Set();
264
+ for (const row of rows)
265
+ (row.session === sessionId ? claimed : foreign).add(nameKey(row.participant));
341
266
  for (const key of foreign)
342
267
  claimed.delete(key);
343
268
  return claimed;
@@ -350,30 +275,19 @@ async function readLiveClaimedParticipants(hostLedger, location, sessionId, now)
350
275
  return undefined;
351
276
  }
352
277
  }
353
- /**
354
- * End one session's ownership of a participant inside the ownership claim critical
355
- * section. The artifact `done` (when this session is still the owner) and retirement of
356
- * this session's routes share one transaction, and the exact captured presence row is
357
- * removed only while the lock is still held. A rejoin or takeover therefore lands either
358
- * before the whole cleanup — and is part of the state being ended — or after it, with
359
- * fresh presence and route rows that this cleanup can never touch.
360
- */
361
278
  export async function endOwnedSession(square, name, sessionId, expectedEpoch) {
362
- const location = square.location;
363
- const hostLedger = square.hostLedger;
279
+ const { location, hostLedger } = square;
364
280
  const run = async () => {
365
281
  const now = square.clock();
366
- let currentSessionId;
367
- let ownerMatches = expectedEpoch === undefined;
282
+ let currentSessionId, ownerMatches = expectedEpoch === undefined;
368
283
  let ownerRecords = [];
369
284
  let liveParticipants = new Set();
370
285
  if (location !== undefined && location !== 'memory' && hostLedger !== undefined) {
371
286
  try {
372
287
  const rows = await hostLedger.listPresence({ location, now });
373
288
  const bindings = rows.filter((row) => nameKey(row.participant) === nameKey(name));
374
- currentSessionId = bindings.find((binding) => binding.session !== sessionId)?.session
375
- ?? bindings.toSorted((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))[0]?.session;
376
- ownerRecords = rows.filter((row) => row.session === sessionId && nameKey(row.participant) === nameKey(name));
289
+ currentSessionId = bindings.find((row) => row.session !== sessionId)?.session ?? bindings.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]?.session;
290
+ ownerRecords = bindings.filter((row) => row.session === sessionId);
377
291
  liveParticipants = liveClaimedParticipants(rows, sessionId);
378
292
  if (expectedEpoch !== undefined) {
379
293
  const owner = await readParticipantOwner(location, name, hostLedger);
@@ -381,55 +295,41 @@ export async function endOwnedSession(square, name, sessionId, expectedEpoch) {
381
295
  }
382
296
  }
383
297
  catch {
384
- liveParticipants = undefined; /* unknown evidence preserves routes; artifact routes still fence ownership */
298
+ liveParticipants = undefined;
385
299
  }
386
300
  }
387
- const stored = await square.artifact.transact((state) => {
301
+ const stored = (await square.artifact.transact((facts) => {
388
302
  let committed = null;
389
- if (location !== undefined && location !== 'memory' && ownerMatches && sessionCanEndParticipant(state, location, name, sessionId, currentSessionId)) {
303
+ if (location !== undefined && location !== 'memory' && ownerMatches && sessionCanEndParticipant(facts, location, name, sessionId, currentSessionId)) {
390
304
  try {
391
- committed = committedActivity(storeActs(state, [coreDone(state, name, '', now)]), 'done');
305
+ committed = facts.land(coreDone(facts, name, '', now));
392
306
  }
393
307
  catch (error) {
394
308
  if (!isSquareError(error) || (error.code !== 'already_done' && error.code !== 'not_joined'))
395
309
  throw error;
396
310
  }
397
311
  }
398
- if (location !== undefined && location !== 'memory') {
399
- dropEndedSessionWakeRoutesFromState(state, location, sessionId, { expectedEpoch, liveParticipants, force: committed !== null, participant: name });
400
- }
401
- return { state, result: committed };
402
- });
403
- if (stored !== null) {
404
- for (const ownerRecord of ownerRecords) {
405
- await hostLedger.removePresenceIfUnchanged(ownerRecord).catch((error) => {
312
+ if (location !== undefined && location !== 'memory')
313
+ dropEndedSessionWakeRoutes(facts, location, sessionId, { expectedEpoch, liveParticipants, force: committed !== null, participant: name });
314
+ return committed;
315
+ })).value;
316
+ if (stored !== null)
317
+ for (const row of ownerRecords)
318
+ await hostLedger.removePresenceIfUnchanged(row).catch((error) => {
406
319
  process.stderr.write(`! host presence cleanup degraded: ${error instanceof Error ? error.message : String(error)}\n`);
407
320
  });
408
- }
409
- }
410
321
  return stored;
411
322
  };
412
- const stored = hostLedger !== undefined
413
- ? await hostLedger.withClaimLock(run)
414
- : await run();
323
+ const stored = hostLedger === undefined ? await run() : await hostLedger.withClaimLock(run);
415
324
  return stored === null ? null : { activity: toPublicActivity(stored) };
416
325
  }
417
- /**
418
- * Retire an ended session's orphan routes when no participant name is known. The
419
- * ownership claim lock is held across the ledger read and the artifact transaction, so a
420
- * rejoin that claims first is seen as a live participant and its route survives.
421
- */
422
326
  export async function retireEndedSessionRoutes(square, sessionId) {
423
- const location = square.location;
424
- const hostLedger = square.hostLedger;
327
+ const { location, hostLedger } = square;
425
328
  if (location === undefined || hostLedger === undefined)
426
329
  return;
427
330
  await hostLedger.withClaimLock(async () => {
428
331
  const liveParticipants = await readLiveClaimedParticipants(hostLedger, location, sessionId, square.clock());
429
- await square.artifact.transact((state) => {
430
- dropEndedSessionWakeRoutesFromState(state, location, sessionId, { liveParticipants, force: false });
431
- return { state, result: undefined };
432
- });
332
+ await square.artifact.transact((facts) => { dropEndedSessionWakeRoutes(facts, location, sessionId, { liveParticipants, force: false }); });
433
333
  });
434
334
  }
435
335
  export function hold(square, name, reason = '', control) { return landCore(square, 'hold', name, reason, {}, control); }