Synapse 3.0 — quaderno di idee¶
Stato: pensatoio, non un impegno. Nessuna di queste voci è approvata, stimata seriamente o schedulata. Serve a far sedimentare le idee mentre la 2.1.x continua a ricevere manutenzione settimanale.
Come si alimenta: (a) la routine di manutenzione settimanale (runbook §6) ci scarica tutto ciò che incontra ed è troppo grosso per una patch; (b) Emanuele ci butta dentro idee quando gli vengono.
Cos'è una "3.0": un major SemVer autorizza breaking change. Se un'idea non richiede di rompere qualcosa (API, schema, contratto d'uso) o non è un salto di categoria del prodotto, probabilmente è una 2.2, non una 3.0.
Aperto: 2026-08-07, subito dopo la 2.1.7.
Dove siamo (baseline 2.1.7)¶
Ciò che è fatto e solido, e che quindi non va rimesso in discussione a cuor leggero:
- Parità funzionale con
nashsu/llm_wikichiusa (K1–K8, F1–F18). - Invarianti I1–I9 rispettati e verificati in CI (incrementalità, layout server-side, no-work-per-token, CodeMirror, compatibilità Obsidian, provider pluggable, loop bounded, docs-as-DoD, no-reinvent).
- Suite test ampia e veloce: ~3160 pytest + ~2550 vitest + E2E Playwright su Postgres reale.
- Superficie di distribuzione larga: web/PWA, desktop Tauri (macOS + Windows) con updater, clipper Chrome, MCP remoto (bearer + OAuth 2.1/PKCE dalla 2.1.6), app iOS nativa in costruzione.
- Sicurezza: Fernet at-rest, PBKDF2 sui token, CSP, rate-limit, Cloudflare Access, token scoped revocabili.
Il prodotto funziona. Una 3.0 non serve a farlo funzionare: serve a cambiare cosa è.
Tema 1 — Ingest deterministico e riparabile ⭐¶
Il problema più concreto che abbiamo. Nella sola settimana del 20-22 luglio 2026 il loop di ingest si è rotto per quattro cause radice indipendenti:
| Release | Causa |
|---|---|
| 2.1.2 | rsplit(".", 1) non spogliava le sottocartelle dal path sorgente |
| 2.1.4 | max_thinking_tokens non limitato → il turno si consumava in ragionamento, zero output |
| 2.1.5 | index.md illimitato nel system prompt → superato ARG_MAX del kernel (E2BIG) |
| 2.1.7 | POST /mcp/server senza slash finale → 307, connettore MCP morto |
Il pattern è chiaro: la generazione a blocchi è un singolo turno di testo libero che deve andare bene al primo colpo, e ogni retry peggiora le cose (il prompt cresce con gli errori precedenti invece di diventare più mirato). Quando fallisce, fallisce in modo silenzioso e opaco: "Non convergito", nessun output parziale salvato, soldi spesi.
Idee (breaking, quindi 3.0):
- Generazione per-pagina invece che monolitica. Oggi un documento produce N pagine in
un unico blob
---FILE:. Se il blob è malformato, si perde tutto. Generare una pagina per chiamata: fallimenti isolati, retry mirato solo sulla pagina rotta, progresso parziale sempre salvato. Costa più chiamate, ma il costo di un run non convergente oggi è già ~0,50-2 $ buttati. - Output strutturato al posto del parsing di testo.
---FILE:/---END FILE---è un protocollo fragile inventato in casa. Tool-use / JSON Schema danno garanzia sintattica dal provider. Richiede però una strategia per Ollama (structured output via grammar) — I6 dice che il routing dev'essere capability-aware, non per-provider. - Contesto incrementale invece di dump.
index.mdcresce di una riga per pagina e viene reincluso per intero in ogni prompt. In 2.1.5 l'ho tappato con un cap, ma è una pezza: la strada giusta è retrieval del contesto rilevante (le pagine vicine al documento in ingest) invece del catalogo completo. - Checkpoint e ripresa. Un run interrotto (crash, budget, 429) dovrebbe riprendere da dove era, non ricominciare.
Perché è il tema numero uno: è il ciclo centrale del prodotto, è quello che l'utente vede rompersi, ed è quello che brucia soldi quando sbaglia. Tutto il resto è cosmetica al confronto.
Sforzo: L. Rischio: alto (tocca il cuore). Serve l'harness di parità ADR-0083 vivo e una fase di doppio-binario prima di spegnere il vecchio path.
Tema 2 — Identità reale e multi-utente¶
Oggi Synapse è mono-operatore per costruzione: un SYNAPSE_AUTH_TOKEN, un vault per
backend, e — dalla 2.1.6 — un server OAuth il cui passo di consenso è approvato dallo stesso
token statico condiviso (ADR-0090 lo dice
esplicitamente: "se Synapse diventasse davvero multi-utente, servirebbe identità per-utente
dietro /authorize").
Idee:
- Tabella
users+ membership sui vault; ruoli (owner / editor / reader). - Il consenso OAuth diventa un vero login utente, non un token condiviso.
- Vault condivisi in sola lettura → abilita il caso d'uso "pubblico il mio wiki a un collega".
- Audit log per-utente (chi ha ingerito cosa, chi ha approvato quale review).
Attenzione: questo è il classico cambiamento che sembra una feature e invece è una
riscrittura trasversale — tocca auth, MCP, clipper, iOS, ogni query che oggi assume
settings.vault_id. Da valutare solo se c'è un caso d'uso reale, non "perché sarebbe bello".
Sforzo: L. Prerequisito per: pubblicazione, collaborazione.
Tema 3 — Qualità del recupero (retrieval)¶
Il retrieval a 4 fasi funziona, ma è fermo alla forma originale: vettoriale bge-m3 + espansione sul grafo + budget + assemblaggio.
Idee:
- Ibrido lessicale + vettoriale (Postgres FTS /
pg_trgmaccanto a Qdrant). Era già stato scartato in v2.0 per priorità, ma resta il singolo miglioramento con miglior rapporto qualità/sforzo: i nomi propri e i codici (es. sigle normative) il vettoriale li sbaglia sistematicamente. - Reranking del set candidato prima dell'assemblaggio.
- Verifica delle citazioni: oggi il modello cita
[n]e ci fidiamo. Un passo di verifica automatica (la frase citata esiste davvero nella fonte?) alzerebbe molto la fiducia, che è il punto di un wiki. - Query understanding: riformulazione/espansione prima del recupero.
Sforzo: M per l'ibrido, M per il reranking, L per la verifica citazioni.
Tema 4 — Il grafo come strumento di ragionamento, non solo vista¶
Oggi il grafo è bellissimo e sostanzialmente passivo: lo guardi. I 4 segnali di rilevanza e le community Louvain sono già calcolati e cachati server-side — cioè l'infrastruttura c'è già, è sotto-sfruttata.
Idee:
- Rilevamento di contraddizioni attraversando il grafo (esiste già un abbozzo nel loop di consistenza post-ingest, mai portato a valore).
- Grafo temporale: com'è evoluta la conoscenza? Quali pagine sono state riscritte più volte? Dove la comprensione è cambiata?
- Sintesi guidata dal grafo: oggi synthesis/comparison nascono da cluster; potrebbero nascere da buchi strutturali (nodi ponte mancanti, community isolate).
- Health del grafo come metrica di prodotto: densità dei link, orfani, community frammentate — già in parte in Lint, ma non presentata come "salute del sapere".
Sforzo: M ciascuna. Nota: questo è il tema che differenzia di più Synapse da "una cartella di markdown generata da un LLM".
Tema 5 — iOS: da cantiere a prodotto¶
La Fase A è avviata (ADR-0088: SwiftUI nativa, design system allineato al brand, shell a 5 tab). Mancano le superfici vere e la distribuzione.
Idee: Fase B (Home, lettura wiki, chat con streaming e citazioni, ricerca) e Fase C (grafo, review queue, sorgenti, impostazioni) + TestFlight.
Domanda aperta e non banale: il grafo su iOS — WKWebView che riusa sigma.js (parità garantita, I2 rispettato perché le coordinate arrivano già calcolate dal server) oppure rendering nativo? L'ADR-0088 lascia la decisione Proposed, in attesa di misure vere su device.
Serve da te: device fisico per i test di performance, iscrizione Apple Developer Program (99 $/anno) per TestFlight.
Sforzo: L. Candidato flagship di una 3.0.
Tema 6 — Economia: costo e velocità dell'inferenza¶
I7 impone budget e log dei costi, quindi i dati ci sono già. Non sono però mai stati usati per ottimizzare.
Idee:
- Prompt caching. Schema, purpose e contesto del vault sono quasi identici tra un documento e il successivo. Su un import di 150 file è la leva di costo più grande disponibile, e oggi è completamente inutilizzata.
- Batch API per gli import massivi non interattivi (costo dimezzato, latenza irrilevante quando stai importando di notte).
- Routing per complessità: classificare la difficoltà del documento e mandare i casi facili a un modello più economico. Il tiering lo applichiamo già a noi durante lo sviluppo; non lo applichiamo al runtime del prodotto.
- Dashboard costi con proiezione, non solo consuntivo.
Sforzo: S/M ciascuna, e con ritorno immediato e misurabile. Il tema meno affascinante e forse più redditizio.
Tema 7 — Scala¶
Nessuno ha mai misurato Synapse su un vault grosso. Il benchmark a 5k pagine era stato messo in lista e poi rinviato.
Segnali che qualcosa scricchiola già: index.md illimitato (2.1.5 lo dimostra),
FA2 ricalcolato da zero a ogni bump invece che a caldo, la home fa un fan-out di ~16
chiamate all'avvio, HomeDashboard.tsx è un monolite da ~3.100 righe mai scomposto.
Idee: benchmark onesto a 1k / 5k / 20k pagine con numeri pubblicati; FA2 warm-start;
paginazione/virtualizzazione ovunque manchi; index.md ripensato come vista materializzata
invece che file monolitico.
Sforzo: M per la misura (da fare prima di ottimizzare), poi dipende da cosa emerge.
Tema 8 — Il wiki come qualcosa che si può mostrare¶
Oggi il wiki è visibile solo a chi ha accesso all'istanza.
Idee: publishing statico (export di un sito navigabile, ricerca inclusa), link pubblici in sola lettura per singola pagina, export in PDF/EPUB di una sezione. Dipende dal Tema 2 se si vuole controllo d'accesso fine.
Sforzo: M. Nota: trasforma il prodotto da "strumento personale" a "strumento con un pubblico" — decisione di prodotto, non tecnica.
Tema 9 — Debito tecnico che vale una major¶
Roba rinviata più volte, che in una major si può affrontare rompendo qualcosa:
HomeDashboard.tsx~3.100 righe, mai scomposto (rinviato in 1.9.2 e in 2.0.0).GraphViewer.tsx~1.565 righe dopo l'estrazione, obiettivo 800-1.000.- 6 poller REST residui non ancora passati al canale SSI/SSE (il pattern è già dimostrato sui due consumatori principali).
- Migrazione completa dei client API all'envelope/
ApiErrorcondiviso (parziale). - Lazy-load del locale i18n non attivo (rinviato tre volte: 1.9.3, 2.0.0, 2.1).
- Focus-trap mancante su
CascadeDeleteModal— questo gestisce cancellazioni, ha priorità sugli altri due modali. - Coverage ratchet strumentato ma senza soglia di gate.
- Harness di parità ADR-0083 mai rieseguito dopo la migrazione review-create al block loop.
- Lockfile delle dipendenze backend.
Sforzo: singolarmente S/M, complessivamente L. Nessuno di questi da solo giustifica una major; insieme sono il "pulisci mentre hai il permesso di rompere".
Come sceglierei (opinione, da discutere)¶
Se dovessi indicare una direzione: Tema 1 (ingest deterministico), con il Tema 6 (costi) come compagno naturale — si toccano gli stessi file e il secondo ripaga il primo.
Il ragionamento: gli altri temi aggiungono superficie a un prodotto che ne ha già molta. Il Tema 1 invece rende affidabile la cosa che l'utente fa ogni giorno e che oggi si rompe in modo visibile e costoso. Quattro release di fila in una settimana per lo stesso ciclo non sono sfortuna: sono un progetto che ti sta dicendo dove fa male.
Il Tema 5 (iOS) è il flagship più vistoso e il più facile da raccontare, ma è anche il più isolato: non migliora nulla di ciò che esiste già.
Il Tema 2 (multi-utente) lo terrei fermo finché non c'è una seconda persona vera che usa il sistema. È il tipo di complessità che, se introdotta "per il futuro", si paga ogni giorno senza mai ripagarsi.
Voci dalla manutenzione settimanale¶
pypdf fermo su 5.x con 24 advisory aperte — serve il salto a 6.x¶
- Problema:
pip-auditriporta 24 advisory supypdf==5.9.0(PYSEC-2026-3004…3027, PYSEC-2026-3610…3613, GHSA-jm82-fx9c-mx94). Nessuna è corretta in una 5.x: la fix più bassa è 6.7.1, la più alta 6.14.2. Il vincolo attualepypdf>=4.2,<6esclude per costruzione ogni versione corretta, quindi il debito non si chiude con un bump patch. - Evidenza:
backend/pyproject.toml:34("pypdf>=4.2,<6"),backend/requirements-lock.txt:242ebackend/requirements-prod-lock.txt:207(pypdf==5.9.0). - Impatto: pypdf è l'estrattore PDF di default (
app/config.py:866,pdf_extractor="pypdf") ed è anche il fallback incondizionato quando Marker o MinerU falliscono (app/ingest/extract.py:26-27) — quindi ogni PDF che entra nel vault, incluso materiale scaricato dal web via clipper o deep-research, passa da qui. Le advisory di questa famiglia sono tipicamente DoS su file malformati (loop infiniti, esaurimento memoria) più che RCE, e il deployment è mono-operatore: rischio reale ma contenuto. Non abbastanza contenuto da lasciarlo indefinito. - Sforzo: M. Il salto 5.x → 6.x è un major con API breaking (la routine settimanale ha
il divieto esplicito di major, §2 punto 6 del runbook): va allargato il vincolo in
pyproject.toml, rigenerati entrambi i lockfile, e riverificatoapp/ingest/extract.pycontro l'API 6.x, con un test su PDF reali. - Trovato: 2026-08-07
Nessuna superficie operatore per vedere e revocare le concessioni OAuth MCP¶
- Problema: dalla 2.1.8 un cambio del token statico revoca tutte le concessioni OAuth insieme, ma resta l'unico strumento disponibile: è un'accetta, non un bisturi. Non esiste modo di elencare i client autorizzati, vedere quando hanno ottenuto accesso, o revocarne uno solo senza buttare giù anche gli altri e senza cambiare il token che gli altri client (Claude Desktop) stanno usando.
- Evidenza:
backend/app/mcp/oauth.pynon espone alcuna rotta di lettura/revoca;backend/app/routers/config/mcp.pygestisce solo il token statico. Il modelloMcpOAuthClient/McpOAuthToken(backend/app/models.py:2576) ha già i campi necessari (client_name,created_at,revoked_at). - Impatto: una volta che più di un client OAuth è configurato — plausibile appena si aggiungono claude.ai web e un secondo dispositivo — la revoca selettiva diventa necessaria. Oggi l'operatore non ha nemmeno visibilità su quali client abbiano accesso.
- Sforzo: M. Richiede endpoint REST (
GET/DELETE /mcp/oauth/grants), una sezione UI in Settings, e la propagazione allaMcpOAuthTokenCache. Superficie nuova = feature, non patch. - Trovato: 2026-08-07
POST /register non autenticata può saturare il tetto dei 200 client¶
- Problema: la Dynamic Client Registration è raggiungibile senza credenziali (per
necessità: è il punto d'ingresso RFC 7591) e crea una riga
mcp_oauth_clientsper chiamata. Al raggiungimento di_MAX_REGISTERED_CLIENTS = 200ogni registrazione successiva riceve 429 per sempre: non esiste pruning, scadenza o pulizia dei client mai usati. - Evidenza:
backend/app/mcp/oauth.py:79(_MAX_REGISTERED_CLIENTS = 200) eregister_client()— il tetto è un contatore su tabella, senza rate limit né TTL. - Impatto: basso e non catastrofico — serve
remote_mcp_enabledON ed endpoint esposto, e il percorso/authorize(che registra JIT ed è protetto dal token statico) continua a funzionare anche a tetto pieno, quindi i client già configurati non si rompono. Ma un operatore che aggiunge un nuovo connettore dopo la saturazione riceve un 429 opaco. - Sforzo: S/M. La correzione giusta non è alzare il tetto ma dare un ciclo di vita ai client (TTL sui client senza token attivi + pruning), il che si incastra naturalmente con la voce precedente sulla superficie di revoca.
- Trovato: 2026-08-07
pypdf: le advisory aperte sono salite da 24 a 40 — il salto a 6.x è sempre più urgente¶
- Problema: aggiornamento della voce del 2026-08-07 (che resta valida in pieno, incluso
l'impatto e lo sforzo).
pip-auditoggi riporta 40 advisory supypdf==5.9.0, non più 24: alle famiglie già note si sono aggiunte PYSEC-2026-1827…1833 (fix da 6.0.0 a 6.6.2) e PYSEC-2026-3655/3656 (fix in 6.15.0). Il quadro non cambia di natura — nessuna advisory è corretta in una 5.x, la fix più bassa è ora 6.0.0 e la più alta 6.15.0 — ma il ritmo con cui la lista cresce (+16 in una settimana) dice che rimandare ha un costo crescente. - Evidenza:
backend/pyproject.toml:34("pypdf>=4.2,<6");.venv/bin/python -m pip_audital 2026-08-14. - Impatto: invariato rispetto alla voce originale (pypdf è l'estrattore PDF di default e il fallback incondizionato di Marker/MinerU), ma la superficie cresce a ogni settimana di attesa.
- Sforzo: M — invariato. Resta un major, quindi fuori dal mandato della routine settimanale.
- Trovato: 2026-08-14
Le dipendenze di build/test del frontend hanno 11 advisory, tutte dietro un major¶
- Problema:
npm auditriporta 11 vulnerabilità (2 critical, 6 high, 3 moderate) suvitest/@vitest/coverage-v8(critical,<=3.2.5),vite(high,<=6.4.2), più le transitivepostcss,nanoid,esbuild,js-yaml,brace-expansion,fast-uri,@vitest/mocker,vite-node. Le fix richiedononpm audit fix --force, cioè major su vitest e vite. - Evidenza:
npm auditdafrontend/al 2026-08-14;frontend/package.json(vite,vitest,@vitest/coverage-v8indevDependencies). - Impatto: basso e circoscritto.
npm audit --omit=devriporta 0 vulnerabilità: nulla di tutto questo finisce nel bundle servito all'utente. È superficie di build e di test, sfruttabile solo da chi controlla già il sorgente o l'ambiente CI. Per questo NON è stata toccata in 2.1.9: un major su vite+vitest tocca la configurazione di build, i 123 file di test e la pipeline E2E — l'esatto opposto di una patch a basso rischio. - Sforzo: M/L. Vanno fatti insieme (vitest 4 richiede vite 7), con rilettura di
vite.config.ts, dei setup di vitest e del job E2E in CI. - Trovato: 2026-08-14
safe_fetch è vulnerabile a DNS rebinding: risolve l'host, poi riconnette per nome¶
- Problema: la guardia SSRF valida l'host risolvendo
getaddrinfo()e controllando tutti gli indirizzi restituiti, ma poi passa a httpx la URL con l'hostname, non l'IP verificato. Fra il controllo e la connessione httpx risolve il nome una seconda volta, in modo del tutto indipendente: un DNS autoritativo ostile che serve un record TTL=0 può restituire un IP pubblico alla prima query (supera la guardia) e127.0.0.1/169.254.169.254alla seconda (quella che apre davvero la socket). È un TOCTOU classico, e vale su ogni hop di redirect perché ogni hop ripete lo stesso schema controlla-per-nome → riconnetti-per-nome. - Evidenza:
backend/app/security_net.py:311-321—await _check_host(host)seguito daclient.build_request("GET", current_url)dentrosafe_fetch(); l'IP validato in_check_host(backend/app/security_net.py:185-193) viene scartato e mai riusato. - Impatto: medio. L'attaccante deve controllare un dominio e il suo DNS autoritativo, e la
URL deve arrivare fino a
safe_fetch— cioè comparire fra i risultati di ricerca web (deep-research o chat web-context), quindi non è scelta direttamente dall'attaccante ma è influenzabile. Riuscendo, legge servizi interni non altrimenti esposti (Qdrant, Postgres via HTTP, l'API Synapse stessa) da dentro il container. Il cap sul corpo introdotto in 2.1.10 limita quanto si può esfiltrare in un colpo solo, ma non chiude il buco. - Sforzo: M. La correzione corretta non è una guardia in più ma pinnare l'IP verificato:
transport httpx custom che connette all'indirizzo già validato mantenendo
Hostheader e SNI sul nome originale, con la validazione spostata dentro il resolver. Tocca il percorso di rete condiviso da deep-research e chat web-context e va verificato contro TLS/SNI e IPv6 — troppo per una patch settimanale, e va accompagnato da test con un resolver che cambia risposta fra la prima e la seconda chiamata. - Trovato: 2026-08-21
Una cancellazione dura lascia la run fantasma in coda e la riga ingest_runs a "running"¶
- Problema:
run_ingest_pipelineapre una rigaingest_runsconstatus="running"e registra la run nel queue manager (open_run), e le chiude entrambe solo nei due rami terminali espliciti (except IngestCancelledeexcept Exception). UnCancelledError— che è unaBaseExceptione quindi non passa da nessuno dei due — sfila via lasciando la riga DB eternamente a "running" e laRunHandledentro_active. Conseguenze: la run fantasma resta nel pannello attività finché il processo vive,_activenon si svuota mai del tutto e quindi la callbackon_drained(parità WS-C/ADR-0079) non scatta più, e la riga a "running" inquina lo storico. La 2.1.11 ha chiuso il pezzo bloccante della stessa causa radice (lo slot di concorrenza, che deadlockava il provider), ma non questo. - Evidenza:
backend/app/ingest/pipeline.py:466-486(apertura riga +open_run) e i due soli rami che chiamanofinalize(pipeline.py:606,pipeline.py:641). - Impatto: medio, ma solo cosmetico/osservabilità — non blocca l'ingest. Si manifesta allo shutdown del container e su qualunque task di ingest cancellato.
- Sforzo: M. Non è "aggiungere una
finally": bisogna decidere cosa scrivere e quando. Durante uno shutdown la sessione DB può essere già chiusa, quindi la finalizzazione va o tentata best-effort e ignorata, o delegata allo sweep delle orphan-running-rows già esistente (che oggi gira solo all'avvio). La scelta giusta è probabilmente la seconda — far girare lo sweep anche in chiusura — e va progettata, non improvvisata in una patch settimanale. - Trovato: 2026-08-28
pollChain: un unsubscribe tardivo può spegnere una catena che non è più la sua¶
- Problema:
stopInternal()azzerarefCountquando la catena raggiunge il suo stato terminale, mentre i subscriber sono ancora attaccati. Le loro closure di detach restano però vive e continuano a decrementare: se nel frattempo un nuovo subscriber ha fatto ripartire la catena, il detach di un subscriber vecchio portarefCounta 0 e chiamastopInternal(), spegnendo il polling di qualcun altro. Il commento inpollChain.ts:76-79afferma che le closure superstiti "restano sicure perché clampanorefCounta 0" — clampano, sì, ma dopo aver già decrementato e fatto scattare lo stop. - Evidenza:
frontend/src/store/pollChain.ts:72-81(stopInternal) epollChain.ts:127-140(subscribe). - Impatto: latente, oggi non raggiungibile. Serve una catena condivisa che raggiunga
uno stato terminale, e le due condivise (
activityStore,statusStore) hanno unintervalForche non restituisce mainull; quelle che terminano davvero (ingestStore,researchStore,usePollChain) creano una catena per chiamante, quindi hanno un solo subscriber. Diventerebbe reale il giorno in cui una catena condivisa acquisisce uno stato terminale — cioè per una modifica del tutto ordinaria, senza alcun segnale che si sta inciampando in questo. Sintomo: uno stato che smette silenziosamente di aggiornarsi. - Sforzo: S. Un token di generazione incrementato in
stopInternal()e catturato insubscribe(), così un detach di una generazione passata non decrementa. Non è stato fatto nella 2.1.11 per disciplina d'ambito: non è raggiungibile, e la regola d'oro del runbook dice di non toccare ciò che non è rotto. Va fatto insieme alla prima modifica che renda terminale una catena condivisa. - Trovato: 2026-08-28
Il rate limit sui fallimenti di autenticazione non ferma un brute force: riscrive solo il 401 in 429¶
- Problema:
AuthFailureRateLimitMiddleware.dispatch()chiama il limiter dopocall_next(request), e solo quando la risposta è già un 401. Il tentativo di autenticazione viene quindi sempre eseguito per intero: superata la soglia, l'unico effetto è che il 401 viene sostituito da un 429. Nulla viene bloccato. Peggio, il controllo è cieco proprio sul caso che dovrebbe impedire: un token indovinato risponde 200, non 401, quindi non passa mai dal limiter — un attaccante che tira milioni di tentativi li vede tutti valutati, e quello giusto va a buon fine anche a contatore ampiamente sforato. SEC-RL-1 dichiara "prevents token-guessing and brute-force auth attacks": oggi non lo fa. - Evidenza:
backend/app/rate_limit.py:297-315(dispatch()—response = await call_next(request)precede il check, che è dentroif response.status_code == 401);backend/app/rate_limit.py:175-245(_AuthFailureLimiter, la contabilità in sé è corretta); registrato inbackend/app/main.py:458. - Impatto: medio. Il token statico è lungo e generato con
secrets, quindi il brute force resta impraticabile per entropia, non grazie a questo controllo — ma il controllo è documentato come se ci fosse, il che è la parte che fa danno: nasconde l'assenza di lockout a chi legge SECURITY.md o il docstring, e non c'è alcun costo imposto all'attaccante (né ritardo, né blocco, né allarme oltre una riga di log). - Sforzo: M. La correzione non è spostare due righe: bloccare prima di
call_nextsignifica introdurre un vero lockout per IP, che vale su ogni endpoint e non solo su quelli autenticati — un frontend con un token sbagliato in configurazione si auto-esclude dall'intera app per la durata della finestra. Va deciso il perimetro (solo le route autenticate? solo/mcp?), la granularità (IP, o IP+token-prefix?), la via di uscita per l'operatore chiuso fuori, e va accompagnato da un segnale visibile (contatore in/status, non solo un WARNING nel log). È un cambio di comportamento osservabile su un percorso di autenticazione: fuori dal mandato di una patch settimanale, che per definizione non deve poter chiudere fuori l'utente. - Trovato: 2026-09-04
La verifica dei token API blocca l'event loop per ~175 ms per token attivo, su ogni richiesta¶
- Problema:
ApiTokenCache.find_match()è sincrona e viene chiamata dentro il__call__del middleware ASGI. Per ogni token attivo che non corrisponde esegue unhashlib.pbkdf2_hmac(..., 260_000)— misurato 175 ms su questa macchina — e lo fa nel thread dell'event loop, non in un worker. Non è lentezza di una richiesta: è l'intero backend fermo (stream di chat in corso, SSE/events, watcher, tick del grafo) per tutta la durata della scansione. Con N token attivi e nessuna corrispondenza il costo è N × 175 ms per richiesta. Stessa forma inMcpOAuthTokenCache.find_match(), sul gate di/mcp/server. - Evidenza:
backend/app/auth.py:264(chiamata sincrona nel middleware);backend/app/runtime_state.py:646-672(ApiTokenCache.find_match, loop O(n) di PBKDF2) ebackend/app/runtime_state.py:579-594(la gemella OAuth); costo definito inbackend/app/runtime_state.py:157(_PBKDF2_ITERS = 260_000). - Impatto: medio-alto, e la parte scomoda è che non serve essere autenticati. Il token
di bootstrap è confrontato prima con
compare_digest(veloce), quindi il percorso PBKDF2 si raggiunge solo quando il bearer presentato non è quello — cioè con un token API scoped, ma anche con un bearer qualsiasi inventato. Una sequenza di richieste conAuthorization: Bearer <spazzatura>costringe il backend a N × 175 ms di event loop bloccato ciascuna, senza mai autenticarsi: è un denial of service a costo nullo per chi lo manda, e per l'operatore si presenta come "il backend è diventato inspiegabilmente lentissimo". Il docstring diApiTokenCacheè consapevole del loop O(n) ("expected to stay small") ma ragiona sul numero di token, non sul fatto che ogni iterazione sia deliberatamente costosa e sincrona. - Sforzo: M. Non è "avvolgerlo in
asyncio.to_thread": spostare il lavoro su un worker rendefind_matchasincrona su entrambi i middleware di autenticazione, e sposta il problema invece di risolverlo (il pool di thread diventa la nuova coda). La correzione vera è smettere di fare una scansione lineare: dare a ogni token un identificatore in chiaro (<token_id>.<secret>, come fanno GitHub e Stripe) così la verifica è un lookup O(1) seguito da un solo PBKDF2 — che però è un cambio di formato dei token già emessi, quindi breaking, quindi 3.0 e non una patch. Va deciso anche il percorso di migrazione per i token esistenti (doppia verifica per una release? rigenerazione forzata?) e se il costo PBKDF2 vada abbassato per i token generati consecrets(ad alta entropia), dove 260k iterazioni difendono da un attacco a dizionario che non è applicabile. - Trovato: 2026-09-11
pypdf è bloccato a 5.9.0 da un vincolo <6, e le correzioni di sicurezza vivono solo nella 6.x¶
- Problema:
pypdfè l'estrattore PDF predefinito e incondizionato (pdf_extractor: str = "pypdf"; anche con Marker o MinerU attivi,extract.pyricade su pypdf su qualunque fallimento — decisione PM, documentata). Parsa quindi input non fidato per costruzione: upload daPOST /ingest/upload, pagine catturate dal clipper, e i PDF che la deep research scarica da risultati di ricerca web.pip-auditsull'albero risolto riporta oltre 80 advisory note per pypdf 5.9.0 — in larga parte loop infiniti, ricorsione illimitata ed esaurimento di memoria su PDF malformati, cioè esattamente la classe raggiungibile da un file che un estraneo sceglie. Nessuna ha una correzione nella linea 5.x: le versioni di fix dichiarate partono da 6.0.0 e arrivano a 6.16.1. - Evidenza:
backend/pyproject.toml:34("pypdf>=4.2,<6"— il tetto che impedisce l'aggiornamento);backend/requirements-lock.txt:242ebackend/requirements-prod-lock.txt:207(pypdf==5.9.0, immagine di produzione inclusa);backend/app/config.py:866-873(default"pypdf") e:904(fallback permanente e incondizionato);backend/app/ops/deep_research.py:699-700(_extract_pdf_body, PDF presi dal web). Riproducibile conpip-auditnel venv di backend. - Impatto: medio-alto. Non è esecuzione di codice: è denial of service su un percorso
che un file può innescare da solo. Un PDF costruito ad arte — caricato, clippato, o
semplicemente linkato da un risultato SearXNG durante una deep research, dove nessun
umano ha scelto il file — può impegnare CPU o RAM in un thread worker finché il container
non viene riavviato. Il tetto
<6significa anche che ogni advisory futura su pypdf resterà non applicabile finché il vincolo non cambia: il debito non è statico, cresce. - Sforzo: M. Il salto 5.9 → 6.x è major, quindi fuori dal mandato della manutenzione
settimanale per definizione (§3 del runbook). pypdf 6 ha rimosso API deprecate della linea
4/5, perciò va verificato che
ingest/extract.pynon ne usi nessuna, che l'estrazione di testo continui a produrre lo stesso output sul corpus di test (la resa cambia tra versioni: è una regressione silenziosa sul contenuto del wiki, non un errore), e vanno rigenerati entrambi i lock. Da fare come voce a sé, con un confronto prima/dopo su PDF reali, non infilato in una patch. - Trovato: 2026-09-18
Il contesto RAG include il frontmatter YAML delle pagine, e lo paga dal budget del passaggio¶
- Problema:
_load_passage()legge il file sorgente conread_text()e lo passa direttamente all'assemblaggio del blocco[n]. Nessuno rimuove il frontmatter: ogni pagina wiki citata entra nel contesto del modello con il suo blocco---in testa (type,title,sources[],tags,domain,generation_key…). Il docstring della funzione dice "Read the body of a single source file", e tutto il resto del codice che legge una pagina usafrontmatter.loads(raw).contentper ottenerla davvero — questo è l'unico punto che non lo fa. - Evidenza:
backend/app/rag/retrieval.py:884-908(_load_passage, nessun parse del frontmatter);backend/app/rag/retrieval.py:811e:818-830(il passaggio finisce verbatim nel blocco[n]); da confrontare conbackend/app/ops/cascade_delete.py:204-218ebackend/app/ops/lint/fixes.py:108-118, che separano le due parti. Il caso non è coperto: i test di assemblaggio scrivono file sorgente senza frontmatter (backend/tests/test_retrieval.py:591), quindi la differenza non è mai osservata. - Impatto: basso-medio, ma su ogni turno di chat. Il costo è doppio: il frontmatter
consuma il
per_passage_cap(budget_chars // len(ranked)), quindi su un contesto stretto taglia testo reale in coda al passaggio; e immette nel prompt chiavi che il modello può citare come se fossero contenuto (sources: [raw/sources/…]è un percorso di filesystem, non una fonte da mostrare all'utente). Su una pagina corta consources[]lungo il frontmatter può essere la maggioranza del blocco. - Sforzo: S per il codice, M per il resto. La riga è una sola, ma cambia il testo
inviato al provider a ogni turno: è un cambio funzionale su un flusso esistente, cioè
esattamente ciò che una patch settimanale non deve fare (§3 del runbook). Va accompagnato
da una decisione esplicita su cosa tenere —
titleetypehanno valore come contesto,generation_keyesources[]no — e da un confronto di qualità delle risposte prima/dopo, non dedotto. - Trovato: 2026-09-18
La cancellazione a cascata limita al vault solo uno dei tre metodi di ricerca dei riferimenti¶
- Problema:
cascade_deletetrova le pagine che referenziano quella da eliminare con tre metodi. Il metodo (b) filtra esplicitamentePage.vault_id == settings.vault_id, con un commento che spiega perché: il confronto avviene suslugify(target_title), che può collidere tra vault. Il metodo (a) fa unORche includeLink.target_title == title— lo stesso confronto per titolo, sulla stessa tabella — e il metodo (c) enumeraPage.file_path LIKE 'wiki/%': nessuno dei due applica quel filtro. La protezione è stata messa su uno dei tre punti che ne hanno bisogno. - Evidenza:
backend/app/ops/cascade_delete.py:296-306(metodo (b), il filtro presente e commentato);backend/app/ops/cascade_delete.py:236-258(metodo (a),or_(target_page_id == page_id, target_title == title)e risoluzione delle pagine sorgente senzavault_id);backend/app/ops/cascade_delete.py:340-352(metodo (c), enumerazione senzavault_id). - Impatto: nullo oggi, per la stessa ragione dichiarata nel commento del metodo (b): il
processo serve un solo
settings.vault_id. Diventa una perdita di dati — riscrittura di[[wikilink]]in file di un altro vault — il giorno in cui più vault condividono un database, che è la direzione in cui il modello dati già punta (Page.vault_id,project_registry, il selettore di vault nel frontend). - Sforzo: S come diff, M come verifica. Aggiungere il predicato è banale; stabilire che i tre metodi abbiano ora esattamente lo stesso perimetro, e che nessun test esistente dipenda dal comportamento attuale, è il lavoro vero. Non urgente, ma va fatto prima e non dopo la prima funzionalità multi-vault reale: dopo, il bug è una cancellazione già avvenuta.
- Trovato: 2026-09-18
Le dipendenze backend non sono bloccate: un minor a monte può rompere la CI senza un commit¶
- Problema:
backend/pyproject.tomldichiara le dipendenze di runtime con vincoli aperti (sqlalchemy[asyncio]>=2.0.0,fastapi>=0.111, …) e la CI installa conpip install -e .senza lockfile né constraints. La versione che finisce nel job — e nell'immagine Docker — è quindi quella che PyPI serve nel momento in cui il job gira, non quella verificata all'ultimo merge. La deriva non è teorica: fra il 2026-09-18 (CI verde sulla 2.1.14) e il 2026-09-25 è uscita SQLAlchemy 2.1, che ha reso più preciso il tipo degli elementi diRow/Result, emypy app— invariato, con mypy bloccato a==2.1.0— ha iniziato a fallire sumaincon due errori in file che nessuno aveva toccato. Lo stagetypecheckblocca il merge, quindi per una settimana ogni PR sarebbe stata rossa per una causa esterna al repository. Le due righe sono state corrette nella 2.1.15, ma la correzione è il sintomo: il prossimo minor a monte ripresenta lo stesso problema, e non è detto che la volta dopo sia un errore di tipi invece di un cambio di comportamento a runtime. - Evidenza:
backend/pyproject.toml:13("sqlalchemy[asyncio]>=2.0.0", nessun tetto) e in generalebackend/pyproject.toml:10-46;.github/workflows/ci.yml:128-136(il jobtypecheckinstalla conpip install -e .+pip install -e ".[dev]", la cache disetup-pythonè la cache dei download di pip e non fissa le versioni risolte);backend/app/ingest/context.py:94ebackend/app/ops/lint/detectors.py:102(i due punti che SQLAlchemy 2.1 ha fatto emergere). Il contrasto è netto con il frontend, che hapackage-lock.jsonversionato e usanpm ci. - Impatto: medio, ma sulla capacità di rilasciare. Una CI che può diventare rossa da sola fa perdere il segnale: la prima reazione a un fallimento non riconducibile al diff è ririgare il job, che è esattamente l'abitudine che il runbook vieta (§3, «mai retry alla cieca»). E il rischio non si ferma ai tipi: lo stesso meccanismo può far entrare in produzione un minor con un cambio di comportamento — la serializzazione di un tipo, il default di un pool — che nessun test copre, senza che alcun commit lo registri.
- Sforzo: M. Il diff è piccolo (generare un
constraints.txto unrequirements.lockconpip-compile/uv pip compilee farlo usare a CI e Dockerfile), ma la parte vera è decidere il processo: chi rigenera il lock e con quale cadenza, come si distingue un bump deliberato da una deriva, e come si evita che il lock diventi il posto dove le advisory invecchiano indisturbate (il problema opposto, già visibile su pypdf). Va deciso insieme a quello, non separatamente. - Trovato: 2026-09-25
Nessun controllo automatico che un file pubblicato nel vault sia leggibile dagli altri lettori¶
- Problema: la 2.1.16 ha corretto sei siti che pubblicavano file nel vault con i permessi
0600dimkstemp, e li ha fatti passare tutti perapp/vault_io.py. Ma nulla impedisce al settimo di nascere: il prossimotempfile.mkstemp(...)+os.replace(...)scritto a mano reintroduce esattamente lo stesso difetto, in silenzio, e la 2.1.12 dimostra che succede davvero (lachmodfu aggiunta solo nel sito appena creato, senza cercarne i fratelli). Il difetto non è visibile in nessun log, non rompe nessun test funzionale, e si manifesta solo sul lato Obsidian/LiveSync del vault condiviso — cioè fuori dal processo che lo ha causato. - Evidenza:
backend/app/vault_io.py(la correzione, con la motivazione nell'header);backend/tests/test_vault_file_mode.py(i sette punti pinnati uno per uno, enumerati a mano);backend/app/routers/ingest.py:947nella 2.1.12 (lachmodopen-coded, unico sito corretto per quattro settimane). Il contrasto utile è conI5, che è verificato in CI dabackend/scripts/check_obsidian.py: la validità strutturale del vault ha un guardiano automatico, la sua leggibilità no. - Impatto: medio, e crescente con la superficie di scrittura. Ogni nuovo ingresso (un formato sorgente, un'operazione di manutenzione, un export) è un'occasione di regressione, e il costo di una regressione è un vault che Obsidian non riesce più a leggere — il tipo di guasto che l'utente attribuisce a LiveSync, non a Synapse. La difesa attuale è un test che elenca i siti noti: cresce solo se chi aggiunge un sito si ricorda di aggiungerlo.
- Sforzo: M. Il diff è piccolo in entrambe le direzioni possibili, ma sono scelte diverse e
vanno decise, non sommate: (a) un gate statico in
test_code_quality.pyche vietimkstemp+replacefuori davault_io— semplice, ma va insegnato a distinguere i temp file del vault da quelli legittimamente0600in/tmp(provider/cli.py,deep_research.py); (b) un controllo a runtime nelcheck_obsidianesistente, che segnali i file del vault non leggibili in gruppo — copre anche i file arrivati da fuori Synapse, ma introduce una nozione di "permessi attesi" che oggi non esiste da nessuna parte. La parte vera è decidere quale delle due è la fonte di verità, perché averle entrambe significa due posti dove la regola invecchia. - Trovato: 2026-10-02
GraphCache perde il recompute di follow-up quando il bump arriva durante la rivalidazione in background¶
- Problema:
_kick_background_revalidate()imposta_in_flight = Truein modo sincrono e poi crea il task — ma il corpo del task non parte fino al successivo giro di event loop, e la prima cosa che fa èself._pending = False, con il commento «safe: nothing could have set it between in_flight=True and here». Pertick()è vero (non c'èawaitin mezzo); per il kick in background no: in quella finestra il chiamante ritorna lo snapshot stale e continua ad await-are, quindi unnotify_bump()concorrente può impostare_pending = Truee vederselo azzerare subito dopo. Il follow-up previsto da AC-F16db-3 non viene mai schedulato. - Evidenza:
backend/app/graph/cache.py:417-419(il set sincrono +create_task);backend/app/graph/cache.py:196(self._pending = Falsecon la precondizione che vale solo per il chiamante sincrono);backend/app/graph/cache.py:139-142(notify_bumpche imposta_pendingquando_in_flight). - Impatto: basso. Il difetto si auto-ripara: il marker non corrisponde più alla
data_version, quindi laGET /graphsuccessiva è un MISS e serve stale + rivalida. Il costo reale è che il grafo resta indietro di un bump finché qualcuno non lo richiede, invece di essere ricalcolato proattivamente — e che un invariante dichiarato nel docstring (un follow-up esattamente per ogni bump durante un run) non è vero su uno dei tre ingressi. - Sforzo: S come diff (passare al task la generazione/il flag catturato al momento del
kick, o spostare l'azzeramento di
_pendingnel chiamante sincrono), M come verifica: la macchina a stati ha tre ingressi (tick, MISS inline, kick in background) e cinque campi condivisi, e i test attuali la guidano con un clock iniettato ma mai con un bump dentro la finestra fracreate_taske la prima riga del task. Serve un test che sappia schedulare in quella finestra, ed è quel test — non la riga corretta — il lavoro. - Trovato: 2026-10-02
Il controllo aggiornamenti non ha cache negativa: un 403 di GitHub lo fa ripartire a ogni richiesta¶
- Problema:
_fetch_latest_version()dichiara nel docstring di essere «cached ~1h to respect the unauthenticated rate limit (60/h)», ma la cache viene scritta solo sul percorso di successo. Un non-200 (compreso il 403 da rate-limit) e qualsiasi eccezione ritornano il valore stale senza aggiornare il timestamp, quindi la condizione d'ingresso della cache resta falsa e la chiamata successiva ritenta subito. Nel momento esatto in cui GitHub ci sta dicendo di rallentare, smettiamo di avere una cache. - Evidenza:
backend/app/ops/system_update.py:68-88:if cached is not None and (now - cached_at) < _CACHE_TTL_SECONDSlegge una tupla che il ramoresp.status_code != 200(:81-83) e l'except(:87-88) non riscrivono mai. Il chiamante èGET /ops/update-status, che la Home interroga. - Impatto: basso-medio. Non rompe nulla di visibile — il ramo di fallimento è progettato per degradare — ma trasforma ogni caricamento della Home in una richiesta uscente da 8 secondi di timeout verso un'API che ci ha già rifiutato, e il rate limit unauth è per IP: dietro un NAT condiviso o con più schede aperte si auto-alimenta.
- Sforzo: S come diff (scrivere
_latest_cache = (now, cached)anche sui rami di fallimento), ma la decisione non è il diff: una cache negativa con lo stesso TTL di un successo significa che un 403 transitorio nasconde per un'ora un aggiornamento appena pubblicato, e il pulsante «Aggiorna sistema» è esattamente ciò che l'utente va a guardare dopo aver letto le release notes. Servono due TTL distinti (breve sul fallimento, lungo sul successo) e una decisione su cosa mostrare in UI mentre la cache negativa è calda — cioè un cambio di comportamento su un flusso esistente, non una correzione. - Trovato: 2026-10-02
katex ha un'advisory in produzione, e il suo trust:false è ciò su cui poggia il bypass di DOMPurify¶
- Problema:
katex0.16.47 è coperta da GHSA-238p-pmpm-9mq7 ("existing prototype pollution can bypass trust restrictions", severity low, range0.11.0 – 0.18.1). La fix è inkatex@0.19.0, cioè un major — fuori portata per una patch. Ma il punto non è l'advisory in sé: è dove cade.renderMarkdown.tsrende il LaTeX contrust: falsee poi reinserisce l'HTML di KaTeX DOPO la sanitizzazione DOMPurify, con il commento «KaTeX output (trust:false) is XSS-safe by construction; injecting it here avoids DOMPurify stripping KaTeX's span/MathML markup». L'advisory dice esattamente che queltrust:falseè aggirabile. La giustificazione del bypass e il contenuto dell'advisory sono la stessa proposizione, una affermata e una negata: se iltrustcade, l'output di KaTeX non è più "safe by construction" e passa in un punto in cui nessuno lo filtra più. - Evidenza:
frontend/package.json("katex": "^0.16.47");frontend/src/components/chat/renderMarkdown.ts:88-93(trust: false),:268-270(la reinserzione post-sanitizzazione).npm audit --omit=devdafrontend/al 2026-10-09: 1 advisory (era 0 quando è stata scritta la voce del 2026-08-14 sulle dipendenze di build — quella voce afferma «nulla di tutto questo finisce nel bundle», e per katex non è più vero). - Impatto: basso oggi, ma è l'unico advisory che raggiunge il bundle servito e sta sul confine XSS del contenuto meno fidato che l'app tratta: output dell'LLM, corpi di pagina del vault e testo recuperato dal web in deep research. L'attacco non è autosufficiente — serve una prototype pollution già presente nella pagina, che oggi non c'è — quindi è un gadget di defense-in-depth, non una via sfruttabile da sola. È il motivo per cui va schedulato, non per cui va corso.
- Sforzo: S per il bump (
^0.16→^0.19), M per la verifica: 0.19 cambia i default district/truste l'outputhtmlAndMathml, quindi vanno riviste le prove di rendering LaTeX e lo screenshot E2E del tema scuro con KaTeX (quello già messo in quarantena e riabilitato in 2.1.0). La decisione che vale più del bump: se la pipeline debba continuare a reinserire HTML non sanitizzato fidandosi di un'opzione di una libreria terza, o se l'HTML di KaTeX vada sanitizzato con un profilo DOMPurify che conoscaspan/MathML — cioè non fidarsi più ditrust:falsecome perimetro unico. Quella è una riprogettazione del confine di sanitizzazione, non un aggiornamento di dipendenza. - Trovato: 2026-10-09