managarten/docs/plans/articles-bulk-import.md
Till JS 7bca16dfa7 feat(articles): bulk-import schema + plan (Phase 1)
Three new sync-tracked Dexie tables under the articles appId:

  articleImportJobs     — job header (counters, status, lease metadata).
  articleImportItems    — one row per URL in a job, state-machine driven.
  articleExtractPickup  — short-lived server→client handoff inbox.

URL stays plaintext on items by necessity — the server-worker reads it
without master-key access, same rationale as articles.originalUrl. The
extracted article eventually lands encrypted in the existing `articles`
table; bulk-import rows hold only pointers.

Plan: docs/plans/articles-bulk-import.md (full architecture, 7 phases,
test matrix, edge-cases). Phase 2 already shipped in 5535f2da4 (worker);
this commit lays the schema underneath it.

Originally committed as b2f4e8314, lost during a parallel reset, here
restored via cherry-pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:11:51 +02:00

23 KiB
Raw Blame History

Articles — Bulk URL Import

Status (2026-04-28)

Phase 0 — Plan: in progress. Phasen 17: offen.

Ziel

User wirft eine Liste von URLs in ein Textfeld (zeilengetrennt), Mana extrahiert + speichert alle Artikel im Hintergrund. Funktioniert auch wenn der Tab schließt, das Gerät wechselt, das Netz kurz weg ist. Der Job überlebt Sessions und ist auf jedem Gerät sichtbar an dem der User eingeloggt ist.

Heute existiert nur Single-URL-Ingestion (AddUrlForm, QuickAddInput, Bookmarklets v1+v2, Share-Target). Alle Pfade rufen am Ende articlesStore.saveFromUrl() oder saveFromExtracted() auf.

Leitsätze

  1. Job-State lebt in der synchronisierten DB, nicht im Tab. Damit fallen Tab-Close-Resilienz, Multi-Device-Sicht, Resume-after-Offline und Audit automatisch ab.
  2. Server macht Extract, Client macht Encrypt. Das ehrt das At-Rest- Modell — der Master-Key bleibt clientseitig, der Server sieht den extrahierten Text nur kurz in einer Pickup-Inbox (gleicher Threat- Model wie heute schon der /extract-Endpoint).
  3. Eine Code-Bahn für jede Ingestion. Single-URL und Bulk laufen nach Phase 7 durch denselben Worker — der QuickAdd-Pfad legt unter der Haube auch einen 1-URL-Job an.
  4. Soft → Hard. Schema- und Semantik-Migrationen kommen in zwei Commits: erst tolerant zu alten Rows, dann hartes Cleanup.

Architektur

            ┌──────────────────────────────────────────┐
            │  /articles/import (List + JobDetail)     │
            │  pure liveQuery-View, UI macht keine     │
            │  Job-Logik selbst                        │
            └─────────────────┬────────────────────────┘
                              │ liveQuery
                              ▼
  ┌────────────────────────────────────────────────────────┐
  │  Dexie + mana-sync  (articles appId)                   │
  │                                                        │
  │   articleImportJobs                                    │
  │     id, spaceId, totalUrls, status, leasedBy,          │
  │     leasedUntil, savedCount, duplicateCount,           │
  │     errorCount, warningCount, finishedAt               │
  │                                                        │
  │   articleImportItems                                   │
  │     id, jobId, spaceId, idx, url, state, articleId,    │
  │     warning, error, attempts, lastAttemptAt            │
  │                                                        │
  │   articleExtractPickup  (kurzlebige Inbox)             │
  │     id, itemId, payload (extracted), createdAt         │
  └─────────────────┬───────────────────────┬──────────────┘
                    │ pending items         │ pickup rows
                    ▼                       ▼
  ┌─────────────────────────────┐  ┌──────────────────────────┐
  │  apps/api  Extract-Worker   │  │  Client  Pickup-Consumer │
  │   • snapshot der Items      │  │   • liveQuery auf Pickup │
  │   • Lease + Heartbeat       │  │   • encryptRecord        │
  │   • Concurrency 3 / User    │  │   • articleTable.add()   │
  │   • shared-rss extractFromUrl│ │   • item.state='saved'   │
  │   • schreibt Pickup-Row     │  │   • pickup.delete()      │
  │   • setzt Item-State        │  │                          │
  └─────────────────────────────┘  └──────────────────────────┘

