Files
ThothII/docs/superpowers/specs/2026-06-25-thothii-architecture-design.md
T
marcopan 2e34ba33e0 docs(spec): allinea §5.1 workspace YAML al Config reale (decisione B, post Task A2)
La prima stesura usava una struttura 'ideale' (relational/vector_db.collection/
embeddings.provider) che non combaciava col modello Config portato da ChironeWp3.
Allineato alla struttura reale (database/rest/vector_rest/vector_write_rest/
vector_db top-level). Aggiunta nota di allineamento + modello delle key D11.
2026-06-26 22:27:05 +02:00

72 KiB

ThothII — Design dell'architettura

Data: 2026-06-25 Stato: Draft, in attesa di review Fonti: prd/ThothII-prd.md, analisi di ChironeWp3/ (harness funzionante) e Thoth/thoth_sqldb2/ (modulo DB di riferimento)


1. Obiettivo

Costruire un sistema che, a partire da una richiesta in linguaggio naturale, generi SQL eseguibile su un database target, attraverso un workflow human-in-the-loop a 8 fasi orchestrato dal coding harness Pi in modalità RPC. Il sistema sostituisce l'interazione terminale di ChironeWp3 con un'interfaccia React guidata da uno scambio strutturato di JSON.

ThothII si articola in tre progetti autonomi (harness, backend, frontend), sviluppati e testabili in modo indipendente, integrati tramite un contratto JSON esplicito.

Posizione su ChironeWp3 (premessa importante)

ChironeWp3 è il punto di partenza dell'harness di ThothII, non un asset intoccabile o "collaudato al 100%". Il suo codice (CLI nsp in Python, gate extension in JS, skills markdown, modelli di sessione/workflow) viene portato dentro il progetto harness/ di ThothII per essere rivalidato e perfezionato, non assunto come affidabile per inerzia. Nello sviluppo si applicano quindi, per ogni componente portata:

  • Lettura critica del codice portato: si verifica che faccia davvero ciò che lo spec descrive, si individuano rigidità, duplicazioni (come il drift PHASE_NAMES già scoperto tra Python e JS), accoppiamenti nascosti, e invarianti sottintesi (come il no-limbo enforcement solo in JS).
  • Aggiornamento dove ThothII cambia il contratto: il gate passa da TUI a widget-descriptor (D2/D4); phase.py passa da ladder if==N a workflow.yaml data-driven (F2); il modello vector DB passa a doppia key (D11); si aggiunge nsp memory save-one. Queste non sono riusi passivi: sono modifiche che vanno progettate e testate.
  • Copertura di test (D10): i golden test su widget-descriptor validano il comportamento portato, anche per le parti "ereditate". Nessun componente viene considerato pronto solo perché proveniva da ChironeWp3.

Dove lo spec dice "riuso" va inteso come "punto di partenza da adattare e validare", non come "codice sicuro da prendere tal quale". Le decisioni che scelgono il riuso lo fanno perché riducono il rischio rispetto a una riscrittura da zero — ma il riuso stesso è lavoro di adattamento, non un'assunzione di affidabilità.


2. Decisioni architetturali ( locked )

Le decisioni seguenti sono state prese durante il brainstorming. Ogni voce riporta l'opzione scelta e il perché.

D1 — Decomposizione: tre progetti autonomi, harness autosufficiente harness/ contiene tutto il layer Pi: skills markdown, nsp CLI Python (codice deterministico), gate extension JS, .pi/. backend/ è puro Node+Fastify. frontend/ è React/Next/ShadCn/AGGrid. Perché: punto di partenza ampio da ChironeWp3 (da rivalidare e adattare in harness/, non assunto affidabile per inerzia — vedi §1), confini puliti, l'harness resta testabile in isolamento scambiando JSON.

D2 — Contratto centrale: widget-descriptor JSON L'harness emette e riceve messaggi JSON strutturati (vedi §4) invece di un TUI. Il backend è un traduttore passivo che forwarda questi messaggi tra Pi e frontend. Perché: mappa 1:1 i tipi di interazione del PRD, è minimale e testabile.

D3 — Workspace come YAML I workspace (DB relazionale + pgvector + evidence + embeddings) sono definiti in harness/workspaces/<name>.yaml. I secret stanno in .env, referenziati come ${VAR}. Il caricamento è isolato nel modulo harness/nsp/workspace.py. Perché: coerente con ChironeWp3, versionabile, testabile; il modulo workspace.py è il confine per future migrazioni.

D4 — Gate come extension JS dentro Pi La logica del gate (anti-bypass, input-lock, presentazione dei widget, iniezione kickoff) resta in un'extension JS dentro Pi, adattata per emettere widget-descriptor via extension_ui_request/extension_ui_response. Il backend non implementa gate. Perché: la logica del gate è la parte più delicata di ChironeWp3 (anti-bypass, no-limbo, iniezione kickoff) ed è il punto di partenza più ragionevole — nonostante richieda rivalutazione e adattamento al nuovo contratto widget-descriptor (vedi §1). Il protocollo RPC di Pi è progettato per questo. Spostarla nel backend significherebbe reimplementare l'anti-bypass da zero.

D5 — Persistenza su filesystem (identica a ChironeWp3) Sessioni e artefatti su harness/sessions/<id>/. Il ledger delle decisioni (review_decisions.jsonl) è append-only ed è la verità. La fase corrente è derivata (chronological fold del ledger). Il backend fa da proxy REST verso il filesystem via nsp ... --json. Perché: il modello di sessione di ChironeWp3 (ledger append-only + fold cronologico) è uno dei pezzi di valore e il punto di partenza più solido — da rivalutare e adattare (es. introducendo schema_version, §5.3) ma non da riscrivere da zero nel backend.

