@1agh/maude 1.4.4 → 1.4.6

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 (47) hide show
  1. package/apps/studio/annotations-model.ts +33 -1
  2. package/apps/studio/api.ts +19 -0
  3. package/apps/studio/canvas-comment-mount.tsx +48 -3
  4. package/apps/studio/canvas-lib.tsx +5 -0
  5. package/apps/studio/canvas-shell.tsx +110 -14
  6. package/apps/studio/canvas-source-memo.ts +39 -0
  7. package/apps/studio/canvas-text-patch.ts +117 -0
  8. package/apps/studio/client/app.jsx +108 -60
  9. package/apps/studio/client/comments-overlay.css +10 -0
  10. package/apps/studio/client/file-tree.jsx +157 -0
  11. package/apps/studio/client/index.html +1 -1
  12. package/apps/studio/client/panels/DiffView.jsx +37 -8
  13. package/apps/studio/client/panels/GitPanel.jsx +2 -1
  14. package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
  15. package/apps/studio/client/styles/3-shell-maude.css +21 -9
  16. package/apps/studio/client/styles/4-components.css +4 -2
  17. package/apps/studio/collab/index.ts +9 -0
  18. package/apps/studio/collab/persistence.ts +49 -1
  19. package/apps/studio/comment-anchor.ts +116 -0
  20. package/apps/studio/comments-overlay.tsx +78 -35
  21. package/apps/studio/context.ts +1 -0
  22. package/apps/studio/cursors-overlay.tsx +181 -107
  23. package/apps/studio/dist/client.bundle.js +1086 -1086
  24. package/apps/studio/dist/comment-mount.js +2 -2
  25. package/apps/studio/dist/styles.css +1 -1
  26. package/apps/studio/hmr-broadcast.ts +59 -3
  27. package/apps/studio/http.ts +3 -0
  28. package/apps/studio/sync/accepted-cold-start.ts +95 -7
  29. package/apps/studio/sync/accepted-link.ts +165 -29
  30. package/apps/studio/sync/agent.ts +28 -2
  31. package/apps/studio/sync/comment-ledger.ts +229 -0
  32. package/apps/studio/sync/file-plane.ts +101 -2
  33. package/apps/studio/sync/index.ts +124 -17
  34. package/apps/studio/sync/journal-client.ts +9 -3
  35. package/apps/studio/sync/migrate-seed.ts +9 -2
  36. package/apps/studio/sync/projection.ts +114 -11
  37. package/apps/studio/sync/remote-docs.ts +3 -2
  38. package/apps/studio/sync/source-recovery.ts +67 -0
  39. package/apps/studio/sync/status.ts +11 -3
  40. package/apps/studio/sync/transaction-client.ts +49 -4
  41. package/apps/studio/use-collab.tsx +28 -1
  42. package/apps/studio/use-selection-set.tsx +13 -3
  43. package/apps/studio/use-undo-stack.tsx +22 -2
  44. package/apps/studio/whats-new.json +45 -0
  45. package/cli/lib/workspace-plan.mjs +10 -0
  46. package/package.json +8 -8
  47. package/plugins/design/templates/_shell.html +21 -0