Datenmodell

articleImportJobs (synced, articles appId)

interface LocalArticleImportJob extends BaseRecord {
  totalUrls: number;
  status: 'queued' | 'running' | 'paused' | 'done' | 'cancelled';

  /** Worker-Lease — verhindert dass mehrere Worker denselben Job ziehen.
   *  Server-Worker stempelt seine workerId beim Claim, erneuert die
   *  leasedUntil per Heartbeat. Lease-Ablauf > 60s = Job ist verfügbar. */
  leasedBy: string | null;
  leasedUntil: string | null;

  startedAt: string | null;
  finishedAt: string | null;

  /** Counters werden vom Server beim Item-Übergang in einen Terminal-State
   *  inkrementiert. Pure Bookkeeping — Truth liegt in den Item-Rows, das
   *  hier ist die Cache-Spalte für die Liste. */
  savedCount: number;
  duplicateCount: number;
  errorCount: number;
  warningCount: number;
}

articleImportItems (synced, articles appId)

type ImportItemState =
  | 'pending'        // wartet auf den Worker
  | 'extracting'     // Worker hat geclaimed
  | 'extracted'      // Pickup-Row liegt für den Client bereit
  | 'saved'          // im Article-Table angekommen
  | 'duplicate'      // Article mit dieser URL gabs schon
  | 'consent-wall'   // gespeichert, aber Cookie-Wand erkannt
  | 'error'          // X Versuche fehlgeschlagen
  | 'cancelled';     // Job abgebrochen vor Verarbeitung

interface LocalArticleImportItem extends BaseRecord {
  jobId: string;
  idx: number;                 // Reihenfolge aus der User-Eingabe
  url: string;                 // PLAINTEXT — Server muss lesen können
  state: ImportItemState;
  articleId: string | null;    // bei saved/duplicate gesetzt
  warning: 'probable_consent_wall' | null;
  error: string | null;
  attempts: number;
  lastAttemptAt: string | null;
}

url bleibt bewusst plaintext — der Server-Worker liest sie aus sync_changes und kann nicht entschlüsseln. Gleiche Begründung wie bei articles.originalUrl / newsArticles.originalUrl / links.originalUrl.

error bleibt plaintext, weil Fehlertexte technisch sind ("502 Bad Gateway") und keinen User-Inhalt enthalten.

articleExtractPickup (synced, articles appId, kurzlebig)

interface LocalArticleExtractPickup extends BaseRecord {
  itemId: string;              // Pointer zum Item — auch dessen jobId
  payload: ExtractedArticle;   // PLAINTEXT — Server hat das eh
  createdAt: string;
}

Inbox-Tabelle. Server schreibt rein, Client liest und löscht. Im Steady-State leer. TTL serverseitig: 24 h, dann GC.

Warum eine eigene Tabelle statt direkt articles schreiben? Der Server hat keinen Master-Key — er kann den Article nicht verschlüsseln. Pickup ist die Übergabe-Pufferzone, der Client holt sie ab und ruft die existierende saveFromExtracted() auf, die encryptRecord() triggert.

Crypto-Registry

// articleImportJobs: keine User-typed Inhalte → plaintext-allowlist
// articleImportItems: url + error sind plaintext, sonst nichts schützenswert → plaintext-allowlist
// articleExtractPickup: payload wird gleich nach Apply gelöscht → plaintext-allowlist

Alle drei landen auf der plaintext-allowlist.ts, nicht in ENCRYPTION_REGISTRY. Items und Job-Rows enthalten keine User-typed- Felder die nicht eh schon plaintext bleiben müssten (URL fürs Routing, Counters, Foreign Keys). Der eigentliche Article-Inhalt wandert wie bisher verschlüsselt in articles.

Module-Config + Sync

modules/articles/module.config.ts bekommt drei neue Tabellen:

tables: [
  { name: 'articles' },
  { name: 'articleHighlights', syncName: 'highlights' },
  { name: 'articleTags' },
  { name: 'articleImportJobs', syncName: 'importJobs' },
  { name: 'articleImportItems', syncName: 'importItems' },
  { name: 'articleExtractPickup', syncName: 'extractPickup' },
]

Damit gehen sie automatisch durch den Standard-Sync-Pfad, RLS, field-level LWW. Keine neue Sync-Infrastruktur.

Server-Worker

Wo

