@1agh/maude 0.58.1 → 0.58.3

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 (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -17,21 +17,23 @@
17
17
  // the provider lib didn't install for some reason) prints a useful error
18
18
  // instead of crashing the dev-server boot.
19
19
 
20
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
21
21
  import { readdir } from 'node:fs/promises';
22
22
  import path from 'node:path';
23
23
 
24
24
  import type { Awareness } from 'y-protocols/awareness';
25
25
  import * as Y from 'yjs';
26
-
26
+ import { renewHubCredential } from '../cloud/renew.ts';
27
27
  import { Y_TYPES } from '../collab/persistence.ts';
28
28
  import type { Context, LinkedHub } from '../context.ts';
29
29
  import { createHistory } from '../history.ts';
30
30
  import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
31
31
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
32
+ import { pushAssets } from './asset-push.ts';
32
33
  import { atomicWrite } from './atomic-write.ts';
33
34
  import { createAutoCommit } from './autocommit.ts';
34
35
  import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
36
+ import { canvasPathFromDoc, stampCanvasPath } from './codec.ts';
35
37
  import {
36
38
  type ConnectionMonitor,
37
39
  createConnectionMonitor,
@@ -40,12 +42,20 @@ import {
40
42
  import { createDocNameResolver } from './doc-name.ts';
41
43
  import { createEchoGuard } from './echo-guard.ts';
42
44
  import { createFsReader, type FsReader } from './fs-mirror.ts';
43
- import { getHubToken } from './hubs-config.ts';
45
+ import { getHubRecord } from './hubs-config.ts';
44
46
  import { loadJournal, type SyncJournal } from './journal.ts';
45
47
  import { isLoopbackHost } from './loopback.ts';
48
+ import { migrateFlatFallback } from './migrate-flat-fallback.ts';
46
49
  import { migrateSeed } from './migrate-seed.ts';
50
+ import { ORIGINS } from './origins.ts';
47
51
  import { createDocProjection, type DocProjection } from './projection.ts';
48
- import { describeRemoteDiff, diffRemoteDocs, fetchRemoteDocs, pullTargets } from './remote-docs.ts';
52
+ import {
53
+ describeRemoteDiff,
54
+ diffRemoteDocs,
55
+ fetchRemoteDocs,
56
+ pullTargets,
57
+ resolvePulledTarget,
58
+ } from './remote-docs.ts';
49
59
  import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
50
60
  import { writeUntrustedMarkers } from './untrusted.ts';
51
61
 
@@ -88,15 +98,50 @@ export type AuthFailureClass = 'rate-limit' | 'not-authorized' | 'invalid-token'
88
98
  * → `generic` (interop-safe degradation). */
89
99
  export function classifyAuthFailure(raw: string): AuthFailureClass {
90
100
  const s = raw.toLowerCase();
91
- if (s.includes('rate limit')) return 'rate-limit';
92
- if (s.includes('not authorized')) return 'not-authorized';
101
+ // Permanent classes FIRST. The hub's invalid-token bucket refuses with
102
+ // "invalid token — rate limited, retry in up to 60s": both substrings are
103
+ // present, and testing 'rate limit' first filed an expired credential under
104
+ // the transient class — so the runtime retried into the very bucket
105
+ // refusing it, and the one cause that needed a person never surfaced.
93
106
  if (s.includes('invalid token')) return 'invalid-token';
107
+ if (s.includes('not authorized')) return 'not-authorized';
108
+ if (s.includes('rate limit')) return 'rate-limit';
94
109
  return 'generic';
95
110
  }
96
111
 
97
112
  export const AUTH_WARN_DEBOUNCE_MS = 2_000;
98
113
  export const AUTH_REPROBE_MS = 5 * 60 * 1000;
99
114
  export const BOOT_SETTLE_TIMEOUT_MS = 15_000;
115
+ /** F1 — minimum wall-clock between renewal attempts. A burst of rejections
116
+ * collapses to one renewal (single-flight); the NEXT burst waits this out. */
117
+ export const RENEW_MIN_INTERVAL_MS = 60_000;
118
+ /** F1 — consecutive successful renewals with no completed handshake in between
119
+ * before the runtime stops renewing and lets the link surface as
120
+ * refused/stalled. Small: a renewal that fixed the credential clears at least
121
+ * one doc, so >0 useless renewals means the credential was never the cause. */
122
+ export const RENEW_MAX_WITHOUT_PROGRESS = 3;
123
+ /** F2 — setTimeout clamps delays above this (2^31-1) to 1 ms, turning a
124
+ * far-future expiry into a tight renewal loop. Clamp before we hand it over. */
125
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
126
+ /** F2 — a hub-reported `expiresAt` further out than this is not believed for
127
+ * scheduling (cloud cells issue ≤ 12 h; a month is already implausible). */
128
+ export const MAX_CREDENTIAL_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
129
+
130
+ /**
131
+ * A hub-reported `expiresAt` sane enough to schedule against, or null.
132
+ *
133
+ * F2: the value crosses the DDR-054 trust boundary (a hub picks it) and is fed
134
+ * to `setTimeout`. A non-integer, a past stamp, or one absurdly far out (skew,
135
+ * or a hostile hub planting a far-future value to arm a tight loop) must not
136
+ * schedule a renewal — null means "no timer", the safe pre-existing behaviour.
137
+ * A credential already expired simply falls to the invalid-token path instead.
138
+ */
139
+ export function validExpiry(raw: unknown, nowMs: number = Date.now()): number | null {
140
+ if (typeof raw !== 'number' || !Number.isInteger(raw)) return null;
141
+ if (raw <= nowMs) return null;
142
+ if (raw - nowMs > MAX_CREDENTIAL_LIFETIME_MS) return null;
143
+ return raw;
144
+ }
100
145
 
101
146
  const AUTH_CLASS_HINT: Record<AuthFailureClass, string> = {
102
147
  'rate-limit':
@@ -194,6 +239,22 @@ export interface CreateSyncRuntimeOptions {
194
239
  settleTimeoutMs?: number;
195
240
  setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
196
241
  clearTimer?: (h: ReturnType<typeof setTimeout>) => void;
242
+ /**
243
+ * Silent credential renewal (cloud workspaces). Called single-flight at
244
+ * ~80 % of the stored credential's remaining life and on any
245
+ * `invalid-token` rejection; a non-null return swaps the runtime's token
246
+ * in place and re-probes rejected docs immediately. Default: the Maude
247
+ * Cloud renewal lane (`cloud/renew.ts`); it answers null for a machine
248
+ * that is not signed in, so self-hosted hubs keep today's behaviour.
249
+ * Never called under cell pairing (the cell's token comes from its env).
250
+ */
251
+ renewCredential?: () => Promise<{ token: string; expiresAt: number | null } | null>;
252
+ /** F1 — clock for the renewal frequency floor (test injection). Default Date.now. */
253
+ now?: () => number;
254
+ /** F1 — minimum ms between renewal attempts. Default RENEW_MIN_INTERVAL_MS. */
255
+ renewMinIntervalMs?: number;
256
+ /** F1 — consecutive no-progress renewals before giving up. Default RENEW_MAX_WITHOUT_PROGRESS. */
257
+ renewMaxWithoutProgress?: number;
197
258
  };
198
259
  }
199
260
 
@@ -347,14 +408,21 @@ export function createSyncRuntime(
347
408
  // this process in its environment. `~/.config/maude/hubs.json` is a PERSON's
348
409
  // credential store and does not exist in a cell (HOME=/tmp) — which is exactly
349
410
  // why the old guard was unreachable by accident rather than by design.
350
- const resolvedToken = cellPairing ? cellPairing.token : getHubToken(linkedHub.url);
411
+ const storedRecord = cellPairing ? null : getHubRecord(linkedHub.url);
412
+ const resolvedToken = cellPairing ? cellPairing.token : (storedRecord?.token ?? null);
351
413
  if (!resolvedToken) {
352
414
  console.warn(
353
415
  `[sync] linked to ${linkedHub.url} but no token in ~/.config/maude/hubs.json. Re-run 'maude design link' on this machine. Solo mode for now.`
354
416
  );
355
417
  return null;
356
418
  }
357
- const token: string = resolvedToken;
419
+ // MUTABLE on purpose: silent renewal swaps it in place, and connectCanvas
420
+ // reads it at call time — so every provider (re)created after a renewal
421
+ // carries the fresh credential without a runtime restart.
422
+ let token: string = resolvedToken;
423
+ // Through validExpiry (F2) — the stored value came off disk written from a
424
+ // hub body, so a bogus/far-future stamp must not schedule a renewal at boot.
425
+ let tokenExpiresAt: number | null = validExpiry(storedRecord?.expiresAt);
358
426
 
359
427
  // DDR-102 — the default factory multiplexes every provider over ONE shared
360
428
  // WebSocket per hub URL; the runtime owns its disposal (stop(), after the
@@ -410,6 +478,36 @@ export function createSyncRuntime(
410
478
  >();
411
479
  let authWarnTimer: TimerHandle | null = null;
412
480
  let reprobeTimer: TimerHandle | null = null;
481
+ let renewTimer: TimerHandle | null = null;
482
+ /** Single-flight guard: 73 rejected docs must trigger ONE renewal, not 73. */
483
+ let renewInFlight: Promise<boolean> | null = null;
484
+ // F1 (2026-08-10 security review) — RATE DISCIPLINE on the renew↔reprobe
485
+ // cycle. Single-flight bounds concurrency; these bound FREQUENCY. Without
486
+ // them a legitimate cell refusing on VOLUME (its invalid-token bucket, which
487
+ // now answers "invalid token — rate limited") classifies permanent →
488
+ // triggers renewal → renewal succeeds (the token was never the problem) →
489
+ // reprobeNow reconnects all N docs → the bucket refuses them again → loop.
490
+ // Reproduced at 2342 renewals/s. This is the exact retry-storm class the
491
+ // whole change exists to end, one level up — so it gets a floor AND a cap.
492
+ const renewNow = opts.auth?.now ?? (() => Date.now());
493
+ const renewMinIntervalMs = opts.auth?.renewMinIntervalMs ?? RENEW_MIN_INTERVAL_MS;
494
+ const renewMaxWithoutProgress = opts.auth?.renewMaxWithoutProgress ?? RENEW_MAX_WITHOUT_PROGRESS;
495
+ /** Wall-clock of the last renewal attempt (0 = never). The floor. */
496
+ let lastRenewAt = 0;
497
+ /** Successful renewals since the last completed handshake. The cap: a
498
+ * renewal that keeps succeeding while nothing connects is not fixing
499
+ * anything — stop, and let the link surface as refused/stalled. Reset to 0
500
+ * at every `connected` doc (the handshake-success point). */
501
+ let renewalsSinceProgress = 0;
502
+ // Silent credential renewal (cloud). Absent under cell pairing — the cell's
503
+ // token arrives via its environment and is the hub's own to rotate.
504
+ const renewCredential = cellPairing
505
+ ? null
506
+ : (opts.auth?.renewCredential ??
507
+ (async () => {
508
+ const r = await renewHubCredential(linkedHub.url);
509
+ return r.ok ? { token: r.token, expiresAt: r.expiresAt } : null;
510
+ }));
413
511
  const settleTimers = new Set<TimerHandle>();
414
512
  /** Pending synthetic `fs:any` emissions (cell pairing only), keyed by the
415
513
  * design-root-relative path so a second write to the same file inside the
@@ -422,6 +520,34 @@ export function createSyncRuntime(
422
520
  if (started || stopped) return;
423
521
  started = true;
424
522
 
523
+ // A NEW PROCESS MUST NOT SERVE THE OLD ONE'S VERDICT.
524
+ //
525
+ // `_sync.json` is a file, and `/_sync-status` returns whatever is in it.
526
+ // The first honest snapshot of THIS run is not written until every provider
527
+ // has been constructed — after a scan and a 6-second listing fetch — and
528
+ // until then the endpoint, the CLI and the browser banner were all reading
529
+ // the last run's counters as if they were current. That is how a live fleet
530
+ // showed `0 synced · 73 rejected` for a process whose own log said
531
+ // `76/76 synced` against a hub that was accepting the credential: the
532
+ // rejections were real, and they were from a previous session.
533
+ //
534
+ // Per-document verdicts are NOT rehydratable — a verdict is about a
535
+ // handshake this process has not made yet. So the file is reset to the
536
+ // honest seed (`connecting`, nothing known) the moment the runtime starts.
537
+ resetPersistedStatus(ctx, linkedHub.url);
538
+
539
+ // Fix 4 (sync RCA 2026-08-10) — one-shot quarantine of the pre-fix-5 flat
540
+ // fallback twins, BEFORE the scan: a flat twin that survived into the scan
541
+ // would sync as its own document and re-seed the duplicate on every peer.
542
+ // Skipped under cell pairing — a cell's checkout is the hub's to manage,
543
+ // and quarantining there would dirty a tree the hub commits.
544
+ if (!cellPairing) {
545
+ migrateFlatFallback({
546
+ designRoot: ctx.paths.designRoot,
547
+ designRel: ctx.paths.designRel,
548
+ });
549
+ }
550
+
425
551
  const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
426
552
  // DDR-064 pre-cutover A4 + A6 — two files must never share a document, and
427
553
  // the pinned set must be bounded. See `admitCanvases`.
@@ -445,25 +571,114 @@ export function createSyncRuntime(
445
571
  localCanvases.map((c) => docNameFor(c.slug)),
446
572
  remoteDocs
447
573
  );
574
+ // PROVISIONAL targets. The listing carries names and byte counts only — a
575
+ // document's own `syncMeta.path` lives INSIDE it, so every target here is
576
+ // the fallback, and each one is re-resolved in `handleSynced` once that
577
+ // document has actually synced. See `relocatePulled` below.
578
+ //
579
+ // A FRESH LINK HAS DECLARED NOTHING. A design root with no canvases of its
580
+ // own and no `config.json` is a folder somebody just pointed at a project.
581
+ // `config.json` is not synced, so such a peer has only the DEFAULT groups
582
+ // (`system`, `ui`) — and a project whose author calls their group `screens`
583
+ // would have every single incoming path refused as out-of-group and land
584
+ // flat and invisible. That is the empty-folder case, and it is the one this
585
+ // whole change exists for.
586
+ //
587
+ // So on that one boot, an undeclared group is accepted (rules 1-7 are
588
+ // untouched — a path still has to slug back to its own document), and the
589
+ // groups actually seen are then WRITTEN into a config.json, additively. The
590
+ // relaxation therefore applies once: the next boot has a config.
591
+ // EMPTINESS IS A FACT ABOUT THE FOLDER, not about the scan. `scanCanvases`
592
+ // walks only DECLARED groups and applies the syncable + sandbox gates, so a
593
+ // project with real work in it scans to zero whenever `syncTsx:false`, the
594
+ // sandbox is off, or its canvases sit in a group it never declared — and
595
+ // treating that as "a bare folder somebody just pointed at a project" would
596
+ // let a hub author a `config.json` into a project that had work in it.
597
+ let freshLink =
598
+ localCanvases.length === 0 && !existsSync(designConfigPath(ctx)) && designRootIsBare(ctx);
599
+ const learnedGroups = new Set<string>();
600
+ // True once THIS boot wrote the config. Until then an existing file is
601
+ // somebody's own declaration and is never touched; afterwards it is ours to
602
+ // extend as further groups arrive.
603
+ let ownsSeededConfig = false;
604
+ const noteLearnedGroup = (group: string): void => {
605
+ if (!freshLink || learnedGroups.has(group)) return;
606
+ learnedGroups.add(group);
607
+ if (existsSync(designConfigPath(ctx)) && !ownsSeededConfig) return;
608
+ if (seedProjectConfig(ctx, learnedGroups, ownsSeededConfig)) ownsSeededConfig = true;
609
+ // ONE GROUP, ONCE. `freshLink` was a `const`, so after the first config
610
+ // was written every FURTHER undeclared group was accepted and appended —
611
+ // the relaxation perpetuating itself instead of closing, and a hub free to
612
+ // plant an unbounded set of directories in a single session. The project
613
+ // has now declared itself; everything after this is checked against that
614
+ // declaration like any other boot.
615
+ freshLink = false;
616
+ pathOpts.allowUndeclaredGroup = false;
617
+ };
618
+ const pathOpts: {
619
+ designRel: string;
620
+ canvasGroups: Context['cfg']['canvasGroups'];
621
+ allowUndeclaredGroup: boolean;
622
+ onRefused: (slug: string, reason: string) => void;
623
+ } = {
624
+ designRel: ctx.paths.designRel,
625
+ canvasGroups: ctx.cfg.canvasGroups,
626
+ allowUndeclaredGroup: freshLink,
627
+ onRefused: (slug: string, reason: string) =>
628
+ console.warn(`[sync/${slug}] ignoring the path this document carries — ${reason}`),
629
+ };
448
630
  const pulled = pullTargets(
449
631
  remoteDiff.hubOnly,
450
632
  ctx.paths.designRoot,
451
633
  path.join,
452
634
  path.resolve,
453
- path.sep
635
+ path.sep,
636
+ { ...pathOpts, realpath: realpathOfDeepestExisting }
454
637
  );
455
638
  const pullNote = describeRemoteDiff(remoteDiff);
456
639
  if (pullNote) console.log(`[sync] ${pullNote}`);
640
+ /** Descriptor paths for one slug at one body path. The sidecar rules live
641
+ * here, once: `.meta.json`/`.css` are SIBLINGS of the body, while
642
+ * `.annotations.svg` is keyed by the flat slug at the design root — the
643
+ * asymmetry `workspace-files.mjs` documents, and which moving the body
644
+ * must not quietly change. */
645
+ const descriptorFor = (slug: string, bodyAbs: string): CanvasDescriptor => ({
646
+ slug,
647
+ html: bodyAbs,
648
+ comments: path.join(ctx.paths.commentsDir, `${slug}.json`),
649
+ annotations: path.join(ctx.paths.designRoot, `${slug}.annotations.svg`),
650
+ meta: bodyAbs.replace(/\.tsx$/i, '.meta.json'),
651
+ css: bodyAbs.replace(/\.tsx$/i, '.css'),
652
+ });
653
+ // Which slugs came DOWN this run. Only these get their body path re-decided
654
+ // after their document syncs — a canvas already on this disk has its path
655
+ // from the disk, and letting the wire move it would be exactly the
656
+ // "a peer relocates another peer's work" hazard the hub refuses too.
657
+ // A PULL MAY NEVER TARGET A FILE THAT IS ALREADY ON THIS DISK.
658
+ //
659
+ // "Hub-only" means "no local DESCRIPTOR", which is not the same as "no local
660
+ // file": `scanCanvases` omits a canvas whose `.meta.json` says
661
+ // `syncable: false` (a security opt-out a hub must not be able to flip) and
662
+ // one the sandbox gate excluded. Such a canvas is classified hub-only and
663
+ // pulled, and its target — whether the fallback or a carried path — is the
664
+ // real file. Note the fallback collides on its own: `ui-card` falls back to
665
+ // `ui/card.tsx`, which IS `ui/Card.tsx` on a case-insensitive filesystem, so
666
+ // checking only the carried path would leave the same overwrite reachable
667
+ // with no path at all.
668
+ //
669
+ // Refusing the canvas is the conservative answer and the recoverable one:
670
+ // the project keeps the file it has, and the document is still on the hub.
671
+ //
672
+ // APPLIED TO THE FALLBACK TOO, not only to a carried path. The fallback is
673
+ // derived from the slug and the slug is derived from the path, so it lands
674
+ // in the same place: `system-colors_and_type` falls back to
675
+ // `system/colors_and_type.tsx` whether or not a path arrives. Checking only
676
+ // the carried path leaves every one of these reachable with no path at all.
677
+ const admittedPulls = pulled.filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs));
678
+ const pulledSlugs = new Set(admittedPulls.map((t) => t.slug));
457
679
  const canvases = [
458
680
  ...localCanvases,
459
- ...pulled.map((t) => ({
460
- slug: t.slug,
461
- html: t.bodyAbs,
462
- comments: path.join(ctx.paths.commentsDir, `${t.slug}.json`),
463
- annotations: path.join(ctx.paths.designRoot, `${t.slug}.annotations.svg`),
464
- meta: t.bodyAbs.replace(/\.tsx$/i, '.meta.json'),
465
- css: t.bodyAbs.replace(/\.tsx$/i, '.css'),
466
- })),
681
+ ...admittedPulls.map((t) => descriptorFor(t.slug, t.bodyAbs)),
467
682
  ];
468
683
  // T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
469
684
  // content, so the whole set is untrusted Claude-context. Mark it (writes
@@ -476,7 +691,22 @@ export function createSyncRuntime(
476
691
  // into the tenant's repository, which the hub would then commit and mirror
477
692
  // to their GitHub — a change to somebody's repo that nobody asked for. The
478
693
  // canvases are no less untrusted; the audience for the marker is absent.
479
- if (!cellPairing) writeUntrustedMarkers(ctx, canvases, linkedHub.url);
694
+ //
695
+ // MARKED AT THE PATH THE BODY ACTUALLY LANDS AT. A pulled canvas's target is
696
+ // provisional here — the listing carries no path, so every pulled entry is
697
+ // the fallback, and `relocatePulled` moves it once that document arrives.
698
+ // The markers used to be computed ONLY from this provisional set and never
699
+ // recomputed, so for a hub-only document carrying a nested path the
700
+ // `_untrusted/INDEX.json` + `.claudeignore` block named a file that is never
701
+ // created, while the genuinely hub-pushed body sat at the real path listed
702
+ // nowhere. That is the DDR-054 §3 F3 control pointing at a phantom.
703
+ //
704
+ // So it is written twice: once now (so the markers exist before any provider
705
+ // is built) and once after the pulls settle, from the final descriptors.
706
+ const markUntrusted = (): void => {
707
+ if (!cellPairing) writeUntrustedMarkers(ctx, canvases, linkedHub.url);
708
+ };
709
+ markUntrusted();
480
710
  if (canvases.length === 0) {
481
711
  // DDR-060 / 9.1-D — the silent early-return made linked mode look healthy
482
712
  // while syncing nothing (TSX-only projects: discovery admits .html only,
@@ -625,22 +855,112 @@ export function createSyncRuntime(
625
855
  console.warn(`[sync] hub auth rejections (${linkedHub.url}):\n${lines.join('\n')}`);
626
856
  };
627
857
 
858
+ /** Reconnect every permanently-rejected doc now. Idempotent — the map is
859
+ * drained, so a second call during the same burst is a no-op. */
860
+ const reprobeNow = (): void => {
861
+ if (stopped) return;
862
+ if (reprobeTimer !== null) {
863
+ authClearTimer(reprobeTimer);
864
+ reprobeTimer = null;
865
+ }
866
+ const entries = [...rejectedPermanent.values()];
867
+ rejectedPermanent.clear();
868
+ for (const entry of entries) {
869
+ mon.noteDocState(entry.canvas.slug, 'pending');
870
+ void connectCanvas(entry.canvas, entry.canvasPaths, entry.doc).catch((err) => {
871
+ console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
872
+ });
873
+ }
874
+ };
875
+
628
876
  const scheduleReprobe = (): void => {
629
877
  if (reprobeTimer !== null || stopped) return;
630
878
  reprobeTimer = authSetTimer(() => {
631
879
  reprobeTimer = null;
632
- if (stopped) return;
633
- const entries = [...rejectedPermanent.values()];
634
- rejectedPermanent.clear();
635
- for (const entry of entries) {
636
- mon.noteDocState(entry.canvas.slug, 'pending');
637
- void connectCanvas(entry.canvas, entry.canvasPaths, entry.doc).catch((err) => {
638
- console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
639
- });
640
- }
880
+ reprobeNow();
641
881
  }, reprobeMs);
642
882
  };
643
883
 
884
+ /**
885
+ * Renew the hub credential in place — single-flight, silent, and never
886
+ * worse than failure: an unrenewable credential leaves the stored one
887
+ * untouched and every existing behaviour (slow re-probe, refused status)
888
+ * exactly as it was.
889
+ */
890
+ const renewCredentialNow = (): Promise<boolean> => {
891
+ if (!renewCredential || stopped) return Promise.resolve(false);
892
+ if (renewInFlight) return renewInFlight;
893
+ // F1 — the cap: renewals that keep succeeding without a handshake landing
894
+ // are not fixing anything (a real fix clears at least one doc, which
895
+ // resets this to 0). Stop, and let the docs' own rejected state surface
896
+ // as refused/stalled — both name "reconnect the workspace".
897
+ if (renewalsSinceProgress >= renewMaxWithoutProgress) return Promise.resolve(false);
898
+ // F1 — the floor: one renewal per interval, whatever the outcome. Stamped
899
+ // here (commit point), not on success, so a failing renewal also waits.
900
+ const sinceLast = renewNow() - lastRenewAt;
901
+ if (lastRenewAt !== 0 && sinceLast < renewMinIntervalMs) return Promise.resolve(false);
902
+ lastRenewAt = renewNow();
903
+ renewInFlight = (async () => {
904
+ try {
905
+ const fresh = await renewCredential();
906
+ if (stopped || !fresh || typeof fresh.token !== 'string' || !fresh.token) return false;
907
+ token = fresh.token;
908
+ tokenExpiresAt = validExpiry(fresh.expiresAt);
909
+ renewalsSinceProgress++;
910
+ console.log(`[sync] hub credential renewed for ${linkedHub.url}`);
911
+ scheduleRenewal();
912
+ return true;
913
+ } catch (err) {
914
+ console.warn(`[sync] hub credential renewal failed: ${(err as Error).message}`);
915
+ return false;
916
+ } finally {
917
+ renewInFlight = null;
918
+ }
919
+ })();
920
+ return renewInFlight;
921
+ };
922
+
923
+ /**
924
+ * Arm the pre-expiry renewal at ~80 % of the credential's REMAINING life
925
+ * (never sooner than a minute out). No stored expiry — a self-hosted hub,
926
+ * or a credential written before expiry was persisted — means no timer:
927
+ * exactly the old behaviour. A failed renewal retries on the re-probe
928
+ * cadence; once the credential actually dies, the invalid-token path
929
+ * below triggers renewal anyway, so the timer is an optimization, not the
930
+ * safety net.
931
+ */
932
+ const scheduleRenewal = (): void => {
933
+ if (renewTimer !== null) {
934
+ authClearTimer(renewTimer);
935
+ renewTimer = null;
936
+ }
937
+ if (!renewCredential || stopped || tokenExpiresAt === null) return;
938
+ // F2 — clamp both ends. The lower floor keeps a near-expiry credential
939
+ // from arming an immediate timer; the upper clamp stops a far-future
940
+ // `expiresAt` (validExpiry already rejects > 30 d, but a peer's clock
941
+ // skew can still land the *delay* past setTimeout's int32 ceiling, where
942
+ // it silently fires at 1 ms) from becoming a tight loop.
943
+ const raw = (tokenExpiresAt - renewNow()) * 0.8;
944
+ const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(60_000, raw));
945
+ renewTimer = authSetTimer(() => {
946
+ renewTimer = null;
947
+ void renewCredentialNow().then((ok) => {
948
+ // F4 — a failed pre-expiry renewal RE-ARMS on the slow cadence
949
+ // (through scheduleRenewal, so the frequency floor + cap still
950
+ // apply) instead of leaving the process with no scheduled renewal
951
+ // for the rest of its life.
952
+ if (!ok && !stopped && tokenExpiresAt !== null) {
953
+ renewTimer = authSetTimer(() => {
954
+ renewTimer = null;
955
+ void renewCredentialNow().then((ok2) => {
956
+ if (!ok2) scheduleRenewal();
957
+ });
958
+ }, reprobeMs);
959
+ }
960
+ });
961
+ }, delay);
962
+ };
963
+
644
964
  const handleAuthFailure = (
645
965
  canvas: CanvasDescriptor,
646
966
  canvasPaths: import('./agent.ts').CanvasSyncPaths,
@@ -669,6 +989,52 @@ export function createSyncRuntime(
669
989
  }
670
990
  scheduleReprobe();
671
991
  }
992
+ // An invalid token is the one refusal the runtime can FIX: the stored
993
+ // credential expired (≤ 12 h cell sessions, Phase 23 B2) while the
994
+ // account next to it is still signed in. Renew silently — single-flight
995
+ // across the whole burst — and on success reconnect the rejected docs
996
+ // NOW instead of making the user wait out the slow re-probe (or press
997
+ // Connect again, which is all this ever needed).
998
+ if (reasonClass === 'invalid-token') {
999
+ void renewCredentialNow().then((renewed) => {
1000
+ if (renewed) reprobeNow();
1001
+ });
1002
+ }
1003
+ }
1004
+ };
1005
+
1006
+ /**
1007
+ * A HANDSHAKE THAT COMPLETED IS NOT A REJECTED DOCUMENT.
1008
+ *
1009
+ * `auth-rejected` is deliberately sticky — a dropped socket must not launder
1010
+ * a rotated credential into a spinner. But the verdict is about the HUB'S
1011
+ * ANSWER, and the hub has just given a different one: this document
1012
+ * connected. Clearing it here, at the top of the post-handshake path, means
1013
+ * a re-probe that succeeds clears the record even if the reconcile below
1014
+ * then fails for a reason that has nothing to do with authentication — which
1015
+ * is how `0 synced · 73 rejected` survived on a link the hub was accepting.
1016
+ *
1017
+ * A GENUINE rejection still says so: nothing clears until a handshake for
1018
+ * that document actually completes.
1019
+ */
1020
+ const clearRejection = (slug: string): void => {
1021
+ rejectedPermanent.delete(slug);
1022
+ if (!rejectedReasons.delete(slug)) return;
1023
+ console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
1024
+ };
1025
+
1026
+ /**
1027
+ * Stamp `syncMeta.path` from where this canvas actually is on THIS disk —
1028
+ * never echoed from the wire, so a value some receiver refused is never
1029
+ * laundered onward by being re-sent. Best-effort: a failure never costs
1030
+ * the canvas its sync.
1031
+ */
1032
+ const stampFromLocalFile = (doc: Y.Doc, htmlAbs: string): void => {
1033
+ try {
1034
+ const rel = path.relative(ctx.paths.designRoot, htmlAbs).split(path.sep).join('/');
1035
+ if (rel && !rel.startsWith('..')) stampCanvasPath(doc, rel, ORIGINS.DISK_PROJECTION);
1036
+ } catch {
1037
+ /* best-effort bookkeeping — never costs the canvas its sync */
672
1038
  }
673
1039
  };
674
1040
 
@@ -679,6 +1045,11 @@ export function createSyncRuntime(
679
1045
  provider: SyncProvider
680
1046
  ): Promise<void> => {
681
1047
  if (stopped) return;
1048
+ clearRejection(canvas.slug);
1049
+ // Not `connected` yet — the reconcile below is what makes that true. But
1050
+ // no longer refused, and the difference is the whole point: `pending` says
1051
+ // "still settling", `auth-rejected` says "go fix your credential".
1052
+ mon.noteDocState(canvas.slug, 'pending');
682
1053
  const projection = projections.get(canvas.slug);
683
1054
  const agent = agents.get(canvas.slug);
684
1055
  if (projection) {
@@ -728,10 +1099,41 @@ export function createSyncRuntime(
728
1099
  }
729
1100
  }
730
1101
  }
1102
+ // The path travels back OUT — re-stamped post-reconcile as the belt to
1103
+ // connectCanvas's pre-handshake braces (fix 5): this also covers a PULLED
1104
+ // canvas, whose real local path exists only after relocatePulled ran.
1105
+ stampFromLocalFile(provider.document, canvasPaths.html);
1106
+
731
1107
  // DDR-102 — honest status: the handshake + reconcile completed.
732
1108
  mon.noteDocState(canvas.slug, 'connected');
733
1109
  mon.noteSyncActivity(canvas.slug);
734
- rejectedReasons.delete(canvas.slug);
1110
+ // F1 — a completed handshake IS progress: a renewal actually helped, so
1111
+ // the no-progress cap resets. Without this a healthy link that renews
1112
+ // legitimately every 12 h would burn one cap slot per renewal forever.
1113
+ renewalsSinceProgress = 0;
1114
+ };
1115
+
1116
+ /**
1117
+ * `handleSynced`, with the one guarantee its body cannot make for itself.
1118
+ *
1119
+ * Everything from `migrateSeed` to `agent.reconcile()` can throw, and the
1120
+ * rejection was swallowed by `settleWait` — so a document whose reconcile
1121
+ * failed never reached the `connected` line and sat on whatever its last
1122
+ * verdict was, forever, with nothing on screen or in the log saying why.
1123
+ * A reconcile failure is a real failure and is now LOUD; it leaves the
1124
+ * document `pending` (set at the top of `handleSynced`), which is what it
1125
+ * is: connected to the hub, not yet settled on disk.
1126
+ */
1127
+ const runHandleSynced = async (
1128
+ canvas: CanvasDescriptor,
1129
+ canvasPaths: import('./agent.ts').CanvasSyncPaths,
1130
+ provider: SyncProvider
1131
+ ): Promise<void> => {
1132
+ try {
1133
+ await handleSynced(canvas, canvasPaths, provider);
1134
+ } catch (err) {
1135
+ console.error(`[sync/${canvas.slug}] post-handshake reconcile failed:`, err);
1136
+ }
735
1137
  };
736
1138
 
737
1139
  /** onceSynced() with the boot-settle ceiling — never hangs the summary on
@@ -792,6 +1194,84 @@ export function createSyncRuntime(
792
1194
  return null;
793
1195
  };
794
1196
 
1197
+ /**
1198
+ * Re-decide where a PULLED canvas goes, now that its document has synced.
1199
+ *
1200
+ * The listing (`GET /api/documents`) carries names and byte counts only —
1201
+ * the path lives INSIDE the document, so it cannot be known when the target
1202
+ * is first computed. This runs in the gap: after the handshake, before
1203
+ * anything is written. Nothing is on disk yet for a pulled canvas, so this
1204
+ * is a decision rather than a move.
1205
+ *
1206
+ * Local canvases never reach here. Their path comes from this disk, and
1207
+ * letting a remote value relocate them is the same hazard the hub refuses
1208
+ * with `pathIndex` — a peer moving another peer's work.
1209
+ */
1210
+ const relocatePulled = (
1211
+ canvas: CanvasDescriptor,
1212
+ canvasPaths: import('./agent.ts').CanvasSyncPaths,
1213
+ doc: Y.Doc
1214
+ ): void => {
1215
+ if (!pulledSlugs.has(canvas.slug)) return;
1216
+ const resolved = resolvePulledTarget({
1217
+ slug: canvas.slug,
1218
+ path: canvasPathFromDoc(doc),
1219
+ designRoot: ctx.paths.designRoot,
1220
+ designRel: ctx.paths.designRel,
1221
+ canvasGroups: ctx.cfg.canvasGroups,
1222
+ join: path.join,
1223
+ resolve: path.resolve,
1224
+ sep: path.sep,
1225
+ realpath: realpathOfDeepestExisting,
1226
+ allowUndeclaredGroup: pathOpts.allowUndeclaredGroup,
1227
+ onRefused: (reason) => pathOpts.onRefused(canvas.slug, reason),
1228
+ });
1229
+ if (!resolved) return;
1230
+
1231
+ // NEVER ONTO A FILE THAT ALREADY EXISTS.
1232
+ //
1233
+ // `relocatePulled`'s premise is that nothing is on disk for a pulled
1234
+ // canvas — but "pulled" only means "no LOCAL DESCRIPTOR", and `scanCanvases`
1235
+ // omits a canvas whose `.meta.json` says `syncable: false` (a security
1236
+ // opt-out) or whose `.tsx` the sandbox gate excluded. Such a canvas is
1237
+ // classified hub-only and pulled, and before this feature that was benign:
1238
+ // the body landed flat at the design root, inside no canvas group, loaded
1239
+ // by nothing. Honouring a remote path would land it on the real file and
1240
+ // let a hub overwrite exactly the canvas the user opted OUT of syncing.
1241
+ // The same admission the provisional target already passed, re-asked of
1242
+ // the destination the document actually chose.
1243
+ if (resolved.fromPath && !admitPullTarget(ctx, canvas.slug, resolved.bodyAbs)) return;
1244
+ // The TOP-level component only — `canvasGroups` names a group, not every
1245
+ // folder inside it (`ui/2026/social/x.tsx` declares `ui`). A body that
1246
+ // landed at the design root has no group and teaches nothing.
1247
+ const [group, ...rest] = path
1248
+ .relative(ctx.paths.designRoot, resolved.bodyAbs)
1249
+ .split(path.sep);
1250
+ if (group && rest.length > 0) noteLearnedGroup(group);
1251
+ if (resolved.bodyAbs === canvas.html) return;
1252
+ const next = descriptorFor(canvas.slug, resolved.bodyAbs);
1253
+ // Mutated in place: the descriptor and the paths object are already held
1254
+ // by the status surfaces and by the setup closure below, and handing them
1255
+ // a second object would leave half the runtime writing to the old path.
1256
+ Object.assign(canvas, next);
1257
+ canvasPaths.html = next.html;
1258
+ canvasPaths.meta = next.meta;
1259
+ canvasPaths.css = next.css;
1260
+ console.log(
1261
+ `[sync/${canvas.slug}] pulled into ${path.relative(ctx.paths.designRoot, next.html)}`
1262
+ );
1263
+ // RE-MARK NOW, not at the end of boot. The markers were computed from the
1264
+ // provisional descriptor set and the descriptors are mutated in place
1265
+ // here, so between this line and the end of boot the `_untrusted` index
1266
+ // would name a file that does not exist while the hub-pushed body it
1267
+ // exists to flag sits somewhere unlisted. Deferring the re-mark to the
1268
+ // boot-settle handler leaves exactly that window open — and that handler
1269
+ // is fire-and-forget, so a short-lived process never reaches it at all.
1270
+ // One small write per relocation is the right price for a marker that is
1271
+ // never wrong.
1272
+ markUntrusted();
1273
+ };
1274
+
795
1275
  const connectCanvas = async (
796
1276
  canvas: CanvasDescriptor,
797
1277
  canvasPaths: import('./agent.ts').CanvasSyncPaths,
@@ -805,11 +1285,28 @@ export function createSyncRuntime(
805
1285
  document,
806
1286
  });
807
1287
  providers.set(canvas.slug, provider);
1288
+ // Fix 5 (sync RCA 2026-08-10): stamp the canvas path BEFORE the
1289
+ // handshake, not only after reconcile. The path derives from this peer's
1290
+ // real local file, so it is known NOW — and the hub's FIRST
1291
+ // onDocumentStored must see it, or it memoises a flat fallback in its
1292
+ // pathIndex and a stub is born. Pulled canvases are the one exception:
1293
+ // their local path is a guess until the document arrives, and a guessed
1294
+ // stamp would be laundered into every other peer (handleSynced stamps
1295
+ // them after relocatePulled instead).
1296
+ if (!pulledSlugs.has(canvas.slug)) {
1297
+ stampFromLocalFile(provider.document, canvasPaths.html);
1298
+ }
808
1299
  // First-connect setup (agent/projection creation + doc-scoped wiring)
809
- // MUST run before the onceSynced chain below — handleSynced resolves the
1300
+ // MUST run before handleSynced — that function resolves the
810
1301
  // agent/projection from the maps, and a test stub's onceSynced can
811
1302
  // settle on the very next microtask.
812
- setup?.(provider);
1303
+ //
1304
+ // For a PULLED canvas it must run AFTER the handshake instead, because
1305
+ // the agent is constructed around a body path this peer cannot know until
1306
+ // the document arrives. Ordering, not skipping: the two still happen in
1307
+ // the same order relative to each other.
1308
+ const deferSetup = !!setup && pulledSlugs.has(canvas.slug);
1309
+ if (!deferSetup) setup?.(provider);
813
1310
 
814
1311
  // Task 8 — feed this provider's WS status into the offline monitor.
815
1312
  if (provider.onStatus) {
@@ -834,7 +1331,13 @@ export function createSyncRuntime(
834
1331
  awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
835
1332
  }
836
1333
  // Cold-start reconcile fires once the provider has hub state.
837
- const synced = provider.onceSynced().then(() => handleSynced(canvas, canvasPaths, provider));
1334
+ const synced = provider.onceSynced().then(() => {
1335
+ if (deferSetup) {
1336
+ relocatePulled(canvas, canvasPaths, provider.document);
1337
+ setup?.(provider);
1338
+ }
1339
+ return runHandleSynced(canvas, canvasPaths, provider);
1340
+ });
838
1341
  bootWaits.push(settleWait(synced));
839
1342
  return provider;
840
1343
  };
@@ -1029,8 +1532,32 @@ export function createSyncRuntime(
1029
1532
  // the pull, so it named exactly the canvases that had just arrived and
1030
1533
  // were sitting on disk. `pulled` is the same list under the name that is
1031
1534
  // true, and it is the fact the user is told to act on.
1032
- mon.notePulled(pulled.map((t) => t.slug));
1535
+ mon.notePulled(admittedPulls.map((t) => t.slug));
1536
+ // Re-mark from the FINAL descriptors — `relocatePulled` mutates them in
1537
+ // place after each handshake, and the markers are the one consumer that
1538
+ // read them before that and would otherwise never read them again.
1539
+ markUntrusted();
1540
+
1541
+ // DDR-217 (fix 6) — mirror local assets up AFTER the handshakes settle
1542
+ // (they carry the canvases; assets ride behind, never in front). Not
1543
+ // under pairing: a cell's assets are already on the cell. Fire-and-forget
1544
+ // — a miss is retried on the next boot for free.
1545
+ if (!cellPairing) {
1546
+ void pushAssets({
1547
+ designRoot: ctx.paths.designRoot,
1548
+ hubUrl: linkedHub.url,
1549
+ token: () => token,
1550
+ }).catch((err) => {
1551
+ console.warn(`[sync/assets] asset push failed: ${(err as Error).message}`);
1552
+ });
1553
+ }
1033
1554
  });