D6 — Auth a livelli con middleware OIDC pluggabile Tre modalità selezionate da config: none (utente dev@local, modalità primaria nell'MVP modello B — l'operatore è sulla propria macchina), mock (utente statico da header per test), oidc (OIDC standard: Authentik, Entra ID — stessa codepath, rilevante nell'evoluzione ad A "web app centrale"). Le sessioni ThothII sono associate all'utente autenticato (campo author nel manifest). Perché: copre tutti i casi del PRD con una sola codepath OIDC; none è la scelta naturale per l'MVP modello B (postazione singolo-operatore) e rende i test dell'harness indipendenti dall'auth.

D7 — Backend Node+Fastify+TS con DB ibrido nsp Python possiede tutta la logica DB deterministica (introspection, value sampling, RRF/LSH, schema-link, validazione/preview durante il workflow). Il backend Node si collega al DB del workspace solo per eseguire lo SQL finale approvato in read-only, alimentare AGGrid e gli export. Perché: coerente con D1 (harness autosufficiente); risolve informix (driver Node assenti); nessuna riscrittura di thoth_sqldb2 in TS. Rischio architetturale (D7): due codepath di esecuzione SQL vanno mantenute allineate sull'enforcement read-only. Mitigazione: un'unica fonte di verità per "cosa è permesso eseguire" (config execution.allow del workspace, condivisa tra nsp e backend).

D8 — nsp CLI resta Python Riuso diretto di ChironeWp3 (SQLAlchemy, psycopg2, datasketch/LSH, RRF, schema-link, output --json). Il gate JS dentro Pi lo chiama via bash. Perché: partire dal codice Python esistente di ChironeWp3 (SQLAlchemy, psycopg2, datasketch/LSH, RRF, schema-link) riduce il rischio rispetto a una riscrittura TS da zero — ma il codice va comunque portato in harness/, letto criticamente, adattato ai nuovi contratti e coperto dai golden test (D10). I "due runtime" nell'harness non sono un problema reale.

D9 — Strategia di implementazione: vertical slice per fase del workflow Ordine harness → backend → frontend, ma con loop end-to-end precoci: ad ogni ciclo di sviluppo si estendono tutti e tre i layer solo per la fase del workflow corrente (F1, poi F2, ecc. — non "fasi di sviluppo" generiche). Feedback continuo, nessun progetto in sospeso.

D10 — Test dell'harness: fake-Pi JSONL + golden test Un fake-pi (script Python o JS) implementa il protocollo RPC di Pi. I test verificano che i widget-descriptor emessi matchino golden JSON salvati. Deterministico, senza LLM né Pi reale nei CI. Perché: è l'unica che testa il contratto widget-descriptor (la parte nuova) in modo deterministico e ripetibile.

D11 — Modello a doppia API key per il vector DB + upsert mirato delle Memory Il vector DB (pgvector) è esposto via REST con due endpoint separati, due API key distinte:

  • Reader (vector_rest, key *_API_KEY): solo search_similar / list_tables, sola lettura.
  • Writer (vector_write_rest, key *_WRITE_API_KEY, opzionale): solo existing_vector_hashes / upsert_vector_records, upsert + hash sync, no delete/clear.

Entrambi condividono la stessa URL e lo stesso tipo di config (RestConfig | None). "Writer assente" = sezione omessa o key vuota. Un unico client HTTP (VectorRestClient) parametrizzato dalla config: quale RestConfig gli viene passata determina key e allowlist.

Per salvare una Memory generata durante una sessione (anche remota), ThothII introduce un nuovo comando mirato nsp memory save-one <decision_seq> (deviazione controllata da ChironeWp3, dove il salvataggio avviene solo via memory index = resync completo, server-only). save-one fa un singolo upsert di un record via writer key, usando le RPC upsert_vector_records esistenti, con hash dedup client-side (SHA-256 del content, embedda solo new/changed). È gated da require_vector_write_allowed (permesso su workstation solo se writer configurato, exit 4 altrimenti). Perché: il caso d'uso reale è "salvare la memory appena creata in F5" — un resync intero (memory index) è sovradimensionato e memory promote è server-only. L'upsert mirato è efficiente e abilita il lavoro remoto (scopo esplicito della doppia key). Riuso totale delle RPC writer e del pattern di gating di ChironeWp3; solo il comando save-one è nuovo.

D12 — Modello di deployment: postazione remota (B) nell'MVP, evoluzione possibile a web app centrale (A) L'MVP segue il modello "postazione remota" di ChironeWp3: tutti e tre i layer girano sulla macchina dell'operatore in localhost (pattern "app desktop con UI browser", come Jupyter o VS Code server). Il vector DB e il DWH restano centrali, raggiungiti via REST con la doppia key (D11). L'evoluzione futura a "web app centrale" (A) sposta backend+harness su un server centrale e serve il frontend via browser a più utenti; i contratti FE↔BE e interni non cambiano — è un riposizionamento di deployment, non una riscrittura. Perché: corrisponde al modo di lavoro reale attuale (operatori su postazioni dedicate fuori dal server). Mantenere i 3 layer anche in B rende l'evoluzione B→A pulita (nessun refactor dei contratti) e lascia all'harness le sue responsabilità (workflow, gate) e al backend le sue (REST/SSE, job, API stabile) senza mescolarle.

D13 — Il testo libero dell'utente va interpretato, non ignorato Ogni volta che una risposta permette testo libero (campo freetext del widget, opzione "Altro — specifica", motivazione di un rifiuto, steering !), l'harness deve valutare il testo dell'utente cercando di interpretarlo al meglio nel contesto corrente della sessione (domanda, fase, artefatto mostrato, decisioni già prese), invece di passare alla risposta di default. Questa è una deviazione comportamentale esplicita da ChironeWp3, che tende a ignorare il testo libero a favore della risposta di default. Perché: il revisore che si prende la briga di scrivere testo libero sta comunicando qualcosa che le opzioni predefinite non coprono. Ignorarlo degrada la qualità del risultato (la sua correzione va persa) e la fiducia nell'interazione. Il costo è nel prompt/gate, non in una nuova infrastruttura. Vedi §4.6 per il comportamento atteso.

D14 — Gestione esplicita delle richieste incomprensibili (non ambigue): Value/Schema Linking e SQL Formula Evidence ChironeWp3 tratta due casi critici in modo inadeguato e vanno sviluppati esplicitamente in ThothII come capacità di prima classe dell'harness. Entrambi riguardano la situazione in cui la richiesta dell'utente non è ambigua (il revisore sa cosa vuole) ma è incomprensibile per il modello senza chiarimenti o evidenze. Vedi §4.7 per il dettaglio dei due casi. Perché: sono i casi in cui un NL→SQL "silenzioso" produce SQL sbagliato senza che nessuno se ne accorga (il modello indovina la colonna sbagliata per un valore, o inventa una formula per un concetto). Svilupparli è core-value, non optional. Sono punti di perfezionamento sostanziale del codice portato da ChironeWp3 (vedi §1).

D14a — Value and Schema Linking (value → column grounding). Quando la domanda cita un valore (es. "ablazione", "DRG 123", "fibrillazione atriale") il cui mapping a colonna/e è poco chiaro per il modello, l'harness deve chiarire con l'utente (usando l'indice LSH + RRF, che già restituiscono table.column → valore → score) quale colonna/e corrispondono al valore — gestendo esplicitamente il caso multi-colonna e quello in cui il valore richiede una formula (si collega a D14b). In ChironeWp3 la metà retrieval esiste (nsp search --kind values) ma manca del tutto la metà workflow: nessun decision type, nessuna istruzione skill, nessun widget, l'aggregazione LSH collassa un valore presente in N colonne a una sola. Perché: è il caso in cui il modello scrive WHERE colonna_sbagliata = 'ablazione' in silenzio. Il revisore che cita un valore lo fa apposta — va confermato il grounding prima che diventi SQL.

D14b — SQL functions and formula evidence (concept → formula, reviewer-approved). Quando la domanda contiene un concetto calcolato (es. "fascia di età pediatrica", "indice di Charlson", "ricovero a 30 giorni", ma anche "ablazione" quando richiede più colonne) che si traduce in una formula SQL su più campi, l'harness deve proporre la formula e chiedere l'approvazione del revisore prima che fluisca nel CTE/SQL. Richiede un nuovo tipo di evidenza "formula" (il tier:"concept" di ChironeWp3 è definito ma mai usato; il contenuto esiste già negli 30-esempi-nlq/*.md ma come prose, non come unità recuperabile/validabile) e un flusso di approvazione per-concetto (analogico al gate CTE di F6, ma a granularità concetto e potenzialmente riutilizzabile tra sessioni). Perché: oggi il modello inventa la formula o la legge da prose non strutturata, senza conferma. Un concetto calcolato tradotto male invalida tutta la query. L'approvazione del revisore sul SQL-espressione + colonna/e è load-bearing per la correttezza.

D15 — Rollback a tre granularità con teardown completo dei documenti Il revisore deve poter tornare indietro a tre livelli: (a) ripresenta il widget corrente e scarta l'ultima risposta (granularità step, dentro la stessa fase); (b) torna all'inizio dello step precedente del workflow (fase precedente); (c) torna a uno step specifico (qualsiasi fase precedente). In ogni caso di rollback, tutte le scelte fatte dopo il punto di rollback vanno dimenticate e gli artefatti prodotti vanno cancellati. Questa è una deviazione sostanziale da ChironeWp3, dove phase reopen fa solo append_decision (niente teardown), gli helper aggregano decisioni stale+nuove ignorando il boundary di reopen, e non esiste la granularità step. Vedi §4.8. Perché: un rollback che lascia artefatti stale e decisioni incoerenti è peggio di niente — il modello/prossimo passo legge uno stato inconsistente (es. CTE orfani che bloccano finalize, già verificato come bug latente in ChironeWp3). La correttezza post-rollback è load-bearing.

D16 — Minimizzazione del contesto per LLM medio (35B, <200k token) tramite task document per-step L'architettura deve far sì che ad ogni passaggio il modello riceva un singolo documento di task con esattamente le informazioni necessarie per eseguire il task corrente, derivate dagli step precedenti — non l'intero contesto accumulato nella conversazione. Obiettivo: poter usare un modello di medie dimensioni (35B param, contesto <200k). L'implementazione richiede: (1) un generatore di task document che legge gli artefatti precedenti e emette il slice minimale per la fase corrente; (2) enforcement che il modello non legga mai artefatti integrali fatali (es. physical.yaml = 760KB ≈ 190k token, report.md = 344KB — entrambi fatali per un 35B); (3) gestione del contesto della chat (pruning/ricomposizione) perché la conversazione non cresca senza bound. Vedi §4.9. Perché: la base "context-fresh dagli artefatti via nsp" di ChironeWp3 è giusta, ma non compatta il contesto e non enforce i bound — un modello 35B collasserebbe leggendo lo schema fisico integrale. La target hardware impone il constraint; il task document per-step è la soluzione architetturale.


3. Architettura dei tre progetti e flusso dei dati

Modello di deployment (D12, MVP = B "postazione remota"): i tre layer girano in localhost sulla macchina dell'operatore. Il vector DB e il DWH restano centrali, raggiungibili via REST con la doppia key (D11). L'evoluzione futura ad A ("web app centrale") riposiziona backend+harness su server centrale senza modificare i contratti.

┌─ POSTAZIONE OPERATORE (localhost, MVP) ─────────────────────────────┐
│                                                                      │
│  ┌─ FRONTEND (frontend/) ─────────────────────────────────────────┐ │
│  │  browser → localhost · React + Next.js + ShadCn + AGGrid        │ │
│  │  Consuma SOLO la REST del backend (in localhost).               │ │
│  │  • SSE: stream di eventi (text_delta, ui_request, lifecycle)    │ │
│  │  • POST: risposte utente, azioni (reset fase, export)           │ │
│  └───────────────────────────────▲──────────────────────────────────┘ │
│                                   │ HTTP/SSE (JSON, localhost)        │
│  ┌─ BACKEND (backend/) ───────────┴────────────────────────────────┐ │
│  │  Node.js + Fastify + TypeScript · gira in localhost             │ │
│  │  • Avvia Pi: spawn("pi", ["--mode","rpc"], {cwd: repoRoot})     │ │
│  │  • RpcClient: LineSplitter (LF only!) + dispatch per id         │ │
│  │  • Traduttore: Pi extension_ui_request → ui_request (FE)        │ │
│  │                FE ui_response → extension_ui_response (Pi)      │ │
│  │  • DB workspace: SOLO SQL finale read-only → AGGrid/export      │ │
│  │  • REST: workspaces, sessions, workflow, artifacts              │ │
│  │  • Auth middleware (D6): none (primaria MVP B) | mock | oidc    │ │
│  └───────────────────────────────▲──────────────────────────────────┘ │
│                                   │ JSONL (newline-delimited, LF only)│
│  ┌─ HARNESS (harness/) ───────────┴────────────────────────────────┐ │
│  │  ├── .pi/                      ← config progetto Pi              │ │
│  │  │   ├── settings.json, themes/                                │ │
│  │  │   ├── prompts/             ← /nuova-domanda, /riprendi       │ │
│  │  │   ├── skills/nsp-sessione/ ← SKILL.md + rewriting/mem/cte/sql│ │
│  │  │   └── extensions/nsp-gate.js ← GATE: anti-bypass + widget    │ │
│  │  ├── nsp/ (Python package)    ← CLI deterministica, parla --json│ │
│  │  │   ├── cli/                  ← command groups (da ChironeWp3) │ │
│  │  │   ├── workspace.py          ← caricamento YAML (confine D3)  │ │
│  │  │   ├── workflow.py           ← lettura workflow.yaml (F2)     │ │
│  │  │   ├── db/, rest/, search/, mschema/, vectorstore/, session/ │ │
│  │  ├── workflow.yaml            ← UNICA definizione workflow (F2) │ │
│  │  ├── workspaces/*.yaml        ← definizioni workspace           │ │
│  │  ├── sessions/<id>/           ← persistenza FS (locale, PII)    │ │
│  │  └── tests/ + fake-pi/        ← golden test (D10)               │ │
│  └──────────────────────────────────────────────────────────────────┘ │
│                                                                      │
└────────────────────────────────▲─────────────────────────────────────┘
                                  │ HTTPS (443), doppia API key (D11)
┌─ SERVER / SUPABASE CENTRALE ────┴─────────────────────────────────────┐
│  /dwh/      PostgREST ──► DWH (schema datawarehouse, read-only)       │
│  /vector/v1 RPC allowlist                                          │
│             ├── reader (key reader):  search_similar, list_tables   │
│             └── writer (key writer):  existing_vector_hashes,       │
│                                       upsert_vector_records (no del)│
│                              ──► pgvector (schema_records/evidence/  │
│                                              memory)                │
└──────────────────────────────────────────────────────────────────────┘

Flusso di una decisione (es. "promuovi tabella" in F4)

  1. Il LLM dentro Pi chiama il tool reviewer_decide. Il gate nsp-gate.js costruisce un widget-descriptor {type:"ui_request", id:"u42", phase:"F4", widget:"multiselect", options:[...]}.
  2. Il gate lo emette via ctx.ui.custom. In RPC mode diventa extension_ui_request.
  3. Il RpcClient del backend lo riceve, lo forwarda via SSE al frontend come ui_request.
  4. Il frontend renderizza il widget, l'utente seleziona, il FE fa POST /sessions/:id/response con {type:"ui_response", id:"u42", choices:[...], decision:{type:"table_promoted"}}.
  5. Il backend lo traduce in extension_ui_response e lo scrive su stdin di Pi.
  6. Il gate lo legge, chiama nsp decision add (autorizzato dal suo stesso hook anti-bypass), il ledger si aggiorna, la fase deriva.

4. Il contratto widget-descriptor (D2)

Questa è la parte centrale: il "linguaggio" tra harness, backend e frontend. Deriva dalla verifica esaustiva dei pattern di interazione reali di ChironeWp3 (10 pattern, 4 primitive UI), mappati su una tassonomia di 6 widget.

Principi di flessibilità

Il contratto è progettato per accogliere future modalità di interazione:

  • widget è un campo aperto, non un enum chiuso. I 6 widget sono i kind iniziali registrati. Aggiungerne uno richiede definire un nuovo kind, il suo payload e il renderer frontend. Non cambia l'infrastruttura (forwarding, correlazione id, ledger).
  • Estensibilità per composizione, non per enumerazione. I comportamenti complessi si ottengono componendo primitive (artefatto+decisione = artifact-gate; select+testo se Altro = linkage option.opens; multiselect+contesto = multiselect con content).
  • Versioning del contratto + fallback graceful. Ogni messaggio porta schema_version. Il frontend gestisce i kind sconosciuti con un fallback universale: se arriva un widget che non sa renderizzare, mostra il payload come JSON formattato in un box "Widget non supportato (kind: X) — rispondi manualmente".

Messaggi harness → backend → frontend

// ui_request — l'harness chiede qualcosa all'utente
{
  "type": "ui_request",
  "id": "u42",                      // correlazione con la risposta
  "schema_version": 1,
  "session_id": "2026-06-25-...",
  "phase": "F4_schema_linking",     // fase del workflow
  "title": "Conferma le tabelle per lo schema-linking",
  "intro": "Ho selezionato 3 tabelle candidate. Segna quelle da promuovere.",
  "widget": "multiselect",          // vedi tassonomia §4.1
  "options": [                      // per select/multiselect
    {"id": "t_pazienti", "label": "pazienti",
     "meta": {"signals": {...}}, "selected": true}
  ],
  "reserved": ["back", "exit", "other"],  // opzioni di controllo framework
  "timeout_ms": null
}

// info — messaggio informativo (tipo 1 PRD), non blocca, niente risposta
{
  "type": "info",
  "schema_version": 1,
  "session_id": "...",
  "phase": "F4_schema_linking",
  "level": "info",                  // info|warning|error
  "text": "Sto per proporti lo schema-linking..."
}

Messaggi frontend → backend → harness

// ui_response — la risposta dell'utente a una ui_request
{
  "type": "ui_response",
  "id": "u42",                      // correla la ui_request
  "kind": "multiselect",
  "choices": ["t_pazienti", "t_ricoveri"],
  "decision": {"type": "table_promoted"}  // mappa sul ledger
}

// reserved control — "torna indietro"/"esci"/"altro"
{"type": "ui_response", "id": "u42", "control": "back"}
{"type": "ui_response", "id": "u42", "control": "freetext", "text": "..."}

4.1 Tassonomia dei widget (6)

Ogni voce: copertura, caratteristiche obbligatorie, esempio d'uso.

info — fire-and-forget (notifica toast, livello info/warning/error). Non blocca, non richiede risposta. Usata per: notifiche di sistema (es. "sto per proporti lo schema-linking"), preflight Ollama, warning di input-lock.

select — single-pick da una lista di opzioni. Caratteristiche obbligatorie: escape hatch framework iniettati (Altro/Torna indietro/Esci), marker option.recommended evidenziato "(consigliato)", linkage option.opens per aprire un widget figlio, invariante no-limbo (Esc/cancel non è mai risposta → loop). Usata per: disambiguazione F1, conferme sì/no (riapri fase), scelte singole.

multiselect — multi-pick con checkbox. Estensioni richieste: stato iniziale pre-selezionato (selected[]), toggle "seleziona/deseleziona tutti", artifact.content embed scrollabile (contesto), descrizione del focus (progressive disclosure), allow_empty: true|false, Altro inline che ritorna {text, choices} insieme, side-effect di deselezione (può emettere decision negativa). Usata per: promozione tabelle F4, selezione memoria F2/F5, evidence accepted/rejected.

freetext — testo libero. Sempre figlio di select/artifact-gate (via Altro/Rifiuta) o via canale steering ambientale. Il testo va interpretato dall'harness nel contesto della sessione, non ignorato a favore di una risposta di default (D13, vedi §4.6). Usata per: "Altro — specifica", motivazione del rifiuto, steering !.

artifact-gate — artefatto + lista disposizioni (fusione di artefatto e select del Pattern 3 di ChironeWp3). È il cuore decisionale di F5/F6/F7. Contenuto: artefatto renderizzato (schema_linking con render umano + JSON intero; cte che legge ctes/<name>.sql; sql che legge sql_final.sql + esegue preview live; memory, question, result) + disposizioni (confirm/approve_reject/view_only). Rifiuta → linkage a freetext per la motivazione. Perché separato da artifact: ChironeWp3 non mostra mai un artefatto puro — l'artefatto è sempre accoppiato a una decisione (Approva/Rifiuta/Altro/Torna/Esci). La decisione è load-bearing sul documento.

artifact — view-only, nessuna disposizione. Per artefatti da mostrare nel pannello destro senza decisione (COT, thinking, risultato in lettura).

4.2 Linkage annidato

Un'opzione può dichiarare quale widget apre se scelta:

ui_request select:
  options: [
    { id:"approve", label:"Approva" },
    { id:"other", label:"Altro — specifica…",
      opens: { widget:"freetext", title:"Specifica…" } },
    { id:"reject", label:"Rifiuta (rivedi e riprova)",
      opens: { widget:"freetext", title:"Motivazione del rifiuto" } },
    { id:"back", label:"Torna indietro",
      opens: { widget:"select", title:"A quale fase?", options:[...] } }
  ]

Il frontend, se l'utente sceglie "other", mostra il widget figlio e raccoglie entrambe le risposte, inviandole insieme nella ui_response. Il testo raccolto via Altro o Rifiuta è testo libero che l'harness deve interpretare nel contesto (D13, §4.6), non un'etichetta da archiviare e dimenticare.

4.3 Canale steering ambientale

Free-text non modale (prefisso ! in ChironeWp3): il revisore può iniettare testo arbitrario in qualsiasi momento durante una sessione attiva. Non è un widget modale, è un canale stream-level. Modellato come un endpoint POST /sessions/:id/steer con {text}. Lo steering è il caso più forte del principio D13: il revisore interrompe appositamente per ridirezionare — ignorare il testo o trattarlo come "continua con il default" vanifica lo scopo del canale.

4.4 Evento di sistema: auto-advance silenzioso

Una fase può chiudersi senza input utente (F2/F6 vuote, phase_auto_approved). Non è un widget — è l'assenza di interazione. Modellato come evento SSE distinto {type:"system_event", event:"auto_advance_silent", phase:"F2"} perché il revisore possa capire perché una fase è sparita.

4.5 Ledger decisioni (enumerati leggendo il codice ChironeWp3, 22 tipi)

I decision.type del widget-descriptor sono esattamente quelli del review_decisions.jsonl di ChironeWp3, verified leggendo src/psdwp3/session/decisions.py:9-32:

  • F1 chiarimento: concept_clarified, ambiguity_open
  • F2 memoria: memory_rejected (side-effect di deselezione), phase_auto_approved (vuota)
  • F3 riscrittura: question_rewritten
  • F4 schema-linking: table_promoted, table_excluded, column_corrected, join_modified, evidence_accepted, evidence_rejected
  • F5 sintesi: phase_approved
  • F6 cte: phase_skipped, cte_approved, cte_corrected*, cte_rejected, phase_auto_approved
  • F7 sql finale: sql_revised, sql_approved, sql_rejected
  • F8 datamart: datamart_requested, datamart_declined
  • meta cross-fase: phase_approved, phase_auto_approved, phase_reopened, phase_skipped

(*) cte_corrected è nel Literal ma non è mai emesso dal gate di ChironeWp3 (reserved/historic).

4.6 Interpretazione del testo libero (D13)

Questa è una deviazione comportamentale esplicita da ChironeWp3, registrata come D13. Il principio: quando l'utente fornisce testo libero, sta comunicando qualcosa che le opzioni predefinite non coprono. L'harness lo valuta e cerca di interpretarlo nel contesto, invece di passare alla risposta di default.

Dove si applica (tutti i canali di testo libero):

  • Altro — specifica… in select e multiselect (linkage option.opens, §4.2): l'utente descrive una scelta fuori dalle opzioni. L'harness interpreta il testo e, se necessario, lo traduce in una decisione o in una nuova proposta (es. in F1 disambiguazione, il testo può chiarire un concetto non previsto; in F4 può indicare una tabella o una correzione non in lista).
  • Rifiuta (rivedi e riprova) → motivazione (linkage, §4.2): la motivazione del rifiuto non è decorativa — deve guidare la rigenerazione. Rifiutare un CTE con "la join è sbagliata, va su dim_pazienti non fact_ricoveri" deve portare a rivedere proprio quel join, non a riproporre lo stesso CTE.
  • Steering ambientale ! (§4.3): è il caso più forte. L'utente interrompe per ridirezionare ("stai escludendo i pazienti pediatrici", "considera solo il 2024"). Ignorarlo o trattarlo come "continua" vanifica lo scopo.

Comportamento atteso dell'harness (il "cosa", non il "come"):

  1. Riceve il testo libero nella ui_response (campo text per Altro/motivazione, o payload dello steering).
  2. Lo valuta nel contesto corrente: domanda originale (e sua versione riscritta in F3+), fase attiva, artefatto mostrato, decisioni già presenti nel ledger, candidate/elementi in gioco. L'interpretazione è compito del LLM dentro Pi, guidato dalla skill; il gate si limita a consegnargli il testo e a non permettergli di "svignarsela" con un default.
  3. Agisce coerentemente: applica l'interpretazione — corregge la proposta, aggiunge un vincolo, riapre una fase se serve, riscrive la domanda se il chiarimento lo richiede. Se il testo è ambiguo, chiede chiarimento (emette un nuovo widget) invece di indovinare in silenzio o ignorare.
  4. Lascia traccia: quando il testo libero influenza una decisione, questa va registrata nel ledger con il rationale che riporta (anche sintetizzato) il testo dell'utente, così l'origine della decisione è ricostruibile.

Cosa NON deve fare (anti-pattern da ChironeWp3 da evitare):

  • Non trattare il testo libero come etichetta opaca da archiviare e dimenticare.
  • Non passare alla risposta/default di default quando c'è testo non vuoto.
  • Non "accettare" la direzione dell'utente a parole e poi proseguire con la proposta originaria.
  • Non ignorare il testo dello steering trattandolo come conferma generica.

Implementazione (dove vive): la responsabilità è dell'harness — in particolare nella skill nsp-sessione (istruzioni al LLM su come trattare il testo libero in ogni fase) e nel gate nsp-gate.js (che consegna il testo al modello e, come per gli altri invarianti, non lascia spazio a "svignarsela"). Il backend e il frontend sono solo trasporto: il FE raccoglie il testo, il BE lo forwarda. Nessuna logica di interpretazione fuori dall'harness. È un punto esplicito di perfezionamento del codice portato da ChironeWp3 (vedi §1), e va coperto dai golden test (D10) con scenari in cui l'utente fornisce testo libero e si verifica che l'harness ne tenga conto.

4.7 Gestione delle richieste incomprensibili (non ambigue) — Value/Schema Linking e SQL Formula Evidence (D14)

Questa sezione specifica il principio D14: due casi in cui la richiesta dell'utente non è ambigua (sa cosa vuole) ma è incomprensibile per il modello senza chiarimenti o evidenze. Sono i casi in cui un NL→SQL "silenzioso" produce SQL sbagliato senza allarme. Entrambi sono sotto-sviluppati in ChironeWp3 e vanno sviluppati come capacità di prima classe. Sono punti di perfezionamento sostanziale del codice portato (vedi §1), non riuso passivo.

Distinzione chiave — ambiguo vs incomprensibile. F1 (chiarimento, concept_clarified/ambiguity_open) gestisce il caso ambiguo: il concetto della domanda ammette più letture e il modello chiede quale. I due casi qui sono incomprensibili: il modello non sa tradurre un elemento specifico della domanda in schema SQL, anche sapendo cosa vuole l'utente. Sono ortogonali a F1 e oggi cadono tra le fasi.

4.7.1 Value and Schema Linking (D14a)

Il caso. La domanda cita un valore (es. "ablazione", "DRG 123", "fibrillazione atriale", "ricovero in UTIC") che il modello non sa mappare a una colonna. Il valore non è ambiguo (l'utente sa cosa intende), ma il modello non sa dove vive nello schema.

Cosa esiste in ChironeWp3 (metà retrieval, OK). L'indice LSH + RRF funziona: nsp search "<valore>" --kind values --json restituisce già table.column → valore → score. Il dato c'è.

Cosa manca (metà workflow, da sviluppare). Tutto il flusso che usa quel dato per chiarire con l'utente:

  • Nessun decision type per il value-grounding (es. value_grounded).
  • Nessuna istruzione nella skill di: "se la domanda cita un valore, esegui nsp search --kind values; se il valore mappa a >1 colonna o a una colonna non ovvia, chiedi conferma del grounding."
  • L'aggregazione LSH collassa un valore presente in N colonne a una sola (_aggregate_lsh in search/__init__.py tiene solo il best). Il caso multi-colonna non è rappresentato.
  • Nessun widget dedicato "conferma valore X → colonna Y".

Il caso ablazione prova che il gap è reale e multi-colonna. "Ablazione" nello schema Chirone si calcola da più punti: il flag fact_..._ablazione.ablazione_transcatetere, e/o fact_see_ablazione_procedura_patologia.patologia = 'ablazione', e/o procedure_type = 'ablazione'. Il modello che scrive WHERE ablazione_transcatetere IS TRUE in silenzio può produrre una query semanticamente diversa da quella voluta. Il grounding del valore è esso stesso una decisione di formula (collegamento a §4.7.2).

Comportamento atteso in ThothII:

  1. Trigger: durante la riscrittura/analisi (F3/F4), per ogni valore letterale citato nella domanda, l'harness esegue nsp search "<valore>" --kind values.
  2. Decisione di chiarire: se il valore mappa a più di una colonna, o a una colonna che il modello non avrebbe scelto da solo, o a una colonna che richiede formula (caso §4.7.2), l'harness presenta un grounding clarification (widget select o multiselect che mostra i candidati colonna → valore → score con provenance LSH).
  3. Interpretazione del testo libero (D13): se l'utente sceglie "Altro" e indica una colonna o una formula diversa, l'harness la valuta nel contesto, non la ignora.
  4. Registrazione: il grounding confermato si registra nel ledger con il nuovo decision type (es. value_grounded, subject = il valore, detail = colonna/e scelta, rationale = score + eventuale testo utente) e si riflette in schema_linking.json (i Candidate oggi non hanno un campo "valore grounded" / "valore di filtro").
  5. Connessione alla formula: se il grounding richiede una formula (es. ablazione), si passa al flusso §4.7.2.

Nuovi elementi dati:

  • Decision type value_grounded (+ value_grounded_multi se serve distinguere).
  • Estensione di Candidate in schema_linking.json con campo grounded_values: [{value, column, score}] per registrare dove ogni valore citato è stato ancorato.
  • Possibilmente un widget dedicato, ma il pattern select/multiselect con meta (provenance + score) basta; non serve un nuovo kind di widget.

**Dove nel workflow:**micro-step tra F3 (riscrittura) e F4 (schema linking), o un sotto-passo esplicito di F4. Nel modello data-driven di workflow.yaml (F2), si modella come un prerequisito/attività di una fase, non come fase numerica separata nell'MVP — ma l'hook va chiarito.

4.7.2 SQL functions and formula evidence (D14b)

Il caso. La domanda contiene un concetto calcolato (es. "fascia di età pediatrica", "indice di Charlson", "ricovero a 30 giorni", "ablazione") che si traduce in una formula SQL su più campi. Il modello non può "indovinare" la formula: o la recupera da una evidenza di formula, o la sintetizza e la fa approvare.

Cosa esiste in ChironeWp3 (contenuto, ma non strutturato). Gli artifacts/evidence/30-esempi-nlq/*.md contengono SQL completo già scritto per concetti come ablazione, cardioversione, device. Ma è prose opaca dentro esempi domanda→SQL, non un'unità "concetto → formula" recuperabile e validabile. In particolare: tier: "concept" è definito nello schema evidence (evidence/model.py) ma mai usato — zero file lo impostano, la retrieval non lo filtra.

Cosa manca (tutto il layer formula, da sviluppare).

  • Nessun tipo dato first-class "formula". Né evidence né le annotation di mschema possono esprimere "concetto C = espressione SQL E su colonne [c1, c2, …]".
  • Nessuna retrieval per formula: non esiste nsp search --kind formula "<concetto>".
  • Nessun decision type formula_approved/concept_formula_rejected.
  • Nessuna istruzione skill di proporre la formula per un concetto e chiederne approvazione prima del CTE.
  • Nessun flusso di approvazione per-concetto: oggi si approvano CTE/query intere (F6), non formule per concetto. La parola "formula" non appare nel codice.

Comportamento atteso in ThothII:

  1. Authoring (offline): un nuovo tipo di evidenza/artefatto per formule di concetto — frontmatter {concept, columns[], sql, status (auto/draft/reviewed), sources} + corpo SQL. Attiva il tier:"concept" morto (o un nuovo kind) rendendolo filtrato e recuperabile. Il contenuto esiste già negli 30-esempi-nlq; va ristrutturato da "esempi domanda→SQL" a "unità concetto→formula".
  2. Retrieval: nsp search --kind formula "<concetto>" restituisce la/e formula/e candidata(e), con provenance (recuperata vs sintetizzata).
  3. Runtime (per-sessione): per ogni concetto calcolato nella domanda, l'harness propone la formula (recuperata o sintetizzata) e chiede approvazione del revisore sul SQL-espressione + colonna/e, prima che fluisca nel CTE/SQL. Il widget è artifact-gate con kind:"formula" (template pronto: il runArtifactGate di ChironeWp3 già mostra SQL scrollabile + approve/reject/other).
  4. Registrazione: decision type concept_formula_approved/concept_formula_rejected, con subject = concetto, detail = formula approvata, rationale = provenance + eventuale testo utente.
  5. Riutilizzo (chiusura del cerchio con la memoria): le formule approvate possono essere persistite nello store formule (come memory.py fa per le decisioni riusabili) così che un "indice di Charlson" approvato in una sessione venga riutilizzato nelle successive. Da considerare post-MVP.

Nuovi elementi dati:

  • Artefatto formula: concept_formulas/<concept>.{yaml,sql} (frontmatter + SQL) o un kind:"formula" in evidence con campo sql/expression.
  • Decision type concept_formula_approved, concept_formula_rejected.
  • Estensione di schema_linking.json con concept_formulas: [{concept, sql, columns, status, source}].
  • nsp search --kind formula (parallelo a --kind values e --kind schema).
  • Opzionale: nsp formula save-one (analogico a memory save-one, D11) per persistere una formula approvata nello store centrale via writer key.

Dove nel workflow: micro-step di F4/F5 (dopo il grounding dei valori, prima della sintesi dello schema-linking). Nel modello workflow.yaml si modella come attività/prerequisito di una fase esistente, non come fase nuova nell'MVP.

4.7.3 Relazione tra i due casi e con il workflow data-driven

I due casi sono complementari e a volte sovrapposti: "ablazione" è sia un problema di value-grounding (in quale colonna) sia di formula (booleano OR patologia OR tipo). Il design deve gestire il continuum:

  • grounding semplice (valore → 1 colonna, confermato),
  • grounding multi-colonna (valore → N colonne, selezionate),
  • grounding con formula (valore/concetto → formula SQL su più colonne, approvata).

Tutti e tre registrano una decisione tipizzata nel ledger e si riflettono in schema_linking.json, diventando parte dell'artefatto che F5 presenta per la conferma di sintesi. Essendo il workflow data-driven (F2, §5.3), possono essere introdotti come micro-step senza rinumerare le fasi: si aggiungono attività/prerequisiti alla fase F4 (o a una nuova F4.5 interna), e si evolve gradualmente.

Impatto sui test (D10): entrambi i casi vanno coperti da golden test — scenari in cui la domanda cita un valore multi-colonna (es. ablazione) e un concetto calcolato (es. fascia pediatrica), verificando che l'harness (a) presenti il grounding/formula gate e (b) registri la decisione corretta. Senza questi test il comportamento "silenzioso" di ChironeWp3 può riaffiorare.

4.8 Rollback a tre granularità con teardown (D15)

Questa sezione specifica D15. Il requisito: il revisore può tornare indietro a tre livelli, e in ogni caso le scelte fatte dopo il punto di rollback vanno dimenticate e gli artefatti prodotti vanno cancellati.

Stato di ChironeWp3 (3 blocker verificati):

  • Nessun teardown dei documenti. phase reopen (phase_cmd.py:102-127) fa solo append_decision; nessuna I/O su file. Conseguenza: schema_linking.json, ctes/*.sql, sql_final.sql persistono stale dopo il reopen. Bug latente confermato: CTE orfani (non più nel plan riderivato) restano su disco e bloccano finalize perché itera glob("*.sql") richiedendo che ognuno sia testato (session_cmd.py:192-204).
  • Gli helper NON sono reopen-aware. current_phase (il fold, phase.py:36-47) è corretto, ma tutti gli altri helper leggono il ledger intero ignorando il boundary di reopen: approved_ctes (phase.py:125-126), _has_decision/_has_decision_subject (phase.py:138-143, usati da advance_problems), build_evidence_entries (artifacts.py:30-39), _compute_promotions (memory.py:135). Mescolano decisioni stale pre-reopen con quelle nuove.
  • Manca la granularità step. "Torna indietro" (doGoBack, nsp-gate.js:295-327) apre sempre il phase-picker → reopen di fase intera. Non esiste "ripresenta il widget corrente, scarta l'ultima risposta" perché il ledger è append-only senza tombstone/retract.

Le tre granularità richieste:

(a) Re-ask current widget (scarta l'ultima risposta, stessa fase). Il revisore risponde male a una domanda e vuole ridarla. Semantica: l'ultima decisione registrata per il widget corrente viene ritirata (tombstone/retract nel ledger), il widget viene ripresentato. Non cambia la fase. (b) Torna all'inizio dello step precedente del workflow. Reopen della fase precedente, con teardown di tutti gli artefatti e decisioni da lì in poi. (c) Torna a uno step specifico. Reopen di una fase arbitraria precedente, stesso teardown.

Architettura del rollback corretto (cosa serve in ThothII):

  1. Vista ledger "effective as of pointer". Un'unica funzione effective_decisions(session) che tutti gli helper consultano, invece degli scan ad-hoc. Implementazione: replay del ledger troncando all'ultima phase_reopened (o usando un generation counter / high-water-mark). approved_ctes, advance_problems, build_evidence_entries, _compute_promotions e chiunque legga il ledger deve passare da qui. È la singola fix architetturale più importante.
  2. Retract/tombstone per la granularità step. Per (a) serve poter ritirare l'ultima decisione senza cancellare la riga (audit). Introdurre un decision type decision_retracted con subject = decision_seq della decisione ritirata; la vista "effective" lo onora (la decisione ritirata non conta più, ma resta nell'audit). In alternativa, uno slot per-step mutabile distinto dal log di audit — più invasivo, si valuta.
  3. Teardown degli artefatti al reopen. phase reopen deve cancellare gli artefatti prodotti da fasi > target e ricalcolare lo stato derivato. Una funzione teardown_to_phase(session, target) che: cancella schema_linking.json/cte_plan.json/ctes/*.sql/cte_tests.json/sql_final.sql/evidence.json a seconda della fase target (la mappa fase→artefatto è nota dal workflow.yaml, §5.3 — ogni fase dichiara artifacts_out); ricrea quelli della fase target allo stato "vuoto/da produrre"; ricalcola evidence.json e l'insieme dei CTE approvati dalla vista effective. Risolve il bug degli orfani.
  4. Widget UI per le tre granularità. Il widget select con linkage option.opens (§4.2) le copre:
    • "Rispondi di nuovo a questa domanda" → semantica (a), ritira l'ultima decisione del widget corrente, ripresenta.
    • "Torna all'inizio della fase precedente" → semantica (b), reopen + teardown.
    • "Torna a una fase specifica…" → linkage a un widget select phase-picker → semantica (c), reopen + teardown. Il frontend deve rendere chiara la distinzione e la conseguenza ("questo cancellerà X e Y").

Invariante forte (da enforcement nel gate): dopo ogni rollback, lo stato di sessione (ledger effective + artefatti su disco + fase derivata) deve essere coerente — non deve esistere artefatto stale né decisione stale che conti. Un check session consistency (parte di nsp session check) lo verifica; se fallisce, il rollback non è completo.

4.9 Minimizzazione del contesto per LLM medio (D16)

Questa sezione specifica D16. Il requisito: ogni passaggio deve dare al modello un singolo documento di task con esattamente le informazioni necessarie, derivate dagli step precedenti — non la conversazione accumulata. Target: modello 35B, contesto <200k.

Stato di ChironeWp3 (buona base, ma 3 gap):

  • Base giusta: il design è "context-fresh dagli artefatti via nsp" — la skill istruisce comandi per-fase (nsp search, nsp schema render --table, nsp memory search), non replay della chat. Gli artefatti di sessione sono piccoli (KB).
  • Gap 1 — niente task document compilato. Non esiste un generatore che produce "question + schema-linking-deciso + il tuo task per questa fase" in un singolo documento minimale. Il modello deve auto-assemblare il contesto lanciando i comandi giusti.
  • Gap 2 — niente enforcement dei bound. Nulla impedisce al modello di leggere artefatti fatali. physical.yaml = 760KB ≈ 190k token, report.md = 344KB — entrambi fatali per un 35B/<200k. La skill steer-a su mschema-text --table (scoped, piccolo) ma è solo una raccomandazione.
  • Gap 3 — niente compaction della chat. Il gate inietta un kickoff one-shot e fa steer, ma nessun pruning/summarization. La conversazione cresce senza bound; su 8 fasi un 35B esaurisce la finestra.

Architettura del context minimization (cosa serve in ThothII):

  1. Generatore di task document per-step. Un componente task_doc(session, phase, step) che legge gli artefatti precedenti e gli input della fase, e emette un singolo documento compatto con: la domanda (originale + riscritta), lo schema-linking deciso (solo le tabelle/colonne promosse, non tutto lo schema), i grounding/formula approvati (D14), l'output dei CTE precedenti (riassunto, non intero), e il task specifico della fase/step corrente. Questo documento è l'input primario del modello per il passo — non la chat.
  2. Enforcement dei bound (deny-list di letture fatali). Il gate (o il layer RPC) blocca le letture di artefatti integrali che sforano il budget: physical.yaml, report.md integrale, e in generale qualsiasi file > soglia (es. 50KB) senza scope. Lo schema arriva al modello solo come slice scoped (mschema-text --table <promoted>) compilato nel task document. Questo è l'invariante che protegge il 35B dal collasso.
  3. Compaction della chat (per-fase). All'inizio di ogni fase, la chat precedente viene ricompattata: il modello riparte dal task document della fase + un riassunto minimale delle decisioni chiave delle fasi precedenti (estratto dal ledger effective, §4.8), non dal transcript integrale. Il transcript completo resta accessibile (audit) ma non è nel contesto attivo. Meccanismo: il gate svuota/resetta il contesto attivo del modello all'inizio di ogni fase, consegnando il task document + il brief delle decisioni. (Dettaglio implementativo: dipende dalle capability di Pi di gestire il contesto; da verificare nel piano harness.)

Budget indicativo per un 35B / <200k:

  • Task document per-step: target <20k token (schema scoped + stato + task).
  • Brief decisioni fasi precedenti: target <5k token.
  • Lascia ~175k token per il reasoning del modello sul task — abbondante per un singolo step.

Relazione con il rollback (D15): il task document è generato dalla vista effective del ledger (§4.8), quindi post-rollback riflette automaticamente lo stato corretto (decisioni stale escluse). Le due decisioni sono complementari: il rollback produce uno stato coerente, il context minimization lo serve al modello in forma compatta.

Impatto sui test (D10): golden test che verificano (a) il task document di una fase contiene esattamente il slice atteso (non artefatti integrali), (b) una lettura di physical.yaml è bloccata, (c) post-rollback il task document esclude le decisioni stale.


5. Modelli dati

5.1 Workspace — harness/workspaces/<name>.yaml

Nota di allineamento (post-brainstorming): la prima stesura di questa sezione usava una struttura "ideale" (relational:, vector_db.collection, embeddings.provider) che non corrispondeva al modello Config reale portato da ChironeWp3. Durante il Task A2 del piano harness si è scelto (decisione B) di allineare spec e codice alla struttura reale di Config, perché è uno dei pochi pezzi riusati quasi tal quali e ristrutturarlo avrebbe propagato il cambio a tutti i moduli che lo leggono. La struttura canonica verificata è harness/workspaces/chirone.example.yaml; il blocco YAML sotto è un estratto che riflette nsp/config.py fedelmente (sezioni database/rest/vector_db/vector_rest/vector_write_rest/embeddings/evidence/execution, tutte top-level; nessun relational: o vector_db.collection). Per l'esempio completo fare riferimento al file chirone.example.yaml.

# Estratto — struttura reale del Config (vedi chirone.example.yaml per il completo).
database:                       # DWH relazionale
  host: ${THOTH_DB_HOST}
  transport: rest               # direct | rest
rest:                           # richiesto se transport=rest
  base_url: ${THOTH_DWH_REST_URL}
  api_key: ${THOTH_DWH_API_KEY}     # ruolo dwh_reader
vector_rest:                    # LETTURA pgvector (rpc search_similar), key reader
  base_url: ${THOTH_VEC_REST_URL}
  api_key: ${THOTH_VEC_API_KEY}
vector_write_rest:              # SCRITTURA pgvector (upsert/hash), key writer SEPARATA, opzionale
  base_url: ${THOTH_VEC_REST_URL}
  api_key: ${THOTH_VEC_WRITE_API_KEY}
vector_db:                      # LOADING diretto pgvector, server-only
  host: ${THOTH_VEC_HOST}
embeddings:
  base_url: ${THOTH_OLLAMA_URL}
evidence:
  source_root: ${THOTH_DOCS_ROOT}
execution:                      # fonte verità read-only (D7)
  allow: [cte_test, explain, preview, aggregate, export]

Il modulo workspace.py (confine D3) carica + valida + espande ${VAR} dal .env, delegando a load_config (portato da ChironeWp3). Una sola fonte di verità per execution.allow, condivisa tra nsp (validazione durante il workflow) e backend (SQL finale read-only).

Modello delle key (D11, vedi §5.4): il Config ha tre sezioni RestConfig indipendenti con key distinte — rest.api_key (DWH reader), vector_rest.api_key (pgvector reader), vector_write_rest.api_key (pgvector writer). Nel deployment Chirone reale la key del DWH reader e quella del pgvector reader condividono lo stesso valore (key unica validata da Nginx), ma restano campi separati nella config per chiarezza e flessibilità.

5.2 Session — harness/sessions/<id>/

Eredita il modello di ChironeWp3:

sessions/<id>/                         # <id> = YYYY-MM-DD-HHMMSS-<slug>
├── session_manifest.yaml              # id, created_at, author, status, question, database, schema, schema_version
├── question.md                        # "# Domanda" + "## Assunzioni"
├── review_decisions.jsonl             # VERITÀ: ledger append-only
├── schema_linking.json                # candidates[], joins[], excluded[], open_questions[]
├── cte_plan.json                      # [ "pazienti_base", "ricoveri_recenti", ... ]
├── ctes/<name>.sql                    # un file per CTE
├── cte_tests.json                     # CteTestRecord[]
├── sql_final.sql                      # SQL finale approvato
├── evidence.json                      # evidence usate/scartate (a finalize)
├── validation_report.md               # parsing/read-only/EXPLAIN/preview (a finalize)
└── risultati_<ts>.csv                 # export (su richiesta)

Campi nuovi nel manifest per ThothII (PRD richiede):

  • author: id utente autenticato (D6)
  • summary: domanda sintetica
  • updated_at + updated_by: timestamp e autore ultima modifica
  • schema_version: versione del workflow usato (F2, vedi §5.4)

Fase corrente = derivata, non memorizzata. Chronological fold del ledger (come ChironeWp3 phase.py): ogni phase_approved/phase_auto_approved avanza, phase_reopened torna indietro. Il review_decisions.jsonl è la verità.

5.3 Workflow — harness/workflow.yaml (unica fonte di verità)

Questa è la principale deviazione architetturale da ChironeWp3, introdotta per abilitare la flessibilità futura del workflow (semplificazione, riordino, fasi opzionali) che il PRD lascia aperta.

Problema risolto: in ChironeWp3 la forma del workflow è codificata in 4 posti indipendenti (phase.py con MAX_PHASE=8, PHASE_NAMES, SCHEMA_LINKING_PHASE=5, DECISION_MIN_PHASE 16-entry, advance_problems ladder if phase==N; nsp-gate.js con costanti mirrorate — già driftato: il PHASE_NAMES JS ha solo 7 entry e manca la fase 8; SKILL.md in prose; finalize con secondo enforcement point). Cambiare il workflow significa toccare 4 posti in 2 linguaggi.

Soluzione: una sola definizione data-driven.

# harness/workflow.yaml
schema_version: 1

phases:
  - id: F1
    name: chiarimento
    advance: kind:phase           # meccanismo di chiusura
    prerequisites: []             # data-driven, no ladder if==N
  - id: F2
    name: memoria
    advance: auto_if_empty        # auto-advance se 0 decisioni sostanziose
    prerequisites: []
  - id: F3
    name: riscrittura
    advance: kind:phase
    prerequisites:
      - decision_exists: question_rewritten
  - id: F4
    name: schema_linking
    advance: reviewer_decide
    prerequisites: []
  - id: F5
    name: sintesi
    advance: kind:phase
    prerequisites:
      - file_validates: [schema_linking.json, SchemaLinking]
  - id: F6
    name: cte
    advance: auto_if_empty_or_skipped
    prerequisites:
      - any:
          - decision_subject_exists: [phase_skipped, "phase:6"]
          - all_ctes_approved: true
  - id: F7
    name: sql_finale
    advance: kind:phase
    prerequisites:
      - decision_exists: sql_approved
  - id: F8
    name: datamart
    advance: reviewer_decide
    prerequisites:
      - any:
          - decision_exists: datamart_requested
          - decision_exists: datamart_declined

decision_min_phase: auto          # DERIVATO dall'ordine delle fasi
max_phase: auto                   # = len(phases)

Come si ottiene la flessibilità:

  • phase.py legge workflow.yaml. max_phase = len(phases) (non più hardcoded). advance_problems valuta i prerequisites della fase (fine della ladder if==N). decision_min_phase derivato dalla posizione della fase che emette quel decision type. Nessuna costante duplicata.
  • nsp-gate.js non mirrora più niente. Legge i metadati via un nuovo comando nsp phase meta --json (restituisce max_phase, PHASE_NAMES, SCHEMA_LINKING_PHASE, advance strategy per fase). Fine del drift JS/Python (il bug F8 scompare).
  • SKILL.md generato o validato vs workflow.yaml. Le sezioni "## Fase N" possono essere generate da workflow.yaml; in alternativa un check assicura che skill e yaml siano allineati.
  • schema_version nel manifest. SessionManifest porta la versione del workflow usato. La funzione current_phase interpreta il ledger secondo la versione, consentendo di evolvere il workflow senza rompere le sessioni esistenti.
  • finalize legge gli stessi prerequisites. Un solo enforcement point, non due.

Le 4 trasformazioni rese fattibili:

  • Ridurre 8→5 fasi (fondere): edit workflow.yaml, i prerequisites data-driven si adattano.
  • Riordinare: edit l'ordine in yaml, decision_min_phase si ricalcola.
  • Aggiungere una fase: aggiungi una entry in yaml.
  • Rendere una fase skippable: aggiungi prerequisites: any: [decision_subject_exists: [phase_skipped, "phase:N"], ...].

Cosa si porta da ChironeWp3 come punto di partenza (da rivalutare in harness/, non assunto affidabile per inerzia — vedi §1): ledger append-only, fold cronologico come meccanismo (count-agnostic), decision_seq come foreign key, reopen generalizzata (a qualsiasi fase precedente), modello memoria (REUSABLE_TYPES, decision_seq), i 22 decision.type. Ciascuno va verificato e coperto dai golden test (D10).

5.4 Vector DB — modello di accesso a doppia API key (D11)

Il vector DB (pgvector) è raggiunto via REST con due endpoint separati, due API key distinte, per permettere alle postazioni remote di salvare Memory senza poter fare operazioni distruttive. Modello derivato dalla verifica del codice ChironeWp3 (config.py, vectorstore/rest_client.py, vectorstore/rest_writer.py, cli/_guards.py, scripts/create_vector_writer_rpc.sql).

Due ruoli, due config:

  • Reader (vector_db.rest, config RestConfig | None). Header X-API-Key: ${THOTH_VEC_API_KEY}. Allowlist RPC: search_similar, list_tables. Sola lettura. Sempre necessaria per nsp search (RRF).
  • Writer (vector_db.write_rest, config RestConfig | None, opzionale). Header X-API-Key: ${THOTH_VEC_WRITE_API_KEY}. Allowlist RPC: existing_vector_hashes, upsert_vector_records. Upsert + hash sync, no delete/clear.

Un unico client HTTP, parametrizzato dalla config. VectorRestClient(cfg: RestConfig) è la stessa classe per reader e writer; quale RestConfig gli viene passata determina key e allowlist. "Writer realmente configurato" = sezione presente e api_key non vuota (has_vector_write_rest controlla entrambi).

Contratto RPC del writer (load-bearing, definito server-side in scripts/create_vector_writer_rpc.sql):

  • existing_vector_hashes(table_name text, kinds text[]) → table(record_key text, content_hash text) — restituisce gli hash correnti per il sync differenziale.
  • upsert_vector_records(table_name text, rows jsonb) → jsonb ({"upserted": N}) — ON CONFLICT (record_key) DO UPDATE, aggiorna kind/content_hash/metadata/embedding/indexed_at. No delete.
  • Entrambi SECURITY DEFINER, set search_path = public, vectors, extensions, REVOKE da public/anon/authenticated, GRANT EXECUTE solo al ruolo vector_writer. La key writer mappa su vector_writer → EXECUTE sulle funzioni, nessun DELETE sulle tabelle raw.
  • Tabelle/kinds ammessi: schema_records (schema_table, schema_column), evidence (evidence), memory (memory).

Hash dedup client-side. content_hash = SHA-256 del content. Il writer confronta gli hash ricalcolati con existing_vector_hashes, embedda solo i record new/changed, li upserta. Idempotente per costruzione.

Gating (3 guard functions in nsp/cli/_guards.py, punto di partenza da ChironeWp3 da portare e rivalutare in harness/):

  • require_server_profile(cfg, command) — operazioni distruttive/server-only (vector init, memory clear, memory promote, memory update, memory delete): exit 4 su profilo workstation sempre.
  • has_vector_write_rest(cfg) — predicato "writer realmente configurato" (sezione presente e key non vuota).
  • require_vector_write_allowed(cfg, command) — operazioni di upsert (vector index-schema, evidence index, memory index, e il nuovo memory save-one): permesse su workstation solo se has_vector_write_rest, exit 4 altrimenti. Su server sempre permesse.

Salvataggio mirato delle Memory — nuovo comando nsp memory save-one (D11, deviazione controllata).

In ChironeWp3 il salvataggio delle Memory su pgvector avviene solo via memory index (resync completo dell'intero registro) o memory promote (server-only). Per ThothII si introduce nsp memory save-one <decision_seq>:

  • Esegue un singolo upsert mirato (un record) del record di memoria associato a quel decision_seq, via writer key.
  • Usa le RPC esistenti existing_vector_hashes + upsert_vector_records (nessuna nuova RPC server-side).
  • Hash dedup client-side: embedda solo se il content è cambiato.
  • Gated da require_vector_write_allowed → funziona su postazione remota se la writer key è configurata.
  • Il workflow (F5 sintesi / F2 memoria) lo chiama quando una Memory viene promossa/accettata per la sessione corrente, invece di scatenare un resync completo.

Perché save-one invece di memory index: il caso d'uso reale è "salvare la memory appena generata in F5", non "resyncare tutto il registro". Un resync intero è sovradimensionato e rallenta il flusso interattivo. save-one è efficiente, idempotente (hash dedup), e abilita il lavoro remoto — che è lo scopo esplicito della doppia key.

Profilo workstation vs server (variabile THOTH_PROFILE):

  • server (default): ricostruzione distruttiva completa via vector_db.local diretto (init, clear, rebuild). Le guard server-only passano.
  • workstation: blocca init/clear/promote/update/delete (exit 4). Permette upsert (index-schema, evidence index, memory index, memory save-one) solo se write_rest configurato.

Variabili d'ambiente (.env):

  • THOTH_PROFILE — server | workstation
  • THOTH_VEC_REST_URL — URL unica per reader e writer
  • THOTH_VEC_API_KEY — key reader (search_similar)
  • THOTH_VEC_WRITE_API_KEY — key writer (upsert), solo se upsert remoto abilitato
  • THOTH_VEC_HOST/PORT/USER/PASSWORD — vector_db.local diretto, server-only (in remoto: segnaposto)
  • THOTH_SSL_CA — path CA per HTTPS interno (alimenta rest.ssl_ca e write_rest.ssl_ca)

6. Frontend / UI

Le decisioni UI derivano dal PRD, formalizzate durante il brainstorming.

Layout — ibrido a 4 zone (Q9-C). Nav sinistra (funzioni + lista sessioni) | workflow bar orizzontale sopra la chat | chat+input al centro | sidebar destra collassabile con tutti gli artefatti/COT/thinking della sessione.

Schema-linking viewer (Q10-A+B). Mermaid flowchart verticale (default, top-to-bottom) + tabella gerarchica come vista alternativa via toggle. Entrambi vincolati a ≤45 elementi e sviluppo verticale. Commento "perché" inline.

CTE / SQL viewer (Q11-A). Code blocks collassabili (▾/▸) con header (nome, n° campi, stato test), SQL formattato + commento per campo. Toggle verticale/orizzontale globale. SQL finale = stesso componente con SELECT espansa e commenti solo su JOIN/WHERE/HAVING/ORDER BY. Evidenziazione sintattica (shiki/highlight.js). Nessun parsing AST richiesto.

Pannello risultati (Q12-A). Contestuale. Numero → grassetto. Lista → AGGrid community con export CSV. Selettore [10 ▾ / tutti]. Datamart (dbt/CSV/Excel) come azione separata con conferma pseudoanonimizzazione.

Testi lunghi e markdown. Box con scorrimento orizzontale e verticale. Markdown "mermaid enhanced": formattazione + rendering degli schemi mermaid inclusi.


7. Strategia di implementazione (D9)

Vertical slice per fase. Ordine harness → backend → frontend, con loop end-to-end precoci. Schema indicativo dei cicli:

  • Ciclo 0 (harness isolato): nsp risponde a nsp search/phase/session in JSON, più il nuovo nsp phase meta --json (F2) che il gate userà per non mirrorare le costanti. Testato con script che mandano JSON a mano + fake-Pi con golden test (D10).
  • Ciclo 1 (harness + backend minimale): il BE fa spawn di Pi, forwarda 1 widget-descriptor (F1: disambiguazione). Test end-to-end via curl.
  • Ciclo 2 (FE minimale): aggiungi il frontend minimo (solo F1) per chiudere il loop visivamente.
  • Cicli 3+: estendi fase per fase (F2 memory, F3 rewrite, F4 schema-link, …) su tutti e tre i layer.

8. Flessibilità e parametricità — quadro

Applicato un filtro secco: flessibilità inclusa solo dove (a) il PRD la chiede, oppure (b) risolve una tensione architetturale già emersa, e il costo è basso.

Accettate (6):

  • F1 — Widget descriptor con kind aperto + fallback. Costo basso (un campo + renderer fallback). Risolve "future modalità di interazione" (richiesta esplicita). Vedi §4.
  • F2 — workflow.yaml come unica fonte di verità. Risolve il drift reale già verificato (JS manca F8). Abilita semplificazione futura. Vedi §5.3.
  • F3 — Workspace YAML parametrico su db_type + transport. È il requisito del PRD (postgres/sqlserver/mariadb/informix + REST/SSH/diretto). Vedi §5.1.
  • F4 — Auth middleware pluggabile none/mock/oidc. PRD chiede esplicitamente Authentik+Entra ID+no-auth. Una sola codepath OIDC. Vedi D6.
  • F5 — embeddings.provider nel workspace. Il layer embeddings è dietro un'interfaccia semplice (embed(texts)→vectors, 1 implementazione). Permette swap provider senza toccare RRF.
  • F6 — Modello a doppia API key per il vector DB. Il PRD richiede lavoro remoto (postazione fuori server) con salvataggio Memory controllato. Due key (reader/writer) con allowlist RPC separate è il modo pulito per abilitarlo senza esporre delete/clear. Vedi §5.4, D11.

Rifiutate (8) — over-engineering:

  • R1 — Registry di adapter DB pluggabile nel backend Node. Il backend (D7) tocca i DB solo per SQL finale read-only; serve un driver per il workspace corrente, non un registry eterogeneo. La complessità dei 5 DB vive in nsp Python (D8). Duplicare thoth_sqldb2 nel backend è senza valore.
  • R2 — Sistema di plugin per i widget del frontend. I 6 widget + fallback (F1) coprono il caso. Un framework di plugin è speculativo: nessuna evidenza di bisogno. Un settimo widget si aggiunge come componente React.
  • R3 — Repository pattern / astrazione sulla persistenza session. Le sessioni sono file su FS (D5), accessi via nsp. Avvolgere il FS in SessionRepository per swap futuro a DB è YAGNI. nsp session/store.py è già il confine.
  • R4 — Event sourcing / message bus interno al backend. Il backend fa solo da traduttore. Non è un sistema event-driven con molti produttori/consumatori. Una message bus aggiunge complessità senza un secondo consumatore.
  • R5 — Configurabilità dei widget e del layout via configurazione. Il PRD descrive una UI specifica. Rendere configurabile il layout = costruirla due volte. YAGNI.
  • R6 — Multi-tenancy con workflow diversi per tenant. L'MVP è mono-utente (D6). Per-tenant workflow è speculativo. Se servirà, "workflow.yaml per-workspace" è un'estensione naturale di F2.
  • R7 — Livello di astrazione sul transport FE↔BE (SSE vs WebSocket vs polling). SSE basta per lo streaming unidirezionale. Astrarrlo per swap futuro è YAGNI.
  • R8 — SQL builder driver-agnostic nel backend. Il backend esegue SQL già generato da nsp, non lo costruisce. Un query builder è inutile.

9. Fuori scope (MVP)

  • Web app centrale multi-utente (modello A, D12): l'MVP è modello B (postazione remota localhost). L'evoluzione ad A riposiziona backend+harness su server centrale + attiva OIDC (D6); i contratti non cambiano.
  • Multi-utente reale con concorrenza (D6 prepara il terreno ma l'MVP è mono-operatore in localhost).
  • CRUD workspace via API (D3: i workspace sono YAML; la scrittura è manuale, il backend li espone in lettura).
  • Plugin system per DB adapter nel backend (R1), plugin widget FE (R2), repository pattern (R3), event bus (R4), configurabilità layout (R5), multi-tenancy (R6).
  • Job runner asincrono per export/preview (Q12-A scelta contestuale, sincrono).
  • Form builder UI per widget futuri (R2).

10. Rischi aperti

Rischio D7 — due codepath SQL read-only. nsp (Python) e backend (Node) eseguono entrambi SQL sul workspace. Devono rimanere allineate sull'enforcement read-only. Mitigazione: un'unica fonte di verità (execution.allow nel workspace YAML, condivisa). Punto di attenzione nello sviluppo.

Rischio fake-Pi — fedeltà del protocollo. Il fake-Pi (D10) deve riprodurre fedelmente il framing del protocollo RPC di Pi (LF-only, niente readline; split su \n). Se devia, i golden test non catturano regressioni reali. Mitigazione: basare il fake-Pi sul reference RpcClient/LineSplitter di ChironeWp3 (docs/superpowers/plans/2026-06-14-psdwp3-pi-web-console-bridge.md).

Rischio workflow.yaml — curva di adozione. Introdurre workflow.yaml come unica fonte di verità richiede di riscrivere phase.py (da ladder if==N a evaluation data-driven) e aggiungere nsp phase meta --json. È lavoro in più rispetto a "copia ChironeWp3 così com'è", ma è il prezzo della flessibilità futura richiesta. Da bilanciare con D9 (vertical slice): il workflow.yaml può entrare gradualmente nei cicli.


11. Nota sulla struttura dei piani di implementazione

Questo documento è la vista d'insieme dell'architettura ThothII. Copre i tre progetti (harness, backend, frontend) e i loro contratti. La fase di writing-plans produrrà tre piani separati, uno per progetto, nell'ordine della strategia D9 (prima harness, poi backend, poi frontend). Ogni piano è autonomo e referenzia questo spec per i contratti condivisi (widget-descriptor di §4, modelli dati di §5). I contratti tra i layer sono definiti qui una volta per tutte, così ogni piano può essere implementato e testato in modo indipendente contro il contratto — esattamente come richiesto dal PRD ("tre diversi progetti autonomi").