apps/api/src/modules/articles/import-worker.ts, gestartet aus apps/api/src/index.ts neben den Routes. Nicht in services/mana-ai (falscher Scope) und nicht in services/mana-research (Provider- Orchestrierung, kein Persistenz-Worker).

Konzept

Standard-Pattern aus services/mana-ai:

  1. Snapshot-Projektion — eine kleine Tabelle in mana_platform.articles_imports-Schema die sync_changes für appId='articles' und tableName ∈ {articleImportJobs, articleImportItems} zu Live-Records faltet (field-level LWW). Refreshed sich pro Tick.
  2. Tick alle 2 s. Liest die Snapshot, sucht:
    • Jobs mit status='running' und (leasedBy=null OR leasedUntil < now)
    • dazu Items mit state='pending' für diese Jobs
  3. LeaseleasedBy auf eigene workerId setzen, leasedUntil = now + 60s. Schreiben als sync_changes-Row mit actor=system, origin=system, source='articles-import-worker'.
  4. Concurrency 3 pro Job — pro Tick max 3 Items in state='extracting' schalten, extractFromUrl() aus @mana/shared-rss aufrufen.
  5. Pickup-Write — bei Erfolg: articleExtractPickup-Row schreiben + Item-State auf extracted. Bei Fehler: attempts += 1, wenn attempts >= 3state='error', sonst zurück auf pending.
  6. Job-Completion — wenn alle Items eines Jobs in einem Terminal-State sind (saved | duplicate | consent-wall | error | cancelled), setze job.status='done' + finishedAt. Counter-Spalten gleich mit aktualisieren.
  7. Heartbeat — solange Items extracting, alle 30 s leasedUntil erneuern.

Single-Instance-Garantie

pg_advisory_lock(<key>) über die Worker-Loop. Falls apps/api in mehreren Instanzen läuft, nimmt nur eine den Lock und tickt. Andere idlen.

Counters: woher

Worker tracked Item-Übergänge und stempelt:

  • pending → extracted: keine Counter-Änderung
  • extracted → saved (Client signalisiert): savedCount += 1
  • extracted → duplicate (Client signalisiert): duplicateCount += 1
  • extracted → consent-wall (Client signalisiert): warningCount += 1
  • jeder Übergang → error: errorCount += 1

Counter-Updates gehen als normale articleImportJobs.update durch sync_changes, RLS-correct.

Server-side Cleanup

Stündlicher GC-Job:

  • Pickup-Rows älter 24 h löschen (Sicherheits-Cap)
  • Jobs mit status='done' AND finishedAt < now - 30d archivieren (späteres Polish — erst mal nur Cap)

Client-Pickup-Consumer

apps/mana/apps/web/src/lib/modules/articles/consume-pickup.ts, gestartet aus data-layer-listeners.ts zusammen mit den anderen Listener-Wirings.

Logik:

liveQuery(() => articleExtractPickup
  .filter(r => !r.deletedAt)
  .toArray()
).subscribe(rows => {
  for (const row of rows) {
    void consumeOne(row);
  }
});

async function consumeOne(row: LocalArticleExtractPickup) {
  // Re-entrancy guard via in-memory Set so multiple liveQuery ticks
  // don't race the same row.
  if (inFlight.has(row.id)) return;
  inFlight.add(row.id);
  try {
    const item = await articleImportItemTable.get(row.itemId);
    if (!item || item.state !== 'extracted') {
      await articleExtractPickupTable.delete(row.id);  // Stale row
      return;
    }
    // Dedupe-Check für den Fall dass der User die URL parallel
    // single-saved hat während der Job lief.
    const existing = await articlesStore.findByUrl(row.payload.originalUrl);
    if (existing) {
      await articleImportItemTable.update(item.id, {
        state: 'duplicate',
        articleId: existing.id,
      });
      await articleExtractPickupTable.delete(row.id);
      return;
    }
    const article = await articlesStore.saveFromExtracted(row.payload);
    const nextState: ImportItemState =
      row.payload.warning === 'probable_consent_wall'
        ? 'consent-wall'
        : 'saved';
    await articleImportItemTable.update(item.id, {
      state: nextState,
      articleId: article.id,
      warning: row.payload.warning ?? null,
    });
    await articleExtractPickupTable.delete(row.id);
  } finally {
    inFlight.delete(row.id);
  }
}