1555
+
1556
+ // Arm the pre-expiry renewal from the credential that just booted. Placed
1557
+ // last — the timer needs nothing from boot, and boot needs nothing from it
1558
+ // (a credential that dies mid-boot lands in the invalid-token path, which
1559
+ // triggers renewal on its own).
1560
+ scheduleRenewal();
1034
1561
  }
1035
1562
 
1036
1563
  async function stop(): Promise<void> {
@@ -1086,6 +1613,10 @@ export function createSyncRuntime(
1086
1613
  authClearTimer(reprobeTimer);
1087
1614
  reprobeTimer = null;
1088
1615
  }
1616
+ if (renewTimer !== null) {
1617
+ authClearTimer(renewTimer);
1618
+ renewTimer = null;
1619
+ }
1089
1620
  for (const h of settleTimers) authClearTimer(h);
1090
1621
  settleTimers.clear();
1091
1622
  for (const h of announceTimers.values()) clearTimeout(h);
@@ -1399,6 +1930,198 @@ async function walk(
1399
1930
  }
1400
1931
  }
1401
1932
 
1933
+ /**
1934
+ * Blank the persisted status for a process that has just started.
1935
+ *
1936
+ * Deliberately NOT a rehydration. Presentation state (which hub, how many
1937
+ * canvases) is knowable up front; a per-document verdict is not — it is the
1938
+ * outcome of a handshake this process has yet to make. Carrying one over is how
1939
+ * a stale `auth-rejected` outlives the credential rotation that fixed it.
1940
+ *
1941
+ * Best-effort, like every other write to this file: a status that cannot be
1942
+ * written must not stop a project from syncing.
1943
+ */
1944
+ function resetPersistedStatus(ctx: Context, url: string): void {
1945
+ try {
1946
+ atomicWrite(
1947
+ path.join(ctx.paths.designRoot, '_sync.json'),
1948
+ `${JSON.stringify(
1949
+ {
1950
+ url,
1951
+ canvases: 0,
1952
+ conflicts: [],
1953
+ state: 'connecting',
1954
+ queuedOps: 0,
1955
+ lastSyncAt: null,
1956
+ offlineSince: null,
1957
+ flash: null,
1958
+ updatedAt: Date.now(),
1959
+ docs: { synced: 0, pending: 0, rejected: 0 },
1960
+ },
1961
+ null,
1962
+ 2
1963
+ )}\n`
1964
+ );
1965
+ } catch {
1966
+ /* best-effort — see the doc comment */
1967
+ }
1968
+ }
1969
+
1970
+ /**
1971
+ * May a pulled canvas be materialised at this path?
1972
+ *
1973
+ * Asked of the PROVISIONAL target and again of whatever the document's own
1974
+ * `syncMeta.path` resolves to, because the two can be the same place: the
1975
+ * fallback is derived from the slug and the slug from the path, so
1976
+ * `system-colors_and_type` targets `system/colors_and_type.tsx` with or without
1977
+ * a path on the wire. A guard on the carried path alone is a guard on the
1978
+ * loudest half of the problem.
1979
+ *
1980
+ * Two refusals, both about what ALREADY occupies the location — which is
1981
+ * precisely what rule 7 does not speak to. Rule 7 ties a path to its own
1982
+ * DOCUMENT; it has nothing to say about the file already sitting there.
1983
+ */
1984
+ function admitPullTarget(ctx: Context, slug: string, bodyAbs: string): boolean {
1985
+ const rel = path.relative(ctx.paths.designRoot, bodyAbs);
1986
+ // 1. A file that is already on this disk. "Hub-only" means "no local
1987
+ // DESCRIPTOR", not "no local file": `scanCanvases` omits a canvas whose
1988
+ // `.meta.json` says `syncable: false` — a security opt-out a hub must not
1989
+ // be able to flip — and one the TSX sandbox gate excluded. Such a canvas is
1990
+ // classified hub-only and pulled, and its target is the real file. Note
1991
+ // `existsSync` settles the case-insensitive collision for free: `ui/card.tsx`
1992
+ // IS `ui/Card.tsx` on macOS, and that is exactly how the fallback reaches a
1993
+ // file the project meant to keep out of the sync set.
1994
+ if (existsSync(bodyAbs)) {
1995
+ console.warn(
1996
+ `[sync/${slug}] not pulling — ${rel} already exists on this machine and is not in ` +
1997
+ "this project's sync set (a `syncable: false` sidecar, or the TSX sandbox gate). " +
1998
+ 'The local file is kept.'
1999
+ );
2000
+ return false;
2001
+ }
2002
+ // 2. A file that means something other than "a canvas". The `.css` and
2003
+ // `.meta.json` siblings are derived from the body path and `system` is a
2004
+ // DEFAULT canvas group, so `system-colors_and_type` writes its css lane
2005
+ // straight over `tokensCssRel` — the stylesheet the dev server serves.
2006
+ if (collidesWithServedPaths(ctx, bodyAbs)) {
2007
+ console.warn(`[sync/${slug}] not pulling — ${rel} would overwrite a served project file.`);
2008
+ return false;
2009
+ }
2010
+ return true;
2011
+ }
2012
+
2013
+ /**
2014
+ * True when the design root holds nothing but runtime state.
2015
+ *
2016
+ * The emptiness question the fresh-link relaxation actually needs to ask. It is
2017
+ * NOT "did the scan find canvases": the scan walks declared groups only and
2018
+ * applies the syncable + sandbox gates, so it returns zero for several projects
2019
+ * that are anything but bare.
2020
+ */
2021
+ function designRootIsBare(ctx: Context): boolean {
2022
+ try {
2023
+ return readdirSync(ctx.paths.designRoot).every(
2024
+ (name) => name.startsWith('_') || name === '.git'
2025
+ );
2026
+ } catch {
2027
+ // No design root at all is as bare as it gets.
2028
+ return true;
2029
+ }
2030
+ }
2031
+
2032
+ /**
2033
+ * True when a pulled body's sidecars would land on a file that means something
2034
+ * other than "a canvas".
2035
+ *
2036
+ * `.css` and `.meta.json` are derived from the body path, and `system` is a
2037
+ * DEFAULT canvas group — so a hub-chosen path inside it can put an attacker's
2038
+ * css lane exactly where `tokensCssRel` is served from. Rule 7 ties a path to
2039
+ * its own DOCUMENT; it says nothing about what already occupies that location.
2040
+ */
2041
+ function collidesWithServedPaths(ctx: Context, bodyAbs: string): boolean {
2042
+ const served = new Set<string>();
2043
+ const add = (rel: unknown): void => {
2044
+ if (typeof rel === 'string' && rel) served.add(path.resolve(ctx.paths.designRoot, rel));
2045
+ };
2046
+ add(ctx.cfg.tokensCssRel);
2047
+ for (const ds of ctx.cfg.designSystems ?? []) add(ds?.tokensCssRel);
2048
+ add('config.json');
2049
+ const stem = bodyAbs.replace(/\.tsx$/i, '');
2050
+ return [bodyAbs, `${stem}.css`, `${stem}.meta.json`].some((p) => served.has(path.resolve(p)));
2051
+ }
2052
+
2053
+ /**
2054
+ * `realpathSync`, but for a path that does not exist yet.
2055
+ *
2056
+ * `realpathSync` throws ENOENT on the file we are about to create, so walk up to
2057
+ * the deepest ancestor that DOES exist, resolve that, and re-attach the tail.
2058
+ * Any symlink already on the path is therefore followed, which is the whole
2059
+ * point: `path.resolve` is lexical, and `mkdirSync(recursive: true)` traverses a
2060
+ * symlinked directory without complaint.
2061
+ */
2062
+ function realpathOfDeepestExisting(p: string): string {
2063
+ let cur = p;
2064
+ for (;;) {
2065
+ try {
2066
+ return path.join(realpathSync(cur), path.relative(cur, p));
2067
+ } catch {
2068
+ const parent = path.dirname(cur);
2069
+ if (parent === cur) return p;
2070
+ cur = parent;
2071
+ }
2072
+ }
2073
+ }
2074
+
2075
+ /** `<designRoot>/config.json` — the project's own declaration of itself. */
2076
+ function designConfigPath(ctx: Context): string {
2077
+ return path.join(ctx.paths.designRoot, 'config.json');
2078
+ }
2079
+
2080
+ /**
2081
+ * Give a freshly-linked, previously-empty folder a config of its own.
2082
+ *
2083
+ * A project pulled into a bare directory has no `config.json` (it is not part
2084
+ * of the sync lane), so it runs on the DEFAULT canvas groups — and a project
2085
+ * whose author calls their group `screens` would be listed by nothing. This
2086
+ * writes what the pull actually brought down, so the next boot needs no
2087
+ * relaxation and the tree lists the project it just received.
2088
+ *
2089
+ * ADDITIVE AND ONE-SHOT. It refuses outright if a config already exists — a
2090
+ * user's own declaration is never edited by the sync runtime, and the caller's
2091
+ * `freshLink` gate means this cannot run on a project that had canvases.
2092
+ * Best-effort: a read-only design root costs the project its tidiness, never
2093
+ * its sync.
2094
+ */
2095
+ function seedProjectConfig(
2096
+ ctx: Context,
2097
+ learnedGroups: ReadonlySet<string>,
2098
+ owned: boolean
2099
+ ): boolean {
2100
+ const file = designConfigPath(ctx);
2101
+ if (existsSync(file) && !owned) return false;
2102
+ const declared = (ctx.cfg.canvasGroups ?? []).map((g) => g.path);
2103
+ const groups = [...declared, ...[...learnedGroups].filter((g) => !declared.includes(g))];
2104
+ try {
2105
+ atomicWrite(
2106
+ file,
2107
+ `${JSON.stringify(
2108
+ {
2109
+ name: ctx.cfg.name,
2110
+ designRoot: ctx.paths.designRel,
2111
+ canvasGroups: groups.map((p) => ({ label: p, path: p })),
2112
+ },
2113
+ null,
2114
+ 2
2115
+ )}\n`
2116
+ );
2117
+ console.log(`[sync] wrote ${ctx.paths.designRel}/config.json (${groups.join(', ')}).`);
2118
+ return true;
2119
+ } catch (err) {
2120
+ console.warn(`[sync] could not write ${ctx.paths.designRel}/config.json: ${String(err)}`);
2121
+ return false;
2122
+ }
2123
+ }
2124
+
1402
2125
  /**
1403
2126
  * The shape written to `<designRoot>/_sync.json` (and broadcast on the
1404
2127
  * 'sync:status' bus) when the project is linked but has zero syncable