@@ -0,0 +1,229 @@
1
+ // Per-machine comment ledger — issue #133.
2
+ //
3
+ // Records, per canvas slug, the comment identities (`commentKey`) that were on
4
+ // this machine's `_comments/<slug>.json` AND in the shared document at the last
5
+ // point the two agreed. It answers the one question neither side can answer
6
+ // alone after a restart: an id that is on disk but not in the document — is it
7
+ // a comment still on its way TO the document (keep it), or one the document
8
+ // DROPPED while this machine was away (a remote delete — let it go)?
9
+ //
10
+ // - in the ledger → it was synced before, the document no longer has it: a
11
+ // delete. The projection may write the file without it, and a cold-start
12
+ // union must not put it back.
13
+ // - not in the ledger → never synced from here: still in flight (a mutation
14
+ // whose import has not landed, an external edit — how /design:edit resolves
15
+ // comments). Keep it; defer, exactly as before.
16
+ //
17
+ // Before this, the "ever carried" knowledge lived only in memory
18
+ // (collab/persistence.ts), so it started empty on every launch. A desktop that
19
+ // was closed while a comment was deleted on the web then read that id as "in
20
+ // flight" forever and never wrote its comments file again (accepted mode: the
21
+ // file froze), and the legacy cold-start union re-added it for everyone.
22
+ //
23
+ // Per hub, like the sync journal (DDR-102): the file carries the hub URL it was
24
+ // recorded against, and relinking to a different hub wipes it — "synced
25
+ // before" against one hub says nothing about another. Best-effort and never
26
+ // throws: a missing or corrupt ledger reads as empty, which degrades to the old
27
+ // behaviour (defer), the safe direction. Lives under `_state/`, which is
28
+ // already per-machine runtime state (DDR-115 taxonomy), so no new path.
29
+
30
+ import { createHash } from 'node:crypto';
31
+ import { existsSync, readFileSync } from 'node:fs';
32
+ import path from 'node:path';
33
+
34
+ import { atomicWrite } from './atomic-write.ts';
35
+ import { commentKey } from './comment-identity.ts';
36
+
37
+ const LEDGER_FILE = 'comment-ledger.json';
38
+ /**
39
+ * Written immediately, not debounced: a record follows a comments-file write
40
+ * (rare), and a peer quit right after syncing must not lose it — a debounced
41
+ * ledger lost exactly that record in the E2E relaunch run and left the file
42
+ * frozen. Order is file first, ledger second, so a crash between the two
43
+ * leaves ids the ledger does not know: deferred, the safe direction.
44
+ */
45
+ const FLUSH_MS = 0;
46
+ /** Same ceiling as collab/persistence.ts MAX_SEEN_COMMENT_IDS (DDR-054 §2d). */
47
+ const MAX_IDS_PER_SLUG = 10_000;
48
+
49
+ interface LedgerFileShape {
50
+ hubUrl: string | null;
51
+ updatedAt: number;
52
+ slugs: Record<string, string[]>;
53
+ }
54
+
55
+ /** A read-only view of the identities recorded for one canvas. */
56
+ export interface SyncedSet {
57
+ readonly size: number;
58
+ /** Takes a raw `commentKey`; the ledger stores only its hash. */
59
+ has(key: string): boolean;
60
+ }
61
+
62
+ /**
63
+ * The stored form of a comment key. Hashed (security review F3): an id-less
64
+ * comment's key is its whole JSON body, which a peer controls — the ledger must
65
+ * not grow with comment bodies, only with how many there are.
66
+ */
67
+ export function ledgerKey(key: string): string {
68
+ return createHash('sha256').update(key).digest('base64url').slice(0, 22);
69
+ }
70
+
71
+ export interface CommentLedger {
72
+ /** Identities last known synced for `slug` (empty when never recorded). */
73
+ get(slug: string): SyncedSet;
74
+ /** Has `slug` ever been recorded (under the current hub)? Distinguishes
75
+ * "synced, and had no comments" from "no knowledge" (first launch after
76
+ * upgrading, a fresh link) — only the first may act on a difference. */
77
+ known(slug: string): boolean;
78
+ /** Replace `slug`'s record with the identities of a list both sides now agree on. */
79
+ record(slug: string, keys: Iterable<string>): void;
80
+ /**
81
+ * Linked to a (different) hub → drop every record. The identity must name
82
+ * the document namespace, not just the URL: one hub serves several
83
+ * workspaces (and branches), and "synced before" in one says nothing about
84
+ * another (security review F1c). `null` = no hub.
85
+ */
86
+ invalidateIfHubChanged(identity: string | null): void;
87
+ /** Persist now. Best-effort. */
88
+ flush(): void;
89
+ }
90
+
91
+ export function commentLedgerPath(designRoot: string): string {
92
+ return path.join(designRoot, '_state', LEDGER_FILE);
93
+ }
94
+
95
+ export interface LoadCommentLedgerOptions {
96
+ /** Tests use 0 to persist synchronously. */
97
+ flushMs?: number;
98
+ writer?: (file: string, bytes: string) => void;
99
+ }
100
+
101
+ export function loadCommentLedger(
102
+ designRoot: string,
103
+ opts: LoadCommentLedgerOptions = {}
104
+ ): CommentLedger {
105
+ const file = commentLedgerPath(designRoot);
106
+ const flushMs = opts.flushMs ?? FLUSH_MS;
107
+ const writer = opts.writer ?? ((p: string, bytes: string) => void atomicWrite(p, bytes));
108
+
109
+ let hubUrl: string | null = null;
110
+ const slugs = new Map<string, Set<string>>();
111
+ let timer: ReturnType<typeof setTimeout> | null = null;
112
+ let dirty = false;
113
+
114
+ try {
115
+ if (existsSync(file)) {
116
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<LedgerFileShape>;
117
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
118
+ hubUrl = typeof parsed.hubUrl === 'string' ? parsed.hubUrl : null;
119
+ const raw = parsed.slugs;
120
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
121
+ for (const [slug, ids] of Object.entries(raw)) {
122
+ if (!Array.isArray(ids)) continue;
123
+ const keys = ids.filter((k): k is string => typeof k === 'string');
124
+ slugs.set(slug, new Set(keys.slice(-MAX_IDS_PER_SLUG)));
125
+ }
126
+ }
127
+ }
128
+ }
129
+ } catch {
130
+ /* corrupt → empty; every id then reads as "in flight", the old behaviour */
131
+ }
132
+
133
+ function persist(): void {
134
+ if (timer) {
135
+ clearTimeout(timer);
136
+ timer = null;
137
+ }
138
+ if (!dirty) return;
139
+ dirty = false;
140
+ const payload: LedgerFileShape = {
141
+ hubUrl,
142
+ updatedAt: Date.now(),
143
+ slugs: Object.fromEntries([...slugs].map(([s, ids]) => [s, [...ids]])),
144
+ };
145
+ try {
146
+ writer(file, `${JSON.stringify(payload)}\n`);
147
+ } catch (err) {
148
+ console.warn(
149
+ '[sync/comment-ledger] persist failed:',
150
+ err instanceof Error ? err.message : err
151
+ );
152
+ }
153
+ }
154
+
155
+ function schedule(): void {
156
+ dirty = true;
157
+ if (flushMs === 0) {
158
+ persist();
159
+ return;
160
+ }
161
+ if (!timer) timer = setTimeout(persist, flushMs);
162
+ }
163
+
164
+ return {
165
+ get(slug) {
166
+ const set = slugs.get(slug);
167
+ return {
168
+ size: set?.size ?? 0,
169
+ has: (key: string) => !!set && set.has(ledgerKey(key)),
170
+ };
171
+ },
172
+ known(slug) {
173
+ return slugs.has(slug);
174
+ },
175
+ record(slug, keys) {
176
+ const next = new Set<string>();
177
+ for (const k of keys) {
178
+ next.add(ledgerKey(k));
179
+ if (next.size >= MAX_IDS_PER_SLUG) break;
180
+ }
181
+ const prev = slugs.get(slug);
182
+ if (prev && prev.size === next.size && [...next].every((k) => prev.has(k))) return;
183
+ slugs.set(slug, next);
184
+ schedule();
185
+ },
186
+ invalidateIfHubChanged(url) {
187
+ if (url === hubUrl) return;
188
+ hubUrl = url;
189
+ slugs.clear();
190
+ schedule();
191
+ },
192
+ flush: persist,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * The local half of a cold-start comments union, minus the comments a peer
198
+ * deleted while this machine was away: those the ledger knows were synced from
199
+ * here and the document no longer holds. Everything else — comments never
200
+ * synced from here — still unions up, exactly as before. Shared by both
201
+ * cold-start paths (sync/agent.ts reconcile, sync/migrate-seed.ts), which must
202
+ * agree or one would resurrect what the other let go.
203
+ */
204
+ export function withoutRemotelyDeleted(
205
+ local: unknown[],
206
+ docList: unknown[],
207
+ syncedBefore: Pick<SyncedSet, 'size' | 'has'> | undefined
208
+ ): unknown[] {
209
+ if (!syncedBefore || syncedBefore.size === 0) return local;
210
+ const inDoc = new Set(docList.map(commentKey));
211
+ return local.filter((c) => {
212
+ const k = commentKey(c);
213
+ return inDoc.has(k) || !syncedBefore.has(k);
214
+ });
215
+ }
216
+
217
+ // One ledger per design root per process: the room projection (collab/) and the
218
+ // sync agent (sync/) must share it, or two writers would overwrite each other's
219
+ // file and each would only know half of what was synced.
220
+ const shared = new Map<string, CommentLedger>();
221
+
222
+ export function commentLedgerFor(designRoot: string): CommentLedger {
223
+ let l = shared.get(designRoot);
224
+ if (!l) {
225
+ l = loadCommentLedger(designRoot);
226
+ shared.set(designRoot, l);
227
+ }
228
+ return l;
229
+ }
@@ -61,6 +61,7 @@ import {
61
61
  statSync,
62
62
  writeFileSync,
63
63
  } from 'node:fs';
64
+ import { open as openAsync } from 'node:fs/promises';
64
65
  import path from 'node:path';
65
66
 
66
67
  import { conflictCopyName, decideFile, type FileState } from './decide-file.ts';
@@ -112,6 +113,28 @@ export function sha256File(abs: string): string {
112
113
  return hash.digest('hex');
113
114
  }
114
115
 
116
+ /**
117
+ * The same digest, yielding to the event loop between chunks. A received video
118
+ * is verified on the path every canvas sync shares: hashing 513 MiB in one
119
+ * synchronous run held a teammate's edit back ~5 s on a receiving peer
120
+ * (F3 S14, 2026-09-23).
121
+ */
122
+ export async function sha256FileAsync(abs: string): Promise<string> {
123
+ const hash = createHash('sha256');
124
+ const buf = Buffer.allocUnsafe(1024 * 1024);
125
+ const fh = await openAsync(abs, 'r');
126
+ try {
127
+ for (;;) {
128
+ const { bytesRead } = await fh.read(buf, 0, buf.length, null);
129
+ if (bytesRead <= 0) break;
130
+ hash.update(bytesRead === buf.length ? buf : buf.subarray(0, bytesRead));
131
+ }
132
+ } finally {
133
+ await fh.close();
134
+ }
135
+ return hash.digest('hex');
136
+ }
137
+
115
138
  /**
116
139
  * What the door accepts, until it tells us otherwise.
117
140
  *
@@ -617,6 +640,21 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
617
640
  let reanchorsInARow = 0;
618
641
  let reanchorHeldSince = 0;
619
642
 
643
+ /**
644
+ * A conflict row is waiting on a remote only a FULL read can tell it.
645
+ *
646
+ * A push answered `conflict` defers to "the next pass, against what the hub
647
+ * holds now" — but for a path the hub then leaves alone, a cursor read says
648
+ * nothing, the remembered remote stays unknown, and the row sat in
649
+ * `conflict` for good: Resync restarted the plane and read the same silence
650
+ * (rca issue-file-ledger-orphaned-rows). So a conflict owes one full read.
651
+ * Seeded from the ledger, which is what makes Resync (a restart) pay it.
652
+ * Capped at one per `REANCHOR_HOLD_RECOVERY_MS`, like the re-anchor storm,
653
+ * so a hub answering `conflict` to every push cannot farm full reads.
654
+ */
655
+ let fullReadOwed = Object.values(ledger.rows()).some((r) => r.state === 'conflict');
656
+ let lastOwedFullReadAt = Number.NEGATIVE_INFINITY;
657
+
620
658
  /**
621
659
  * When the plane may talk to the hub again. Issue #109.
622
660
  *
@@ -901,7 +939,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
901
939
  }
902
940
  let got: string;
903
941
  try {
904
- got = sha256File(staged);
942
+ got = await sha256FileAsync(staged);
905
943
  } catch {
906
944
  return { ok: false, reason: 'could not read the downloaded file back' };
907
945
  }
@@ -1474,7 +1512,14 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1474
1512
  requestsThisPass = 0;
1475
1513
 
1476
1514
  // ── 1. The hub's side ────────────────────────────────────────────────
1477
- const startedFrom = ledger.cursor();
1515
+ const payOwedRead = fullReadOwed && now() - lastOwedFullReadAt >= REANCHOR_HOLD_RECOVERY_MS;
1516
+ if (payOwedRead) {
1517
+ fullReadOwed = false;
1518
+ lastOwedFullReadAt = now();
1519
+ }
1520
+ // An owed read is a compaction read in the SAME epoch: the ancestors still
1521
+ // describe this log, so nothing is degraded — it only refreshes remotes.
1522
+ const startedFrom = payOwedRead ? 0 : ledger.cursor();
1478
1523
  let fullRead = startedFrom === 0;
1479
1524
  let page = await fetchJournal({
1480
1525
  hubUrl: opts.hubUrl,
@@ -1625,6 +1670,45 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1625
1670
  ledger.forget(rel);
1626
1671
  continue;
1627
1672
  }
1673
+ // NOT OURS ON EITHER SIDE ANY MORE — forget it, do not keep refusing it.
1674
+ //
1675
+ // A path can leave the file plane while the ledger still tracks it: a
1676
+ // `.css` written before its `.tsx` becomes the canvas's sidecar, or the
1677
+ // canvas groups change. The local scan stops offering it, the hub stops
1678
+ // listing it, and the row stayed behind forever — re-stamped `stuck` by
1679
+ // the admission drop against a remembered remote, or frozen in
1680
+ // `conflict` with nothing left to decide — counted as "waiting" in the
1681
+ // panel (rca issue-file-ledger-orphaned-rows). Only when this page did
1682
+ // not offer it: a path the hub is actively naming still goes through
1683
+ // admission, which reports the refusal.
1684
+ if (!here && !row && kept) {
1685
+ const cls = classifyProjectFile(rel, {
1686
+ canvasGroups: opts.canvasGroups,
1687
+ hasFile: (r) => local.has(r) || existsSync(path.join(designRoot, r)),
1688
+ });
1689
+ if (!isFilePlaneClass(cls)) {
1690
+ ledger.forget(rel);
1691
+ continue;
1692
+ }
1693
+ }
1694
+ // A DELETE IS A TRANSFER TOO. The code-module gate below only sees a
1695
+ // non-null remote, so a tombstone walked past it: an ancestor this peer
1696
+ // recorded (by push, or by agreement) was all `tombstone-agreed` needed
1697
+ // to quarantine a code module on the word of a hub that may not move
1698
+ // one. Refuse it here, reported, with the file and its row untouched.
1699
+ if (row?.deleted && here && !opts.allowCodeModules) {
1700
+ const cls = classifyProjectFile(rel, {
1701
+ canvasGroups: opts.canvasGroups,
1702
+ hasFile: (r) => local.has(r) || existsSync(path.join(designRoot, r)),
1703
+ });
1704
+ if (cls === 'code-module') {
1705
+ out.dropped.push({
1706
+ rel,
1707
+ reason: 'code modules are removed only by an owner-vouched or loopback hub',
1708
+ });
1709
+ continue;
1710
+ }
1711
+ }
1628
1712
  // What the hub holds: this page when it spoke about the path, otherwise
1629
1713
  // what we last learned. `undefined` (never learned) reads as null only
1630
1714
  // after a full read has had the chance to say so.
@@ -1650,6 +1734,20 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1650
1734
  continue;
1651
1735
  }
1652
1736
  if (cls === 'code-module' && !opts.allowCodeModules) {
1737
+ // AGREEMENT IS NOT A TRANSFER. The gate refuses to move a code
1738
+ // module from an unvouched hub (DDR-054); identical bytes on both
1739
+ // sides move nothing, and reporting them as refused left a
1740
+ // converged file permanently "stuck".
1741
+ if (here && here.hash === remoteHash) {
1742
+ void ledger.adoptAfter(rel, here.hash, () => {}, {
1743
+ ...(row ? { remoteSeq: row.seq } : {}),
1744
+ size: here.size,
1745
+ mtimeMs: here.mtimeMs,
1746
+ state: 'on-hub',
1747
+ });
1748
+ out.synced += 1;
1749
+ continue;
1750
+ }
1653
1751
  drop(
1654
1752
  out,
1655
1753
  rel,
@@ -2059,6 +2157,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
2059
2157
  ledger.setState(rel, 'conflict', {
2060
2158
  reason: 'the hub changed this file while the upload was in flight',
2061
2159
  });
2160
+ fullReadOwed = true;
2062
2161
  out.conflicts.push({ rel, copy: null });
2063
2162
  return true;
2064
2163
  }
@@ -53,6 +53,7 @@ import {
53
53
  stampCanvasPath,
54
54
  stampMovedTo,
55
55
  } from './codec.ts';
56
+ import { commentLedgerFor } from './comment-ledger.ts';
56
57
  import {
57
58
  type ConnectionMonitor,
58
59
  createConnectionMonitor,
@@ -119,6 +120,14 @@ export interface SyncProvider {
119
120
  * Optional: a provider without it is treated as writable.
120
121
  */
121
122
  isWritable?(): boolean;
123
+ /**
124
+ * The hub's own word on this connection's write right, carried by a save-mode
125
+ * notice (`maude.mode` with `writable`). It outranks the scope the handshake
126
+ * returned: a connection admitted read-only while the project took proposals
127
+ * is writable again the moment the project returns to legacy — the hub fences
128
+ * per message, not per handshake — and nothing re-authenticates it.
129
+ */
130
+ noteWritable?(writable: boolean): void;
122
131
  /** The hub authenticated this connection (again) — scope may have changed. */
123
132
  onAuthenticated?(cb: (scope: string) => void): () => void;
124
133
  /** Out-of-band messages from the hub on this document's socket. */
@@ -512,6 +521,14 @@ export interface SyncRuntime {
512
521
  ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
513
522
  /** True while the linked project is in accepted-revisions mode. */
514
523
  acceptedMode?(): boolean;
524
+ /**
525
+ * Issue #133 — may the comment ledger record `slug`'s current comments as
526
+ * synced? Only when the hub is known to hold them: accepted mode (the doc IS
527
+ * the accepted replica), or a synced provider with nothing unacknowledged. A
528
+ * comment added offline and not yet delivered must never read as "synced",
529
+ * or the next cold start would take it for a remote delete.
530
+ */
531
+ commentsConfirmedOnHub?(slug: string): boolean;
515
532
  /** Tripwire count: local writes that reached an accepted replica. */
516
533
  acceptedWriteViolations?(): number;
517
534
  /** Accepted revisions: the project's logical history (T27). Null when legacy. */
@@ -915,6 +932,8 @@ export function createSyncRuntime(
915
932
  // change is a proposal through the durable outbox, and the documents change
916
933
  // when the project publishes the accepted revision. Shared-doc only: the
917
934
  // two-doc agent path has no proposal lane and stays legacy.
935
+ /** Resolves once a previous run's outbox is drained: doc → own accepted html. */
936
+ let outboxDrained: Promise<Map<string, string>> = Promise.resolve(new Map());
918
937
  const acceptedLink: AcceptedLink | null = useSharedDoc
919
938
  ? createAcceptedLink({
920
939
  hubUrl: linkedHub.url,
@@ -925,7 +944,16 @@ export function createSyncRuntime(
925
944
  retryMs: opts.transactionRetryMs,
926
945
  onStats: (stats) => statusStore?.updateAccepted?.(stats),
927
946
  onStage: (summary) => statusStore?.updateAiAction?.(summary),
928
- onBootstrap: (b) => noteProjectConfig(b.projectConfig),
947
+ onBootstrap: (b) => {
948
+ noteProjectConfig(b.projectConfig);
949
+ // F3 S17 — a save made as the socket died is held (the connection
950
+ // was not writable). After the reconnect the handshake re-admits the
951
+ // socket read-only BEFORE this peer learns the project now takes
952
+ // proposals, so its retry found the write still blocked and nothing
953
+ // retried again: the save stayed on disk, and the status said
954
+ // synced, until a restart. Knowing the mode, hand it back now.
955
+ if (b.mode === 'transactions') for (const p of projections.values()) p.retryDeferred();
956
+ },
929
957
  })
930
958
  : null;
931
959
  /**
@@ -2038,16 +2066,15 @@ export function createSyncRuntime(
2038
2066
  ): Awaited<ReturnType<typeof fetchRemoteListing>> {
2039
2067
  const manifest = acceptedOn() ? acceptedLink?.manifest : null;
2040
2068
  if (!manifest) return listing;
2041
- // A document the project RETIRED (moved away) is not a canvas to fetch,
2042
- // even while its storage row lingers — its successor is in the manifest.
2043
- const retired = new Set(manifest.docs.filter((d) => d.retired).map((d) => d.doc));
2044
- const documents = (listing?.documents ?? []).filter((d) => !retired.has(d.name));
2045
- const names = new Set(documents.map((d) => d.name));
2046
- for (const d of manifest.docs) {
2047
- if (d.retired || names.has(d.doc)) continue;
2048
- documents.push({ name: d.doc, bytes: 1 });
2049
- names.add(d.doc);
2050
- }
2069
+ // An open Yjs room is not necessarily an accepted canvas. Its author can
2070
+ // connect before doc.create commits; pulling that empty room would lock
2071
+ // the receiver onto a lossy slug-derived path before the real path arrives.
2072
+ // The accepted manifest alone names live canvases, including successors
2073
+ // of retired documents whose transport rows may still linger.
2074
+ const bytesByName = new Map((listing?.documents ?? []).map((d) => [d.name, d.bytes]));
2075
+ const documents = manifest.docs
2076
+ .filter((d) => !d.retired)
2077
+ .map((d) => ({ name: d.doc, bytes: bytesByName.get(d.doc) ?? 1 }));
2051
2078
  return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
2052
2079
  }
2053
2080
 
@@ -2248,10 +2275,29 @@ export function createSyncRuntime(
2248
2275
  );
2249
2276
  // Work a previous run left unanswered goes first, in creation order —
2250
2277
  // before any cold start can propose something built on top of it.
2251
- void acceptedLink.client.drainOutbox().then((results) => {
2252
- if (results.length)
2253
- console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
2254
- });
2278
+ // What each canvas's disk was last saved as, when that save was one of
2279
+ // these: the base its cold start judges the disk against.
2280
+ const own = new Map<string, string>();
2281
+ outboxDrained = acceptedLink.client
2282
+ .drainOutbox((result, operations) => {
2283
+ for (const o of operations) {
2284
+ const html =
2285
+ o.op === 'lane.replace' && o.lane === 'html'
2286
+ ? o.content
2287
+ : o.op === 'doc.create'
2288
+ ? (o.lanes as Record<string, unknown> | undefined)?.html
2289
+ : undefined;
2290
+ if (typeof o.doc !== 'string' || typeof html !== 'string') continue;
2291
+ if (result.status === 'accepted') own.set(o.doc, html);
2292
+ else own.delete(o.doc);
2293
+ }
2294
+ })
2295
+ .then((results) => {
2296
+ if (results.length)
2297
+ console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
2298
+ return own;
2299
+ })
2300
+ .catch(() => own);
2255
2301
  applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
2256
2302
  proposeLocalFolders();
2257
2303
  }
@@ -2585,6 +2631,21 @@ export function createSyncRuntime(
2585
2631
  if (acceptedOn() && p?.op) projectionForRel(p.rel)?.noteSourceOp(p.op);
2586
2632
  });
2587
2633
  activityUnsubs.push(unsubSourceOp);
2634
+ // Only the trusted API emits this after its write has finished. External
2635
+ // fs events retain the quiet window; both paths use the same projection,
2636
+ // proposal, validation and echo/deduplication rules.
2637
+ const unsubSourceWritten = ctx.bus.on('source-written', (payload: unknown) => {
2638
+ const p = payload as { rel?: unknown; content?: unknown } | null;
2639
+ if (!acceptedOn() || typeof p?.content !== 'string') return;
2640
+ const proj = projectionForRel(p.rel);
2641
+ if (!proj) return;
2642
+ proj.applyFromFs({
2643
+ path: path.join(ctx.paths.designRoot, p.rel as string),
2644
+ bytes: new TextEncoder().encode(p.content),
2645
+ hash: hashBytes(p.content),
2646
+ });
2647
+ });
2648
+ activityUnsubs.push(unsubSourceWritten);
2588
2649
  const unsubUnsuppress = ctx.bus.on('activity:unsuppress', (rel: unknown) => {
2589
2650
  projectionForRel(rel)?.cancelLocalWrite();
2590
2651
  });
@@ -2670,6 +2731,11 @@ export function createSyncRuntime(
2670
2731
  // in `_history/<slug>/` via history.ts so /design:rollback recovers them.
2671
2732
  journal = loadJournal(ctx.paths.designRoot);
2672
2733
  journal.invalidateIfHubChanged(linkedHub.url);
2734
+ // Issue #133 — same per-hub rule for the comment ledger: "synced before"
2735
+ // against one hub says nothing about another.
2736
+ commentLedgerFor(ctx.paths.designRoot).invalidateIfHubChanged(
2737
+ `${linkedHub.url} ${docNameFor('_')}`
2738
+ );
2673
2739
  const history = createHistory(ctx);
2674
2740
 
2675
2741
  // ---- DDR-102 helpers: auth aggregation, re-probe, settle bookkeeping ----
@@ -3058,6 +3124,10 @@ export function createSyncRuntime(
3058
3124
  inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
3059
3125
  }
3060
3126
  const rel = path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/');
3127
+ // A save a previous run left unanswered is answered first: a disk
3128
+ // edited after it was edited ON it (F3 S14 on the cloud cell — judged
3129
+ // against the older base, the save conflicted with itself).
3130
+ const ownAccepted = (await outboxDrained).get(docName) ?? null;
3061
3131
  await acceptedColdStart({
3062
3132
  slug: canvas.slug,
3063
3133
  doc: provider.document,
@@ -3067,6 +3137,9 @@ export function createSyncRuntime(
3067
3137
  projection,
3068
3138
  historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3069
3139
  journal: journal ?? undefined,
3140
+ commentLedger: commentLedgerFor(ctx.paths.designRoot),
3141
+ ownAccepted,
3142
+ wasAccepted: (content) => acceptedLink.client.holdsValue(content),
3070
3143
  createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
3071
3144
  });
3072
3145
  projection.reconcile();
@@ -3084,6 +3157,7 @@ export function createSyncRuntime(
3084
3157
  paths: canvasPaths,
3085
3158
  historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3086
3159
  journal: journal ?? undefined,
3160
+ commentLedger: commentLedgerFor(ctx.paths.designRoot),
3087
3161
  snapshot: async (content, reason) => {
3088
3162
  try {
3089
3163
  const snap = await history.writeSnapshot(relBody, content, reason);
@@ -3697,6 +3771,14 @@ export function createSyncRuntime(
3697
3771
  echoGuard,
3698
3772
  adopt: adoptOnce,
3699
3773
  journal: journal ?? undefined,
3774
+ commentLedger: commentLedgerFor(ctx.paths.designRoot),
3775
+ commentsConfirmed: () => {
3776
+ const p = provider as unknown as {
3777
+ synced?: boolean;
3778
+ hasUnsyncedChanges?: boolean;
3779
+ };
3780
+ return p.synced === true && p.hasUnsyncedChanges === false;
3781
+ },
3700
3782
  snapshot: async (content, reason) => {
3701
3783
  try {
3702
3784
  const snap = await history.writeSnapshot(relBody, content, reason);
@@ -3733,6 +3815,8 @@ export function createSyncRuntime(
3733
3815
  }
3734
3816
  if (msg?.type !== 'maude.mode') return;
3735
3817
  if (msg.mode === 'transactions' || msg.mode === 'legacy') {
3818
+ const writable = (msg as { writable?: unknown }).writable;
3819
+ if (typeof writable === 'boolean') provider.noteWritable?.(writable);
3736
3820
  acceptedLink.noteMode(msg.mode);
3737
3821
  void refreshAcceptedMode();
3738
3822
  }
@@ -4876,6 +4960,19 @@ export function createSyncRuntime(
4876
4960
  },
4877
4961
  proposeFolder,
4878
4962
  acceptedMode: acceptedOn,
4963
+ commentsConfirmedOnHub: (slug) => {
4964
+ // The canvas must be live on the hub right now: a provider that has
4965
+ // synced. In accepted mode its doc is the accepted replica (local writes
4966
+ // are refused), but only once THIS canvas's projection exists — before
4967
+ // that, a comment falls back to the local room and is not on the hub
4968
+ // (security review F1b). In legacy mode nothing may be unacknowledged.
4969
+ const p = providers.get(slug) as unknown as
4970
+ | { synced?: boolean; hasUnsyncedChanges?: boolean }
4971
+ | undefined;
4972
+ if (!p || p.synced !== true) return false;
4973
+ if (acceptedOn()) return projections.has(slug);
4974
+ return p.hasUnsyncedChanges === false;
4975
+ },
4879
4976
  acceptedWriteViolations: () => acceptedWriteViolations,
4880
4977
  acceptedHistory: async (q) => {
4881
4978
  if (!acceptedOn() || !acceptedLink) return null;
@@ -5740,11 +5837,17 @@ export function createDefaultProviderFactory(
5740
5837
  socket.on('status', resetSyncedOnDrop);
5741
5838
 
5742
5839
  let authedThisConnection = false;
5840
+ /** A save-mode notice's verdict on THIS connection; a new handshake supersedes it. */
5841
+ let modeWritable: boolean | null = null;
5743
5842
  provider.on('authenticated', () => {
5744
5843
  authedThisConnection = true;
5844
+ modeWritable = null;
5745
5845
  });
5746
5846
  const forgetAuthOnDrop = (evt: { status?: string }) => {
5747
- if (evt?.status !== 'connected') authedThisConnection = false;
5847
+ if (evt?.status !== 'connected') {
5848
+ authedThisConnection = false;
5849
+ modeWritable = null;
5850
+ }
5748
5851
  };
5749
5852
  socket.on('status', forgetAuthOnDrop);
5750
5853
  return {
@@ -5757,7 +5860,11 @@ export function createDefaultProviderFactory(
5757
5860
  // over from before a drop says nothing about the hub now: a project
5758
5861
  // switched to accepted revisions while this peer was away re-admits it
5759
5862
  // read-only, and anything written in between would be dropped there.
5760
- isWritable: () => authedThisConnection && provider.authorizedScope === 'read-write',
5863
+ isWritable: () =>
5864
+ authedThisConnection && (modeWritable ?? provider.authorizedScope === 'read-write'),
5865
+ noteWritable(writable: boolean) {
5866
+ if (authedThisConnection) modeWritable = writable;
5867
+ },
5761
5868
  onAuthenticated(cb: (scope: string) => void): () => void {
5762
5869
  const handler = (evt: { scope?: string }) => cb(String(evt?.scope ?? ''));
5763
5870
  provider.on('authenticated', handler);
@@ -17,6 +17,8 @@
17
17
  // it as "no changes" is exactly the shape that lets a stale peer believe it is
18
18
  // current, which is the failure DDR-214's ordering amendment exists to prevent.
19
19
 
20
+ import { isProjectFileShape } from './file-membership.ts';
21
+
20
22
  /** How long to wait for a journal page. Same figure as the manifest fetch. */
21
23
  const JOURNAL_TIMEOUT_MS = 6000;
22
24
 
@@ -58,8 +60,12 @@ export interface JournalPage {
58
60
  overflowed?: true;
59
61
  }
60
62
 
61
- /** A designRoot-relative path shape a peer will turn into a real file. */
62
- const ENTRY_PATH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;
63
+ // A designRoot-relative path a peer will turn into a real file: the SAME shape
64
+ // rule the hub's file door admits (`isProjectFileShape`). A private, narrower
65
+ // regex here dropped every journalled path with a space in it — `ui/Studio
66
+ // Docs.registry.json` was accepted from its author, journalled, and then
67
+ // silently discarded by every other peer, with no ledger row and no refusal
68
+ // shown (F3 S14, 2026-09-23).
63
69
 
64
70
  function parseEntry(raw: unknown): JournalEntry | null {
65
71
  if (!raw || typeof raw !== 'object') return null;
@@ -67,7 +73,7 @@ function parseEntry(raw: unknown): JournalEntry | null {
67
73
  const seq = e.seq;
68
74
  const p = e.path;
69
75
  if (typeof seq !== 'number' || !Number.isInteger(seq) || seq <= 0) return null;
70
- if (typeof p !== 'string' || !ENTRY_PATH_RE.test(p) || p.split('/').includes('..')) return null;
76
+ if (typeof p !== 'string' || !isProjectFileShape(p)) return null;
71
77
  const sha = typeof e.sha256 === 'string' && /^[0-9a-f]{64}$/.test(e.sha256) ? e.sha256 : null;
72
78
  return {
73
79
  seq,