Multi-Tab: alle Tabs sehen Pickup-Rows. Web-Lock mana:articles:pickup sorgt dafür dass nur ein Tab gleichzeitig konsumiert. Andere Tabs sehen die liveQuery, der Lock-halter pickt ab.

Store-API

modules/articles/stores/imports.svelte.ts — neue Datei.

export const articleImportsStore = {
  /** Erzeugt Job + N Items in einem Dexie bulkAdd, returns jobId. */
  async createJob(urls: string[]): Promise<string> {  },

  async pauseJob(jobId: string): Promise<void> {  },
  async resumeJob(jobId: string): Promise<void> {  },
  async cancelJob(jobId: string): Promise<void> {  },

  /** Setzt alle Error-Items eines Jobs zurück auf pending. */
  async retryFailed(jobId: string): Promise<void> {  },

  /** Soft-Delete des Jobs + aller Items. Article-Rows bleiben. */
  async deleteJob(jobId: string): Promise<void> {  },
};

saveFromExtracted in modules/articles/stores/articles.svelte.ts bleibt der gemeinsame Kern — der Pickup-Consumer ruft sie genauso auf wie der existierende Single-URL-Pfad.

URL-Parser steht im Store, nicht im Component:

export function parseUrls(raw: string): {
  valid: string[];
  invalid: string[];
  duplicates: string[];
} {  }

Pure Funktion, unit-testbar.

UI

/articles/import — Index + Eingabe (+page.svelte)

  • <BulkImportForm> Komponente mit <textarea>, Live-Validierung als $derived über parseUrls().
  • Counter „X gültig · Y ungültig · Z Duplikate" über dem Submit-Button.
  • Submit → articleImportsStore.createJob(urls) → goto /articles/import/[jobId].
  • Unter dem Form: <JobsList> mit aktiven + abgeschlossenen Jobs der letzten 30 Tage. Each row: Status-Pill, Counter, Click → Detail.

/articles/import/[jobId] — JobDetailView

  • Header: Job-Status, Fortschrittsbalken, Counter („3 / 12 — 1 Duplikat, 1 Warnung").
  • Action-Bar: Pause / Fortsetzen / Abbrechen / „Fehler erneut versuchen" (nur wenn errorCount > 0).
  • Liste der Items (virtuelle Scroll für > 100 Einträge): URL + State-Pill + Title (sobald gespeichert) + Action-Link (Öffnen / Erneut versuchen / Fehler-Detail-Tooltip).

Verlinkung

  • AddUrlForm (/articles/add) bekommt unter dem Input einen kleinen Link „Mehrere URLs auf einmal? → Bulk-Import".
  • Wenn der User in AddUrlForm einen Mehrzeiler einfügt, schlägt der Form vor: „4 URLs erkannt — als Bulk importieren?" → routet auf /articles/import mit pre-fill via sessionStorage.
  • /articles/settings bekommt eine vierte Karte „Mehrere URLs importieren" mit Link auf /articles/import.

Domain-Events

Zwei neue Events in data/events:

type ArticleImportStarted = {
  type: 'ArticleImportStarted';
  appId: 'articles';
  collection: 'articleImportJobs';
  recordId: string;
  payload: { totalUrls: number };
};
type ArticleImportFinished = {
  type: 'ArticleImportFinished';
  appId: 'articles';
  collection: 'articleImportJobs';
  recordId: string;
  payload: {
    totalUrls: number;
    savedCount: number;
    duplicateCount: number;
    errorCount: number;
    warningCount: number;
  };
};

Activity-Modul + Recap-Aggregator picken sie automatisch auf (über das bestehende Event-Bus-Pattern).

AI-Tool

Neuer Eintrag im AI_TOOL_CATALOG (packages/shared-ai/src/tools/schemas.ts):

{
  name: 'import_articles_from_urls',
  module: 'articles',
  policy: 'auto',           // Deterministisch, kein per-Article-Approval
  schema: z.object({
    urls: z.array(z.string().url()).min(1).max(50),
  }),
  describe: 'Ein Bulk-Import-Job für Artikel-URLs. Returns jobId zum Tracken.',
}

modules/articles/tools.ts registriert die execute-Function: ruft articleImportsStore.createJob(urls) auf, returns { jobId, totalUrls }.

save_article (single-URL, propose) bleibt für „bitte speichere DIESEN Artikel" Befehle. import_articles_from_urls ist für Listen.

Phasen

Phase 1 — Datenmodell (soft)

  1. Dexie-Version v56 (next free): articleImportJobs, articleImportItems, articleExtractPickup declaren mit Indexen:
    • articleImportJobs: 'id, status, [spaceId+status], createdAt'
    • articleImportItems: 'id, jobId, [jobId+state], idx, state'
    • articleExtractPickup: 'id, itemId, createdAt'
  2. Types in modules/articles/types.ts ergänzen.
  3. module.config.ts um die drei Tabellen erweitern.
  4. collections.ts um Table-Refs erweitern.
  5. Plaintext-Allowlist-Eintrag für alle drei Tabellen.
  6. Smoke: pnpm check:crypto + pnpm validate:all grün.

Acceptance: Schema lädt, kein neuer Daten-Pfad aktiv. Bestehender Single-URL-Pfad läuft weiter.

Phase 2 — Server-Worker

  1. apps/api/src/modules/articles/import-worker.ts mit:
    • Snapshot-Projektion (Mirror von mana-ai/db/missions-projection.ts)
    • Tick-Loop, advisory-lock, Lease/Heartbeat
    • extractFromUrl() Aufruf, Pickup-Write, State-Transitions
  2. Migration apps/api/drizzle/articles/<n>-imports-snapshot.sql:
    • Schema articles_imports
    • Snapshot-Tabellen für jobs + items
    • GC-Cron-Definition
  3. Boot in apps/api/src/index.ts: import { startArticleImportWorker } from './modules/articles/import-worker'; startArticleImportWorker();

Acceptance: Manuelles Insert eines Job + Items via SQL → Worker extracted → Pickup-Row erscheint → Item-State extracted.

Phase 3 — Client-Pickup-Consumer

  1. modules/articles/consume-pickup.ts (s.o.).
  2. data/data-layer-listeners.ts registriert den Consumer beim Boot.
  3. Web-Lock mana:articles:pickup für Multi-Tab-Koordination.
  4. Re-entrancy-Guard via in-memory Set.

Acceptance: Pickup-Row aus Phase 2 wird konsumiert, Article landet verschlüsselt in articles, Item geht auf saved, Pickup-Row gelöscht.

Phase 4 — Store-API

  1. modules/articles/stores/imports.svelte.ts mit allen Methoden.
  2. parseUrls als pure Funktion + Unit-Tests.
  3. saveFromExtracted bleibt unverändert — wird re-used.

Acceptance: articleImportsStore.createJob(['url1', 'url2']) erzeugt Job + 2 Items, Worker zieht sie, beide landen in articles.

Phase 5 — UI

  1. /articles/import/+page.svelte mit Form + JobsList.
  2. /articles/import/[jobId]/+page.svelte mit JobDetailView.
  3. BulkImportForm.svelte, JobsList.svelte, JobDetailView.svelte, ImportItemRow.svelte (alles in modules/articles/components/).
  4. Verlinkung in AddUrlForm, /articles/settings.
  5. Smart-Detect-Hint in AddUrlForm für Mehrzeiler-Paste.
  6. i18n-Keys in de.json + en.json (fr/it/es konsistent mit anderen Articles-Strings).

Acceptance: User pasted 5 URLs in <textarea>, klickt „Importieren", sieht Detail-View mit Live-Updates, kann Pause/Fortsetzen drücken.

Phase 6 — Domain-Events + AI-Tool

  1. ArticleImportStarted + ArticleImportFinished in data/events/types.ts.
  2. Worker emittet Started beim ersten Item-Claim, Finished beim Job-Completion.
  3. import_articles_from_urls in AI_TOOL_CATALOG + tools.ts.

Acceptance: Activity-Modul zeigt beide Events, AI-Tool funktioniert in einer Mission.

Phase 7 — Konvergenz (optional, nach Soak)

  1. QuickAddInput + AddUrlForm legen unter der Haube auch einen Job an (1 Item) statt direkt zu speichern.
  2. Eine einzige Code-Bahn für jede Ingestion.
  3. Hard-Cleanup: saveFromUrl entfernen, Aufrufer auf articleImportsStore.createJob([url]) migrieren.
  4. Single-URL-Pfad navigiert zum Reader sobald der Pickup-Consumer das saved-Event meldet (gleicher Code-Pfad, nur eine UI-Reaktion).

Acceptance: Heart-of-the-app smoke test — neuer User → URL pasten in QuickAddInput → Reader öffnet sich. Performance vergleichbar (max +200 ms Latenz akzeptabel, das ist der Worker-Tick).

Tests

Phase 1

  • parseUrls — gültige / ungültige / Duplikate / Whitespace-Varianten.

Phase 2 (Server)

  • Worker pickt nur einen Job pro Lease — zweiter Worker-Stub kommt nicht ran.
  • Lease-Renewal — Worker verlängert während Extracting.
  • Lease-Expiry — toter Worker, Job wird wieder available.
  • Item-Retry — attempts < 3 schickt zurück auf pending, danach error.

Phase 3 (Client)

  • Pickup → encryptRecord → article.add. Mit fake-indexeddb.
  • Multi-Tab Web-Lock — nur ein Tab konsumiert.
  • Stale Pickup (Item nicht mehr extracted) wird ignoriert + gelöscht.
  • Dedupe-Race: User hat URL parallel single-saved → Item wird duplicate.

Phase 4 (Store)

  • Full lifecycle: createJob → 3 Items → Worker-Mock → 2 saved + 1 error.
  • retryFailed setzt nur Error-Items zurück.
  • cancelJob setzt alle pending-Items auf cancelled.

Phase 5 (UI)

  • E2E Playwright: Bulk-Import mit 5 URLs, alle landen in der Article- List. Pause-Button stoppt Worker (in Test-Env mit Fake-Worker-Hook).

Phase 7 (Konvergenz)

  • QuickAddInput-Pfad geht durch den Worker, Performance-Smoke (Latenz vom Klick bis Reader < 5 s im Local-Dev).

Bekannte Edge-Cases

  1. Worst-Case-Dauer: 50 URLs × ~25 s Server-Extract / Concurrency 3 ≈ 7 min. UI muss das ehrlich anzeigen. „Im Hintergrund — du kannst weitermachen."
  2. Consent-Wall im Bulk: Server flagged warning, Item landet auf consent-wall, JobDetailView zeigt Hinweis „N Artikel mit Cookie- Wand — mit Bookmarklet erneut speichern?". Bulk-Retry dieser Items ist ein späteres Polish, kein MVP-Blocker.
  3. Eingabe-Duplikate: parseUrls dedupliziert vor Job-Erzeugung.
  4. Bestehende Articles: Pickup-Consumer prüft per findByUrl vor saveFromExtracted — falls in der Zwischenzeit single-saved, geht das Item auf duplicate.
  5. Worker-Crash mid-Item: state='extracting' mit abgelaufener Lease wird vom nächsten Tick zurück auf pending gesetzt.
  6. Job-Cancel während Extract läuft: Worker prüft pro Item den Job-Status vor dem Pickup-Write. Bei status='cancelled' → Item-State auf cancelled, kein Pickup.
  7. Ratelimit auf extractFromUrl: Der Server hat Rate-Limits pro User auf /api/v1/articles/extract (200/min). Worker geht nicht über HTTP — er ruft extractFromUrl() direkt aus dem Modul. Kein Rate-Limit-Konflikt.
  8. Job-Liste wird zu lang: Soft-Delete-on-30-Days-old in Phase 7-Polish.

Was bewusst NICHT im Scope ist

  • Cross-User-Resharing von Job-Definitionen (kein Use-Case)
  • Server-side Re-Extraction (für Re-Index nach Readability-Updates) — separater Plan
  • Bulk-Tagging im selben Schritt — der User taggt nach dem Import, nicht während
  • Import aus Pocket / Instapaper / Raindrop-Backup-Dateien — eigenes Modul-Feature, später

Reference Implementations im Repo

  • Server-Worker-Pattern: services/mana-ai/src/runner/, services/mana-ai/src/db/missions-projection.ts
  • Snapshot-Projektion + Advisory-Lock: services/mana-ai/src/db/snapshot-refresh.ts
  • Singleton-Bootstrap-via-sync_changes (Server-Write mit system actor): services/mana-auth/src/services/bootstrap-singletons.ts
  • Local-only Listener-Wiring: apps/mana/apps/web/src/lib/data/data-layer-listeners.ts
  • liveQuery-Driven UI: apps/mana/apps/web/src/lib/modules/ai-missions/