Skip to content

Posta e Google: due strumenti dell'agente, con la conferma imposta dal server - #75

Open
zorahrel wants to merge 30 commits into
mainfrom
topics/posta-e-google
Open

zorahrel wants to merge 30 commits into
mainfrom
topics/posta-e-google

Conversation

@zorahrel

@zorahrel zorahrel commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Cosa cambia

Un agente di card puo' mandare una mail (send_mail) e leggere/scrivere su Google
(google_call, una porta sola sopra la CLI gws). La parte che conta non e' il
trasporto, che era gia' deciso, ma dove sta la conferma: nella rotta che spawna
la CLI, non nella descrizione dello strumento.

  • server/routes/outbound.ts - le due porte. Prima di ogni invio e di ogni
    scrittura apre la domanda sul canale umano che la board gia' usa (commento con
    risposte rapide nel thread della card; pannello sulla riga dello strumento in una
    chat) e non spawna niente finche' non arriva una risposta. Chi chiama la rotta
    a mano trova lo stesso cancello.
  • server/lib/outbound-gate.ts - il si' vale per UN messaggio: la domanda porta
    un digest del contenuto nella chiave e nel testo, e una risposta che non lo porta
    non e' un consenso. Non passa dal bridge dei permessi apposta: quello e' fatto per
    poter smettere di chiedere (allow_always, sessione libera), che e' giusto per un
    tool che tocca un file e sbagliato per una mail.
  • server/lib/outbound-staging.ts - i BYTE confermati, congelati: al momento
    della domanda gli allegati vengono letti e copiati in una cartella di appoggio del
    server (nome da mkdtemp, file creati con O_EXCL), alla CLI va la copia, e prima
    dello spawn la copia viene riletta e ri-hashata.
  • server/lib/outbound-summary.ts - cosa legge chi conferma sulla porta di
    Google: i metodi di Gmail che spediscono sono rifiutati (si usa send_mail), e un
    raw in base64 viene decodificato in mittente, destinatario, oggetto e testo.
  • server/lib/outbound-cli.ts - gli argomenti arrivano da un modello: la CLI si
    chiama per ARGV, mai per una stringa data a una shell, e l'eseguibile si risolve su
    cartelle dichiarate (sotto launchd il PATH non ha /usr/local/bin). Il figlio
    riceve un ambiente minimo, non quello del server.
  • server/lib/outbound-config.ts - configurazione solo da process.env. Il repo
    e' pubblico: nessun indirizzo e nessun nome di account nel codice o nei test, la
    casella Exchange riconosciuta per INDIRIZZO (TOPICS_MAIL_EDM_FROM), e una
    variabile mancante che si nomina invece di ripiegare in silenzio su un'altra
    casella.
  • Traccia sulla card - ogni invio, rifiuto e fallimento lascia una riga (chi, a
    chi, cosa, esito), senza il corpo.
  • Allegati risolti DENTRO il workspace della sessione: senza quel controllo
    send_mail sarebbe una via di esfiltrazione.
  • Envelope + protocollo: OUTBOUND_TOOLS_RULE in shared/board.ts (un agente
    dispatchato legge solo quello) e la sezione 5-ter di docs/board-protocol.md
    (5-bis su main e' la scelta consigliata, entrata col merge), con il test di parita'
    che le tiene legate.

Un difetto ereditato, riparato qui

Il registro di routeAskToTaskThread si puliva solo alla scadenza o rispondendo dal
thread: una risposta arrivata dal pannello lo lasciava sporco, e la domanda del
messaggio DOPO non usciva piu' sulla card, in silenzio.

La prima stesura lo aggirava solo dentro il cancello nuovo (outbound-gate.ts) e
lasciava intatto il percorso da cui e' ereditato: routes/permission.ts non era nel
diff, quindi per ask_user_question il difetto c'era ancora tale e quale. Adesso la
gamba ask-user svuota il registro quando la risposta arriva davvero e quando la
domanda viene annullata; il test e' rosso senza quella riga.

Primo giro di correzioni (review)

Sei cose trovate rileggendo, ognuna chiusa con un test rosso prima della correzione:

  1. Esfiltrazione via link simbolico. resolveAttachments decideva su
    resolve() + isInsideDir(), che non segue i link (lo dichiara
    path-containment.ts) mentre existsSync li segue. Un agente con una shell
    scrive ln -s <segreto> preventivo.pdf nel workspace e l'allegato esce. Ora la
    decisione gira sul percorso REALE di candidato e workspace
    (realPathForNewEntry, lo stesso rimedio di browser-tool-dispatcher.ts).
  2. La conferma NOMINA gli allegati (nome + peso) invece di contarli: era la
    busta chiusa che OUTBOUND-03 vieta due paragrafi sopra per il corpo, ed era cio'
    che rendeva il punto 1 invisibile.
  3. Traccia anche su un fallimento DOPO il si' (eseguibile che la variabile non
    trova piu'): usciva 400 senza riga sulla card, contro OUTBOUND-05. Posta e
    Google.
  4. La conferma in chat era morta sul runtime nativo. findWaitingToolRow
    confrontava il nome prefissato mcp__topics__send_mail, ma
    providers/native/topics-tools.ts pubblica i tool col nome NUDO - ed e' il
    runtime di 702 topic su 776. Nessuna riga, nessun pannello, ogni invio rifiutato
    con «nessuno poteva confermare». Stessa regola a tre vie gia' scritta in
    ask-user-detector.ts per lo stesso motivo.
  5. Una risposta del server veniva ritentata come un socket caduto. Ogni stato
    fuori dal 2xx passava per guasto di trasporto e lo stesso corpo veniva
    ri-POSTato: gli errori parlanti arrivavano travestiti da rete rotta, e su un 502
    dopo la conferma il ri-POST apriva una domanda NUOVA per lo stesso messaggio
    (una seconda mail). Ora HttpAnswerError porta lo stato e solo cio' che non e'
    una risposta si ritenta.
  6. Il difetto ereditato, sopra.

Secondo giro: i quattro blocchi della verifica avversaria

La CI era verde e la verifica ha riprodotto quattro difetti, tutti con i moduli veri.
Ognuno ha adesso un test che va rosso senza la correzione (mutante eseguito).

  1. La conferma vincolava il NOME dell'allegato, non i BYTE. Il punto 1 del giro
    prima aveva chiuso la meta' statica: il percorso resta una stringa, e fra la
    domanda e lo spawn c'e' l'attesa della persona. Sostituendo il file (o
    ripuntandolo a un link) durante quell'attesa partiva altro, con la persona che
    aveva letto «preventivo.pdf (9 B)». Adesso i byte si leggono e si copiano al
    momento della domanda in una cartella privata del server (0700, sotto
    ~/.topics, fuori da ogni workspace), alla CLI va la COPIA, e la domanda porta
    nome, peso e sha256 breve - con l'impronta dentro il digest, cosi' byte diversi
    sono una domanda nuova. (Il «niente secondo controllo prima dello spawn» che stava
    qui e' caduto al terzo giro: vedi sotto.) Le copie si cancellano a invio finito e scadono
    a sei ore. Tetto di 25 MB sul totale, che e' dove si ferma una casella.
  2. google_call era la seconda porta della posta. gws gmail users messages send manda una mail senza passare da send_mail, e il sommario mostrava il body
    tagliato a 300 caratteri, cioe' base64 a meta': destinatario, oggetto e corpo
    invisibili. Ora i metodi di Gmail che spediscono sono rifiutati con un rimando a
    send_mail (elenco esplicito, confronto insensibile a maiuscole e spazi), e ogni
    altra scrittura viene riassunta in parole - raw decodificato, nessun taglio
    senza annuncio. La traccia nomina a chi e con che oggetto.
  3. Una domanda vecchia nel registro rendeva muta la conferma.
    routeAskToTaskThread esce restituendo il task anche quando NON scrive il
    commento, e il cancello lo leggeva come «chiesto»: l'invio restava appeso, sulla
    card compariva la domanda di prima, e lo strumento bruciava 600 gambe. Adesso
    l'instradamento dice shown, e una domanda nuova SOSTITUISCE quella che trova
    per la stessa sessione - la vecchia chiusa con una riga sua. Superare invece di
    rifiutare perche' e' gia' la regola del rendez-vous (waitForAnswer supersede) e
    perche' l'altra strada lascia una persona a sbloccare a mano una domanda a cui
    nessuno puo' piu' rispondere: il turno che l'aveva aperta e' finito.
  4. La riga su cui la conferma si appoggia si scrive con 1-15 s di ritardo.
    blocks e' l'unica colonna che porta le chiamate su una riga moderna (sul DB
    vivo tutte le 6916 righe con blocchi hanno tool_calls vuoto) e passa dal
    throttle: un send_mail che non fosse il primo tool del turno non era ancora
    sulla riga, e veniva rifiutato SECCO - lo strumento solleva sul rifiuto, quindi
    niente seconda gamba. Ora lib/turn-body-flush.ts pubblica il flush del turno
    vivo e la rotta lo forza prima di leggere, come routes/chat.ts fa gia' per lo
    stesso motivo.

Terzo giro (verifica avversaria)

Due blocchi riprodotti dal verificatore, chiusi qui con il loro test rosso; il terzo
e' respinto con la misura che lo respinge.

  1. Il congelamento non vincolava i byte. La copia prometteva di stare «dove
    l'agente non arriva» e non era vero: server e agenti girano sotto lo stesso uid,
    quindi 0700 tiene fuori gli altri utenti, non l'agente. Due strade, entrambe
    riprodotte. (a) Il nome della cartella era sha256(sessione, nomi e impronte) e
    la scrittura saltava se il file c'era gia' (if (!existsSync)): l'agente
    calcolava il nome, piantava il segreto e chiamava send_mail - nessuna corsa,
    nessun tempismo, e partiva la copia piantata. (b) Riscrivere la copia mentre la
    persona legge, che ls -t trova senza calcolare niente. Ora il nome viene da
    mkdtemp e il riuso fra le gambe passa da una mappa in memoria, ogni copia si
    crea con O_EXCL, e dopo il si' le copie si rileggono e si ri-hashano prima
    dello spawn: impronta diversa = invio fermo, con la sua riga sulla card.
    L'argomento contro il ri-controllo («la stessa corsa un giro piu' in la'») era
    sbagliato: la finestra da chiudere e' l'attesa della persona, che dura minuti.
    Quello che questo punto scriveva sulla finestra RESIDUA («microsecondi», «un
    percorso che nessuno fuori dal processo conosce») e' stato falsificato al quarto
    giro: vedi sotto.
  2. Il tetto dei 25 MB si misurava a byte gia' in RAM, e la lettura era sincrona.
    64 MB letti e poi rifiutati sono 64 MB di RSS, cioe' il guasto che il tetto
    esiste per impedire; e una FIFO al posto dell'allegato (mkfifo report.csv, un
    comando nella propria worktree) fermava l'intero event loop prima che a qualcuno
    fosse chiesto niente. Ora la decisione sta sulla stat, quello che non e' un
    file regolare e' un rifiuto, e la copia va a blocchi da 256 KB. Il test che
    doveva coprirlo si intitolava «non entra nella RAM del server» e asseriva solo
    .toThrow: adesso misura l'ordine (un file statabile e non leggibile, oltre il
    tetto, risponde col tetto e non col permesso negato).
  3. google_call con un elenco solo restava una porta della posta. I quattro
    campi diventano argv e la CLI ha quattro helper che spediscono (+send,
    +reply, +reply-all, +forward): nessuno e' un percorso d'API, quindi
    nessuno era nell'insieme, e resource: "+forward" con i flag negli altri campi
    inoltrava un messaggio qualunque della casella, allegati compresi, a un
    indirizzo scelto da chi chiama. I quattro campi ora devono essere nomi d'API
    (^[A-Za-z][A-Za-z0-9_.]*$), il che chiude in un colpo gli helper e i flag;
    l'elenco resta come secondo strato, allargato ai quattro helper, perche' «usa
    send_mail» e' una risposta migliore di «non e' un nome».

Respinto: «la conferma se la firma l'agente». Vero come descrizione (su loopback
evaluateIdentity risponde owner e la rotta dei commenti scrive author: HUMAN),
ma la correzione proposta - chiedere il token del daemon, o un'identita' di
dispositivo appaiato - non regge alla stessa misura che motiva il blocco: gli agenti
girano sotto lo STESSO utente del server, e quel token e' un file 0600 di
quell'utente mentre i token dei dispositivi stanno in data/topics.db, che e' 0644
(misurato con ps e stat). Una credenziale che l'attaccante puo' leggere non e'
una difesa, e' una fortezza. Il confine di fiducia e' l'utente della macchina: ora lo
dicono OUTBOUND-03, l'envelope e l'intestazione del modulo, invece di promettere il
contrario.

Quarto giro (verifica avversaria)

Due blocchi riprodotti, tutti e due chiusi qui — uno con la correzione, l'altro con
la misura che dice cosa resta aperto e perche' non si chiude da questa parte.

  1. Il si' era legato al TASK, non al messaggio. Il registro di
    board-ask-routing.ts e' chiavato su taskId, e due sessioni dello stesso task
    esistono per costruzione: agent-census.ts mappa apposta il coordinatore e le
    sue figlie sullo stesso task. Due conferme di invio aperte insieme diventavano un
    solo blocco di tasti; la seconda scacciava la prima senza scrivere niente, e
    answerRoutedAsk consegnava alla chiave CORRENTE. Riprodotto: la persona legge
    «Preventivo -> cliente@esempio.test», clicca Conferma, e parte un messaggio
    diverso verso un altro destinatario, mentre la prima domanda resta muta e
    inesigibile.

    Adesso una domanda ha un id suo, che e' l'id della riga del thread che la
    porta — l'unica cosa che chi risponde puo' NOMINARE, perche' e' il commento su
    cui clicca. La card lo rimanda indietro (answerTo), e una risposta che nomina
    un id diverso da quello aperto non si consegna, non fa partire niente, e la
    card lo dice
    (senza quella riga la persona ha cliccato Conferma e non e'
    successo niente di visibile).

    Sulla seconda richiesta si e' scelto di RIFIUTARE, non accodare, e non e' la
    stessa scelta per tutti: la card disegna un blocco di risposta rapida solo,
    quindi mostrare la seconda significa togliere la prima, e un atto irreversibile
    non puo' nemmeno restare in coda in silenzio per ore — l'agente polla quattro ore
    e la persona non sa che un invio aspetta. Quindi la conferma di invio riceve un
    rifiuto con la sua ragione e la sua riga sulla card. Una ask_user_question
    generica invece aspetta il suo turno: il suo bridge ripassa ogni 25 secondi
    con la stessa domanda, quindi la coda ha una testa visibile e non perde niente.
    Se la domanda che occupa la card non aspetta piu' nessuno (turno interrotto),
    la nuova subentra, la sostituzione viene scritta nel thread anche fra sessioni
    diverse
    (prima usciva solo per la stessa) e il rendez-vous della sostituita
    viene annullato, cosi' il suo invio fallisce con la sua traccia invece di
    raccogliere un si' che non era suo. Tolto anche il «della stessa sessione» dal
    testo di OUTBOUND-03, che specificava il buco.

  2. La finestra dopo il si': misurata, non chiusa, e adesso scritta com'e'. Fra
    verifyFrozen e la open della CLI ci sono resolveCliPath, Bun.spawn e un
    avvio di processo intero: il verificatore l'ha vinta 1 volta su 1 coi tempi veri
    e 3 su 4 senza nessun ritardo simulato, e il percorso non si indovina — si
    ELENCA (find ~/.topics/outbound-attachments -name ...), cosa che costa niente a
    chi gira come questo utente.

    La chiusura naturale (aprire la copia, scollegarla, passare al figlio
    /dev/fd/N) e' stata provata contro la CLI vera (gws 0.22.5, gmail +send --dry-run, nessun invio), due volte: col descrittore aperto su un file
    della cartella corrente e poi col file scollegato, che e' lo stato che il
    trucco richiede. Stessa risposta:

    error[validation]: --attach '/dev/fd/3' resolves to '/dev/fd/3'
    which is outside the current directory       (400, validationError)
    

    Il rifiuto e' il controllo di contenimento del percorso, prima che
    qualunque cosa venga aperta: su macOS /dev/fd/3 non ha link da seguire,
    canonicalizza a se' stesso e non sta dentro nessuna cartella di lavoro. Col
    figlio dentro /dev/fd il contenimento passa e la CLI si ferma un passo dopo,
    --attach 3:

    error[validation]: Cannot read --attach '3': Bad file descriptor
    (os error 9)                                 (400, validationError)
    

    misurato col descrittore dimostrabilmente ereditato (cat /dev/fd/3 nella
    stessa shell stampa il file). Due strade, due rifiuti: il descrittore alla CLI
    non arriva, e una forma su stdin non c'e'. Che poi la CLI riaprirebbe il file
    per nome e da quel nome deriverebbe l'intestazione MIME e' un'inferenza mai
    misurata
    — il flag non ci arriva — e non serve alla conclusione. Quindi il
    punto (1) della direzione non regge con questa CLI, e non si finge: la copia tiene un nome, e spec, intestazione del
    modulo, commento della rotta e testo dell'envelope adesso dicono la misura vera al
    posto di «microsecondi» e «percorso che nessuno conosce». Il confine e' l'utente
    della macchina: un processo che gira come il proprietario chiama gws da se', e
    nessuna conferma dentro Topics glielo impedisce — quindi la conferma vale per due
    cose oneste, impedire gli errori e lasciare una traccia, e il testo non promette
    una terza.

    E una promessa che il codice davvero non poteva mantenere e' caduta col
    misurato
    : lo stesso --dry-run mostra che gws rifiuta ogni --attach che
    risolva fuori dalla cartella corrente. Le copie congelate stanno sotto
    ~/.topics e il server gira dove l'ha avviato launchd, quindi nessun allegato
    confermato sarebbe mai partito davvero
    : il figlio adesso viene avviato con la
    cartella di appoggio come cwd, e il test lo misura sul PWD del processo, non
    sulla stringa. La traccia sulla card, che scriveva «allegati: preventivo.pdf -
    riuscito», adesso nomina l'impronta confermata («allegati confermati:
    preventivo.pdf (sha256 b0e8de4e)») e non piu' un esito che non puo' provare.

Prove del quarto giro (tutte con la CLI finta, nessun invio)

  • due sessioni dello stesso task, la seconda conferma rifiutata con la sua
    ragione, nessuna domanda nuova sulla card, e il si' che manda il PRIMO messaggio
    (il destinatario dell'altro non compare in argv);
  • una risposta che nomina la riga vecchia resta una nota, la card scrive perche',
    e la domanda aperta resta aperta; sulla riga giusta invece si consegna;
  • se la vecchia non aspetta piu' nessuno, la nuova subentra, la riga «non aspetta
    piu' una risposta» compare fra sessioni diverse, e la gamba della vecchia
    fallisce cancelled;
  • l'allegato arriva dentro il PWD del processo figlio.

Mutanti eseguiti (ognuno fa rosso almeno un test): cancello di vitalita' rimosso
(torna il «sostituisce sempre»), confronto dell'id tolto, cancelAsk sulla sessione
sostituita tolto, answerTo ignorato dalla rotta, rifiuto della seconda conferma
disattivato, cwd del figlio rimosso.

Quinto giro (verifica avversaria)

Quattro blocchi, tutti riprodotti. I primi tre erano un guasto solo visto da tre
lati
: due send_mail della STESSA sessione, a 120 ms, mentre la prima e' nella
sua gamba di attesa. Il bridge MCP gestisce ogni riga JSON-RPC in un callback che
non aspetta (topics-mcp-server.ts), quindi due invii di un messaggio corrono
insieme — e la rotta e' comunque chiamabile a mano.

  1. Una conferma per CARD, chiunque la chieda. Il cancello guardava solo le
    sessioni DIVERSE, quindi per la stessa si cadeva nel ramo di sostituzione: due
    blocchi di conferma vivi sulla card. Adesso la seconda viene rifiutata comunque.
    «Nessuno aspetta piu' quella domanda» si decide su due fatti e non su uno:
    la sessione della domanda non ha piu' un rendez-vous aperto, oppure nessuno
    e' piu' ripassato a ri-porla entro un tempo dichiarato (le gambe del poll sono
    il battito). Una misura sola non basta: il rendez-vous e' chiavato sulla
    sessione e non sa distinguere due richieste della stessa, e il battito da solo
    terrebbe la card per una domanda abbandonata un secondo dopo essere uscita.
    Falsificato al sesto giro: valeva solo per due domande DIVERSE. Con lo
    stesso contenuto il ramo «occupato» non scattava affatto. Vedi sotto.

  2. Il rifiuto arriva PRIMA di toccare qualunque cosa della prima. Il gate
    chiede alla card prima di beginAsk: chi arriva secondo non apre un
    rendez-vous, non scrive nel thread e non ha niente da ripulire. Il registro si
    svuota per id della richiesta, non per sessione — chiavato sulla sessione,
    la gamba che perdeva cancellava la voce di quella che aveva vinto, e il clic
    sulla conferma ancora a schermo non partiva piu' (pendingRoutedAsk tornava
    null, la card non diceva niente). La rotta della domanda usa lo stesso id; la
    sola chiamata che ancora sgombera per sessione e' quella che ha appena chiuso
    il rendez-vous di quella sessione
    , e lo dice nel suo nome.
    Corretto al sesto giro: chiavare per id non protegge quando l'id e' lo
    STESSO, e con lo stesso contenuto lo e' per costruzione. Cio' che regge e' il
    lucchetto, sotto.

  3. La traccia di un invio rifiutato si scrive quiet.
    pendingQuestionComment si ferma sulla prima riga d'agente che non e' ne'
    domanda ne' consegna, quindi «Invio NON partito…» scritto sotto la conferma
    viva le toglieva i tasti e con loro l'answerTo del drawer. Delle tre strade
    possibili — scriverla in una forma che la funzione salta, scriverla dopo la
    risposta, insegnare alla funzione a saltarla — si e' scelta la prima: quiet e'
    la parola che esiste gia' per dire «annoto, non rispondo», e' letta da
    quella funzione sola, e la riga resta visibile e resta l'ultima parola della
    card. Tenerla indietro avrebbe significato un agente che dice «non e' partito
    niente» e una card che non dice niente.

  4. La misura di gws e' stata rifatta. Il referto del giro prima incollava
    come output verbatim una frase che la CLI non produce (resolves to '/dev/fd/preventivo.pdf'). Rimisurata qui con la CLI vera e --dry-run: la
    frase giusta e' resolves to '/dev/fd/3', il rifiuto e' il contenimento del
    percorso e non una riapertura per nome, e col figlio in /dev/fd si ottiene un
    secondo rifiuto (Bad file descriptor). La conclusione regge, l'evidenza e'
    quella vera nei tre posti dove si leggeva (intestazione del modulo, delta di
    spec, questo corpo) e cio' che non e' stato misurato e' dichiarato
    inferenza
    .

Prove del quinto giro (CLI finta, nessun invio)

  • due invii della stessa sessione a 120 ms: il secondo rifiutato, UNA sola
    conferma sulla card (quella del primo messaggio), la riga del rifiuto presente e
    marcata come nota, pendingRoutedAsk che indica ancora la prima, il clic su di
    lei consegnato, e in argv il destinatario confermato e non l'altro;
  • la stessa sessione non sostituisce la propria domanda viva: niente scritto,
    registro invariato, e il si' che paga il messaggio che nomina;
  • una domanda che non aspetta piu' nessuno viene sostituita anche dalla sua
    stessa sessione
    (turno interrotto: senza questo, ogni conferma successiva
    sarebbe rifiutata per una domanda che nessuno puo' rispondere);
  • svuotare il registro con un id che non e' sulla card non tocca la voce aperta;
  • una traccia d'agente quiet sotto una domanda non le toglie i tasti, e senza
    il flag li toglie (lo stesso test tiene i due versi).

Mutanti eseguiti (ognuno fa rosso almeno un test): cancello di vitalita' rimesso a
«solo sessioni diverse» (torna la doppia conferma), quiet tolto dalla traccia,
cancelAsk rimesso sul ramo del rifiuto (uccide il rendez-vous del primo invio).

Sesto giro (verifica avversaria)

Sette blocchi dai due verificatori, e sono facce di un guasto solo: due
richieste di invio potevano essere in volo sulla stessa card. Il giro prima aveva
chiuso solo l'istanza che passa dal ramo «occupato» dell'instradamento — e con lo
STESSO contenuto le due richieste sono la stessa domanda per quel ramo, quindi
non scattava. La seconda si vedeva consegnare l'askId della prima, entrava nel
rendez-vous (chiavato sulla sessione) e lo sostituiva; la prima tornava
«superseded» e si portava via la voce comune. Misurato a 120 ms: conferma a
schermo coi tasti, pendingRoutedAsk null, clic che non consegnava e non diceva
niente, e la gamba dopo scriveva una SECONDA conferma.

  1. Il cancello si serializza da se'. Prende un lucchetto sulla superficie
    che disegna la domanda (la card; la sessione quando non ce n'e' una) prima
    di qualunque scrittura e prima di aprire qualunque rendez-vous, e lo
    rilascia alla fine, anche sugli errori. Chi non lo prende viene rifiutato con la
    sua ragione, e la riga del rifiuto resta una nota che non tocca la domanda viva.
    Non guarda il contenuto: due invii identici non arrivano piu' insieme al
    rendez-vous. Ordinare le operazioni non basta e non e' piu' la difesa.

  2. Il lucchetto attraversa le gambe con un gettone. La rotta lo restituisce
    insieme a pending e pollOutbound lo riporta a ogni gamba; una gamba che lo
    porta rinnova, una richiesta che non ce l'ha e trova la card occupata e'
    rifiutata. Non c'e' altro che possa distinguerli: con un payload identico la
    seconda gamba di una richiesta e una seconda richiesta hanno lo stesso digest,
    la stessa chiave e lo stesso testo. Il lucchetto scade (gamba dichiarata +
    margine), o un agente morto fra due gambe terrebbe la card per sempre.

  3. Una richiesta non possiede un id che non ha creato. created sull'esito
    dell'instradamento, e l'id tenuto attraverso le gambe dentro il lucchetto. La
    finestra dove serve e' reale: il lucchetto scade prima della voce nel registro
    (due minuti di silenzio), e in quel buco l'instradamento consegnerebbe a una
    richiesta identica la riga di chi sta ancora aspettando.

  4. Una ask_user_question generica non uccide piu' la conferma sotto di se'.
    Stesso guasto senza nessun duplicato: routes/permission.ts ignorava busy di
    proposito e chiamava waitForAnswer sulla stessa sessione. Quando la card e'
    occupata da una richiesta della STESSA sessione, quella gamba adesso spende il
    suo tempo senza registrarsi e risponde pending — aspetta il turno senza
    togliere niente. Un'altra sessione e' un altro rendez-vous e non cambia niente.
    Falsificato al settimo giro: valeva solo DENTRO la board. Senza card
    routed.busy non esiste e la corsa era intatta. Vedi sotto.

  5. I tasti non restano su un blocco morto. Una domanda che finisce senza una
    risposta nel thread — scaduta, annullata, o risposta dal pannello della chat —
    viene chiusa con una riga sua, che e' cio' che il lettore dei tasti conta come
    parola (svuotare il registro e basta lasciava un blocco che non rispondeva a
    nessuno, e la traccia accanto e' quiet apposta). E un clic su un blocco che
    non aspetta piu' nessuno riceve una riga sulla card, invece di diventare un
    commento qualunque che per giunta rimetteva al lavoro l'agente. Una frase
    scritta sotto lo stesso blocco resta una nota: e' il contenuto che coincide con
    un'opzione di quel blocco a dire che qualcuno ha premuto.

  6. Testi veri. check:test-globals dichiarava «~80s» ed era contato fra i
    cancelli leggeri: lancia un bun test per OGNI file, cioe' la suite unit
    intera, non sta in .github/workflows/ci.yml, e sul Mac di chi lavora si e'
    preso 18 minuti prima di essere fermato a mano. Corretto nelle tre righe che lo
    dicevano (lo script, il messaggio del preload, la barra della change). E la doc
    di clearRoutedAsk prometteva una protezione che non da': «un id che ha scritto
    qualcun altro non e' un id con cui si puo' chiamare» e' falso proprio nel caso
    che denunciava, perche' l'id e' lo stesso.

Prove del sesto giro (rotte vere, CLI finta, nessun invio)

  • due invii identici della stessa sessione a 120 ms: il secondo rifiutato, UNA
    conferma sulla card, il registro che la nomina, il clic consegnato, e in argv
    un +send solo;
  • la gamba che porta il suo gettone continua (pending, nessuna seconda conferma);
    una richiesta identica senza gettone, e una con un gettone falso, sono
    rifiutate e non toccano la domanda a schermo;
  • lucchetto scaduto, domanda ancora viva: la richiesta identica e' rifiutata
    invece di adottare quella riga, e il clic sulla domanda viva consegna e spedisce;
  • domanda generica mentre un invio aspetta (rotta /ask-user vera, bridge
    vero): pending, niente scritto, registro invariato, l'attesa dell'invio non
    annullata, e il si' che la raggiunge;
  • risposta dal pannello della chat: il blocco sulla card viene chiuso da una
    riga sua e il lettore dei tasti non trova piu' una domanda;
  • clic su un blocco morto: la card scrive che non e' stato consegnato niente,
    l'agente non viene rimesso al lavoro, e una frase sotto lo stesso blocco
    resta un commento normale;
  • clearRoutedAsk con un id non tocca l'altra voce della stessa sessione (due
    card, un agente): e' il caso che la copertura di prima non poteva distinguere;
  • il gettone torna indietro a ogni gamba dal lato dello strumento, e il payload
    resta lo stesso messaggio.

Mutanti eseguiti, otto, ognuno fa rosso almeno un test: lucchetto tolto ·
gettone ignorato dal client · adozione di un id non creato · clearRoutedAsk per
sessione · la domanda generica che si registra lo stesso · la chiusura che non
scrive niente · la riga del clic morto tolta · il clic riconosciuto senza guardare
le opzioni.

Settimo giro (verifica avversaria)

Un blocco riprodotto e quattro mutanti sopravvissuti, cioe' quattro regole che il
testo dichiarava e nessun test teneva. Chiusi tutti e cinque con il loro rosso.

  1. Fuori dalla board la corsa era intatta. Il rimedio del giro prima pende da
    routed.busy, e quel campo esiste solo quando la sessione ha una card: in una
    chat routeAskToTaskThread torna null, heldByThisSession restava falso e la
    gamba si registrava lo stesso. Misurato con le rotte vere e nessuna card:
    CONSEGNATO: true alla domanda generica — cioe' il si' letto sul pannello
    dell'INVIO — e l'invio che torna
    {"state":"refused","reason":"ask_user_question: superseded by a newer question"}.
    La regola e' la stessa con o senza card, e il lucchetto del cancello la
    chiave giusta ce l'ha gia': per una sessione senza card e' session:<k>. Adesso
    il fatto «una conferma di questa sessione aspetta» si legge da li', non dal
    registro delle domande (che e' chiavato sul task e in chat non esiste), e quando
    e' vero la gamba non tocca niente: nessun commento, nessuna voce nel
    registro, nessun waiter.

  2. Il lucchetto scade, e adesso lo dice un orologio. Il caso «lucchetto
    scaduto» fingeva la scadenza con _resetOutboundHolds(): provava il reset, non
    il tempo. Con il confronto su expiresAt tolto del tutto la suite restava
    verde. L'orologio del contratto ora e' iniettabile e il test lo avanza: dentro
    la gamba dichiarata piu' il margine la richiesta senza gettone e' respinta,
    oltre passa.

  3. Il lucchetto e' della CARD, e la suite non lo distingueva da «della
    sessione».
    Nei casi gia' coperti a respingere la seconda richiesta bastava il
    ramo «occupato» dell'instradamento, che legge il registro. Il caso che li separa
    e' quello che conta: la persona ha gia' risposto sulla card, quindi il
    registro e' vuoto, ma la prima richiesta e' viva e la sua gamba dopo sta per
    spendere quel si'. Con la chiave sulla sessione la sorella dello stesso task
    apre una seconda conferma mentre il primo messaggio parte; con la chiave
    sulla card e' rifiutata, e in argv c'e' un +send solo, verso il destinatario
    confermato.

  4. La gamba occupata spende davvero il suo tempo. Toglierlo non e' cosmetico:
    il client MCP fa continue su pending senza nessuna attesa propria, quindi la
    domanda brucerebbe le sue 600 gambe in un istante e morirebbe con «gave up after
    600 poll legs» invece di aspettare il turno. Il test misura i millisecondi della
    gamba, sulle due strade (con card e in chat).

  5. La domanda di un'ALTRA sessione deve invece registrarsi. «Un'altra sessione
    e' un altro rendez-vous e non cambia niente» non lo provava niente, e sbagliarlo
    dall'altro verso costa una domanda che non raggiunge nessuno per tutto il tempo
    in cui la sorella tiene la card. Adesso il caso c'e': la card e' occupata da
    un'altra sessione, la mia gamba si registra, riceve la sua risposta, e la
    domanda dell'altra resta aperta e non annullata.

Prove del settimo giro (rotte vere, CLI finta, nessun invio)

  • chat, nessuna card: domanda generica mentre una conferma d'invio aspetta →
    pending, la gamba spesa, nessun secondo pannello, e il si' sul pannello
    dell'invio che paga l'invio (granted);
  • lucchetto scaduto per orologio: respinta prima, ammessa dopo, senza toccare
    la mappa dei lucchetti;
  • due sessioni di una card, registro gia' vuoto: la sorella e' rifiutata con la
    sua ragione, UN solo blocco di tasti sulla card, un +send solo in argv e
    l'altro destinatario che non compare;
  • domanda di un'altra sessione: {answers} e non pending, e la voce aperta
    dell'altra intatta.

Mutanti eseguiti, cinque, ognuno fa rosso almeno un test: lucchetto letto solo
via routed.busy (il blocco 1) · lucchetto senza scadenza · chiave del lucchetto
sulla sessione invece che sulla card · attesa della gamba occupata tolta · vincolo
«stessa sessione» tolto.

Ottavo giro (verifica avversaria)

Un blocco riprodotto e due mutanti sopravvissuti. Il blocco e' lo speculare
esatto
di quello del giro prima, sullo stesso canale e nella direzione opposta.

  1. In chat il si' arrivava alla SESSIONE, non alla domanda. Il rimedio del
    settimo giro parcheggia la domanda generica senza registrarsi - ma il suo
    pannello resta a schermo: lo dipinge il rilevatore dello stream sul tool_use,
    non la rotta della gamba (server/routes/permission.ts:127-131 lo dice gia').
    La persona clicca quello, e /api/chat/tool-response non guardava quale
    domanda si stesse rispondendo: hasPendingAsk(sessionKey) e' vero e si va
    dritti a deliverAnswer(sessionKey, answers). Misurato con le due rotte vere e
    senza card: consegnato: true e l'invio che torna
    {"state":"refused","reason":"the answer that came back was not about this message"},
    con la gamba dopo della generica ancora {"pending":true}. Un clic, due domande
    rotte.

    La forma giusta questa PR ce l'aveva gia' altrove: sulla strada della board
    answerTo dice che il si' appartiene a QUELLA domanda. Adesso ce l'ha anche la
    chat. L'attesa che possiede il rendez-vous nomina la riga su cui la sua
    domanda e' dipinta - il cancello d'invio il pannello lo dipinge lui, quindi la
    nomina - e la rotta consegna solo se il toolCallId cliccato combacia. Se non
    combacia: niente consegna, niente parte, nessuna attesa uccisa, e un 409 con
    una riga leggibile che il form mostra in linea (ToolInputForm gia' mostra il
    messaggio dell'ApiError). Un pannello vecchio o gia' risposto lo dice, invece
    di rubare il turno a quello vivo.

    Il ramo di compatibilita' e' dichiarato: chi apre l'attesa senza nominare una
    riga - la gamba generica, che il pannello non lo dipinge - riceve la risposta da
    qualunque pannello. Ci arriva solo quando nessuna conferma di quella sessione
    tiene il lucchetto, quindi la sua e' l'unica domanda che possa essere in attesa.
    E un'attesa senza identita' cancella quella lasciata prima, o un invio
    abbandonato renderebbe muta la domanda che gli succede.

  2. I due predicati di outboundHoldOfSession non li teneva nessun test. La
    funzione che il settimo giro ha aggiunto ha due condizioni e togliendone una
    qualunque la suite del ramo restava verde. Nessuna delle due e' accademica:
    senza il vincolo di sessione l'invio di UNA sessione parcheggia le domande
    generiche di tutte le altre (la mappa dei lucchetti e' globale, e
    coordinatore piu' figlie sono piu' sessioni per costruzione); senza la scadenza
    un agente morto fra due gambe lascia la sua sessione senza domande fino al
    riavvio
    , perche' niente ripuliva quella mappa. L'orologio iniettabile non
    arrivava fin qui - l'unico chiamante non passa il secondo argomento - quindi la
    scadenza la prova una rotta, con la gamba dell'invio indietro di due minuti.
    E un lucchetto scaduto adesso esce dalla mappa invece di essere solo
    ignorato: era memoria che non tornava piu'.

Prove dell'ottavo giro (rotte vere, CLI finta, nessun invio)

server/routes/chat.ask-answer-identity.test.ts, con
/api/chat/tool-response e la gamba /ask-user vere, il rendez-vous e il
cancello veri, nessuna card:

  • il si' dato al pannello parcheggiato: 409 ask_not_current, niente scritto,
    nessun broadcast, la conferma ancora in attesa - e il si' dato al suo pannello
    la paga (granted);
  • pannello di un turno finito: 409, la conferma viva intatta e ancora
    rispondibile;
  • domanda generica da sola: risposta consegnata come sempre (il ramo di
    compatibilita');
  • invio abbandonato (orologio indietro di due minuti): la domanda dopo si
    registra e riceve la sua risposta - cioe' la scadenza del lucchetto misurata sul
    chiamante vero, e l'identita' azzerata;
  • lucchetto di un'altra sessione: {answers} e non pending, e l'invio
    dell'altra sessione resta in attesa del suo pannello.

In server/lib/outbound-gate.test.ts, con l'orologio del test: il lucchetto e'
vero per la sua sessione e falso per un'altra, vivo fino all'ultimo millisecondo
della gamba piu' il margine, falso subito dopo, e la mappa e' vuota.

Mutanti eseguiti, sei, ognuno fa rosso almeno un test: vincolo di sessione
tolto · scadenza tolta · rimozione dalla mappa tolta · l'invio che non nomina il
suo pannello · l'attesa senza identita' che non azzera quella prima · il cancello
di rotta tolto.

Prove

Tutte contro un finto eseguibile che registra argv, l'ambiente e il CONTENUTO
di ogni argomento che sia un file. Nessun invio vero, mai.

  • l'iniezione (; rm -rf, virgolette, a capo, $(...)) resta UN argomento e non
    esegue niente;
  • senza conferma non parte nessun processo (il file del finto eseguibile non esiste);
  • il file sostituito da un link DURANTE l'attesa: parte il contenuto confermato, e il
    percorso del workspace non e' nemmeno negli argomenti;
  • la COPIA piantata in anticipo al percorso derivabile: parte il contenuto letto dal
    workspace, non quello piantato;
  • la COPIA riscritta durante l'attesa: invio rifiutato, nessun processo, riga sulla
    card;
  • una FIFO al posto dell'allegato: rifiutata, e il processo non si ferma;
  • un allegato oltre il tetto viene rifiutato senza essere letto;
  • gmail +forward con i flag nei campi liberi: rifiutato con use_send_mail;
  • un campo che comincia con un trattino: rifiutato con invalid_call;
  • gmail users messages send rifiutato senza aprire nessuna domanda;
  • una scrittura Google con raw mostra destinatario, oggetto e testo in chiaro;
  • un account non dichiarato viene rifiutato, non sostituito con il predefinito;
  • una variabile mancante dice QUALE manca e dove si scrive;
  • una lettura Google non chiede niente, una scrittura si', e un metodo che non si sa
    classificare conta come scrittura;
  • l'invio riuscito lascia la traccia sulla card e NON ci incolla il corpo;
  • il throttle VERO, saturo a 15 secondi, con due tool: la conferma non viene
    rifiutata e il pannello finisce sulla riga giusta.

Mutanti verificati (ognuno fa rosso almeno un test): chiave della conferma ignorata,
pending trattato come consenso, ripiego silenzioso sull'account predefinito, spawn
via sh -c, contenimento degli allegati disattivato, alla CLI il percorso del
workspace invece della copia congelata, rifiuto dei metodi che spediscono
disattivato, raw non decodificato, flush della riga rimosso dalla rotta, registro
che considera «gia' instradata» qualunque voce della stessa sessione.

Change

openspec/changes/posta-e-google (gia' approvata), con delta di spec outbound
(OUTBOUND-01..05) e tasks.md.

…ersona ha detto si' a QUEL messaggio

Topics impara a mandare posta e a chiamare Google, e la cosa che rende la
capacita' accettabile non e' il trasporto: e' dove sta la conferma. Non nella
descrizione dello strumento, che e' un consiglio a un modello, ma nella rotta
che spawna la CLI: `server/routes/outbound.ts` apre la domanda sul canale umano
che la board gia' usa (commento con risposte rapide nel thread della card,
pannello sulla riga dello strumento in una chat) e non spawna niente finche'
non arriva una risposta. Un agente che chiama la rotta a mano trova lo stesso
cancello.

Il si' vale per UN messaggio: la domanda porta un digest del contenuto nella
chiave e nel testo, e una risposta che non lo porta non e' un consenso. Per
questo non passa dal bridge dei permessi, che e' fatto apposta per poter
smettere di chiedere (`allow_always`, sessione libera): giusto per un tool che
tocca un file, sbagliato per una mail.

Il resto sono conseguenze:
- gli argomenti arrivano da un modello, quindi la CLI si chiama per ARGV e mai
  per una stringa data a una shell; l'eseguibile si risolve su cartelle
  dichiarate, perche' sotto launchd il PATH non ha /usr/local/bin;
- la configurazione vive solo nell'ambiente (il repo e' pubblico, e GATE-07 e'
  rosso su un indirizzo in un file tracciato): niente indirizzi, niente nomi di
  account nel codice, la casella Exchange riconosciuta per indirizzo, e una
  variabile mancante che si nomina invece di ripiegare su un'altra casella;
- ogni invio e ogni scrittura lasciano una riga sulla card - chi, a chi, cosa,
  esito - senza il corpo; anche i rifiuti e i fallimenti;
- gli allegati si risolvono DENTRO il workspace della sessione: senza quel
  controllo `send_mail` e' una via di esfiltrazione.

Un difetto ereditato, riparato qui: il registro di `routeAskToTaskThread` si
puliva solo alla scadenza o rispondendo dal thread, quindi una risposta arrivata
dal pannello lo lasciava sporco e la domanda del messaggio DOPO non usciva piu'
sulla card, in silenzio.

Le prove girano contro un finto eseguibile che registra argv e ambiente: che
l'iniezione (`; rm -rf`, virgolette, a capo, `$(...)`) resti un argomento solo,
che senza conferma non parta nessun processo, che un account non dichiarato
venga rifiutato invece di sostituito. Nessun invio vero, mai.
… distingue da una rete lenta

Il figlio riceveva PATH e HOME e basta. Misurato oggi su questa macchina: con
quell'ambiente `gws drive files list` stampa «Using keyring backend: keyring» e
non torna piu' (oltre 25 s, due volte); aggiungendo `USER` risponde in un
secondo. Entrambe le CLI leggono le credenziali dalla Keychain di macOS, quindi
la stessa forma vale anche per la casella Exchange.

Era il guasto peggiore disponibile: non un errore, un PIANTO. Da fuori si legge
come rete lenta, brucia tutto il tetto di tempo della chiamata, e si sarebbe
scoperto solo in produzione. `TMPDIR` invece non cambia niente, provato a
parte, e infatti non entra.

Il finto eseguibile registra anche l'ambiente, cosi' che chi un domani
«ripulisce» quella lista trovi un test rosso invece di una chiamata che pende.

E l'elenco dei tool del server MCP avviato come SOTTOPROCESSO
(`topics-mcp-server.e2e.test.ts`) ora nomina i due nuovi: era l'unico rosso
della CI.
…e prende un file vuole un altro file

`TOPICS_GOOGLE_CLIENT_SECRET` punta al client «installed» che Google consegna.
Passarlo come `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` sembra la cosa ovvia ed e'
sbagliata: quella variabile vuole credenziali di UTENTE autorizzato, e la
chiamata muore subito con «Failed to parse authorized user credentials: missing
field client_id» (provato oggi). I due valori dentro quel file sono invece cio'
che la CLI legge come `GOOGLE_WORKSPACE_CLI_CLIENT_ID` e `..._CLIENT_SECRET` -
la stessa cosa che il wrapper della posta su questa macchina esporta a mano.

Quindi il file si legge al momento della chiamata e i due campi entrano
nell'ambiente del figlio; si accetta anche la forma `web`, che e' l'altra che
Google consegna. Gli errori nominano il FILE e il CAMPO che manca, mai i valori.

Provato con l'ambiente ESATTO che costruisce la rotta: `calendar events list`
torna JSON, e `gmail +send --dry-run` con oggetto fra virgolette e `$(...)` nel
corpo produce il POST che spedirebbe, senza spedirlo.
…ttura

Due cose che la conferma sbagliava.

La domanda mostrava mittente, destinatario, oggetto e «32 caratteri». E' una
firma su una busta chiusa: la board dice gia' che se chiedi «confermi X?» chi
risponde deve poter vedere X. Adesso il corpo sta nella domanda (tagliato a
1500 caratteri, e il taglio si dice); la TRACCIA resta com'era, una riga senza
corpo, perche' quella e' l'archivio e non la decisione.

E `watch` era classificato fra le letture perche' suona come un osservatore.
Non lo e': in queste API crea un'iscrizione push che continua a consegnare dopo
la chiamata, cioe' un cambiamento all'account. Passa fra le scritture, con le
sconosciute.
@zorahrel

Copy link
Copy Markdown
Contributor Author

Tre cose emerse provando la catena vera (nessun invio, solo letture e un --dry-run):

  1. Senza USER la CLI non torna. Con PATH e HOME soli, gws stampa «Using keyring backend: keyring» e resta li' oltre 25 s (due prove); con USER risponde in un secondo. Entrambe le CLI leggono le credenziali dalla Keychain. Era la forma peggiore: un pianto si legge come rete lenta, brucia tutto il tetto di tempo e si sarebbe visto solo in produzione. TMPDIR provato a parte, non serve, e infatti non c'e'.
  2. TOPICS_GOOGLE_CLIENT_SECRET e' un file, ma non il file che ..._CREDENTIALS_FILE vuole. Quella variabile della CLI vuole credenziali di utente autorizzato e rifiuta un client «installed» (missing field client_id). I due campi dentro quel file sono GOOGLE_WORKSPACE_CLI_CLIENT_ID e ..._CLIENT_SECRET. Con l'ambiente esatto che costruisce la rotta, calendar events list torna JSON.
  3. La conferma mostrava «32 caratteri» al posto del messaggio. Ora il corpo sta nella domanda (tagliato a 1500 caratteri, col taglio dichiarato); la traccia sulla card resta la riga senza corpo. E watch e' passato fra le scritture: suona come un osservatore, in queste API crea un'iscrizione push che sopravvive alla chiamata.

…n del disegno

`outbound-tools.ts` chiedeva `httpJson` al dispatcher, e il dispatcher chiede a
lui gli schemi dei due tool: un cerchio. In ESM regge finche' nessuno dei due
legge l'altro a livello di modulo, e GATE-08 (`tests/unit/no-import-cycles.test.ts`)
lo rifiuta proprio per questo - il guasto che previene non e' un errore
all'avvio ma un `undefined` silenzioso. Le deroghe sono vuote e restano vuote.

Quindi la cosa condivisa si sposta dove tutti e due possono importarla:
`server/mcp/topics-http.ts` porta `ParsedArgs`, il tetto di tempo di una
richiesta, l'init TLS di loopback, l'errore della richiesta persa e `httpJson`.
Una direzione sola, nessun cerchio.

Code di contorno dello spostamento, tutti misurati dai cancelli e non inventati:
i commenti italiani che viaggiavano con quelle righe sono ora in inglese (un
file nuovo nasce a zero), `loopbackTlsInit` diventa `loopbackInit` perche' `tls`
non e' una parola del dizionario e allargare PROJECT_WORDS avrebbe smosso il
debito di altri sei file, e la baseline degli identificatori perde quella voce -
scende, non sale. Rinominato anche il fixture `desktopClient` in
`installedClient`, che era l'altro rosso della CI.
`resolveAttachments` faceva `resolve()` + `isInsideDir()` + `existsSync()`. Il
primo normalizza `../` e non tocca i link; il terzo li SEGUE. Lo dichiara il
modulo stesso (path-containment.ts:20-23: «a symlink is not [risolto] ... il
chiamante passa percorsi REALI»), e questo chiamante non ne passava. Un agente
ha una shell nella propria worktree: `ln -s <segreto> preventivo.pdf` dentro il
workspace, e l'allegato esce. Riprodotto con la CLI finta: risposta 200 e la
finta CLI legge il contenuto del file fuori. Il test che c'era copriva
`../segreto.txt`, cioe' l'unico caso che il `resolve()` gia' chiudeva.

Adesso la decisione gira sul percorso reale, candidato e workspace, con
`realPathForNewEntry` - lo stesso rimedio che `browser-tool-dispatcher.ts` usa
gia' davanti a `checkUploadPath`, per la stessa minaccia. Il workspace si
risolve anche lui: sotto `/tmp` un progetto vive in `/private/tmp`, e
confrontare un percorso reale contro una radice linkata rifiuterebbe ogni file
legittimo. Alla CLI va il percorso REALE, non il nome chiesto: la risoluzione
avviene prima della conferma e la conferma dura minuti, quindi un link ancora
onesto quando la domanda esce puo' non esserlo piu' quando parte il figlio.

E la domanda NOMINA gli allegati, con nome e peso. «Allegati: 1» era la busta
chiusa che OUTBOUND-03 vieta due paragrafi sopra per il corpo: e' esattamente
cio' che rendeva il link invisibile all'unica persona che poteva fermarlo. Il
workspace di una card e' l'intera cartella del progetto, dove sta anche
`data/topics.db`, e anche quello contava «1».

Terza cosa, OUTBOUND-05: dopo il si' della persona, un eseguibile che la
variabile non trova piu' usciva 400 senza `trace()`. La persona ha confermato e
niente e' partito - e' un fatto della card quanto un invio riuscito. Vale per la
posta e per la scrittura Google, piu' il ramo in cui il cancello stesso solleva.
…a il prefisso

`findWaitingToolRow` confrontava `call.name !== toolName` contro la costante
`mcp__topics__send_mail`. Quel nome e' quello che scrive la flotta MCP, cioe' un
agente pilotato da una CLI. Il runtime nativo non lo scrive:
`providers/native/topics-tools.ts` mappa `toolsForProfile` dritto nella lista
del modello, quindi li' il tool e' `send_mail` e il nome nudo e' quello che
finisce in `blocks` - verificato sul DB di produzione in sola lettura, dove
`update_task` compare nudo sotto provider `topics`, che sono 702 topic su 776.

Conseguenza in una chat senza card: nessuna riga trovata, `asked` resta falso,
e ogni invio e ogni scrittura Google rifiutati con «there is no card thread and
no visible tool row to ask on». Non e' la prima volta: `ask-user-detector.ts`
porta la stessa regola a tre vie e la stessa storia, osservata il 2026-08-28 su
una chat parcheggiata su una domanda senza controllo a schermo. La CI non lo
vedeva perche' ogni caso del file usava il nome prefissato.

Stessa regola del detector: nome pieno, nome nudo, o un altro punto di
montaggio. Il separatore fa parte del confronto, altrimenti un `my_send_mail`
di qualcun altro risponderebbe alla nostra domanda.
…secondo messaggio

`httpJson` solleva sia quando la richiesta si perde sia su qualunque stato fuori
dal 2xx, e il `catch` di `pollOutbound` non sapeva distinguerli: contava tutto
come guasto di trasporto, dormiva e ri-POSTAVA lo stesso corpo. Il commento del
file diceva il contrario di cio' che il codice faceva («Nothing is ever re-sent
after an ANSWER»).

Due conseguenze, misurate con un finto fetch. Un 400 `unknown_account`
diventava 20 POST identiche e l'agente leggeva «lost contact with topics-app»:
cioe' TUTTI gli errori parlanti che OUTBOUND-01 promette (variabile mancante,
account non dichiarato, allegato rifiutato) arrivavano travestiti da rete rotta.
Un 502 e' peggio: il primo giro ha gia' fatto girare la CLI e CONSUMATO la
conferma, quindi il ri-POST apre una domanda NUOVA per lo stesso messaggio - e
chi dice si' alla seconda manda la mail due volte. Il tetto dei 90s non chiudeva
niente, perche' ogni `pending` e' un httpJson riuscito che azzera l'orologio: si
andava avanti fino alle 600 gambe.

Quindi l'informazione «il server ha risposto» diventa una CLASSE,
`HttpAnswerError` con lo stato, e non un prefisso di messaggio da grepare. Il
giro ritenta solo cio' che non e' quella; una risposta torna all'agente com'e',
il messaggio preciso ce l'ha gia'. Un 2xx che porta `error` nel corpo e' una
risposta anche lui.
…er se'

Il referto della change dichiarava riparato «il registro di
`routeAskToTaskThread` che restava sporco dopo una risposta arrivata dal
pannello». Era falso: la riga in piu' stava dentro `outbound-gate.ts`, cioe'
dentro il cancello nuovo. Il percorso da cui il difetto e' ereditato non era
toccato - `server/routes/permission.ts` non compare nel diff, e la sua gamba
`ask-user` chiamava `clearRoutedAskForSession` SOLO sul ramo della scadenza del
TTL. Quindi per `ask_user_question` l'entry restava, e la domanda successiva
continuava a non uscire sulla card: «aspetta te» senza il testo di cosa vuole,
che e' precisamente il difetto che `board-ask-routing.ts` esiste per chiudere.

Adesso la gamba svuota il registro quando la risposta arriva davvero, e quando
la domanda viene annullata o superata. Non quando la gamba scade: li' la
domanda e' ancora aperta ed e' lo stesso pannello.

Per provarlo serviva un seme: `createPermissionRouter` prende ora un `comment`
iniettabile, esattamente come `createOutboundRouter`, perche' quello vero passa
da `createTaskService` e vuole un DB. Il test e' rosso senza la riga (secondo
commento mai scritto) e verde con.
Quattro cose che la spec non diceva e che quindi nessuno poteva contestare al
codice: il contenimento si decide sul percorso REALE (e alla CLI va quello), gli
allegati si NOMINANO nella domanda, la riga dello strumento si trova anche col
nome nudo del runtime nativo, e una risposta del server non si ri-manda. Piu' il
fallimento dopo il si' che deve lasciare la sua riga, che OUTBOUND-05 diceva a
meta'.

Cinque scenari nuovi: il link verso l'esterno, il nome nell'anteprima, il tool
senza prefisso, la risposta non ritentata, il confermato-e-non-partito.
`routeAskToTaskThread` esce restituendo il task anche quando NON scrive il
commento: succede ogni volta che il registro (per TASK, non per domanda) porta
ancora una voce di quella sessione, per esempio quella di un `ask_user_question`
di un turno interrotto, che nessuno dei tre clear copre. Il cancello degli invii
leggeva quel valore come «la domanda e' stata posta»: la POST rispondeva
`pending`, sulla card restava la domanda di prima, e lo strumento bruciava 600
gambe (quattro ore) su una conferma che non esisteva da nessuna parte.

Adesso l'instradamento dice `shown`, cioe' «il commento c'e' davvero», e una
domanda nuova SOSTITUISCE quella che trova per la stessa sessione: e' la regola
del rendez-vous (`waitForAnswer` supersede il waiter che trova) e quella gia'
scritta sul registro. Rifiutare l'invio sarebbe stato l'altra strada, ma
avrebbe lasciato una persona a sbloccare a mano una domanda che nessuno puo'
piu' rispondere. La sostituita viene chiusa con una riga sua: un blocco di
risposta rapida che cambia testo sotto gli occhi senza dirlo e' peggio del
silenzio.
Il cancello cerca lo strumento in attesa nell'ultima riga assistant del DB, ma
su una riga moderna l'unica colonna che porta le chiamate e' `blocks` (sul DB
vivo tutte le 6916 righe con blocchi hanno `tool_calls` vuoto), e `blocks` passa
dal throttle: fra 1 e 15 secondi per tutto cio' che non sia la prima scrittura
del turno o un raddoppio del payload. Un `send_mail` che non fosse il primo tool
del suo turno non era ancora sulla riga quando il cancello guardava, e «non
ancora persistita» veniva trattata come «non c'e' nessuno a cui chiedere»: un
rifiuto SECCO, per giunta, perche' lo strumento solleva sul rifiuto e non c'e'
una seconda gamba, mentre bastava aspettarne una (25 s > 15 s).

`lib/turn-body-flush.ts` pubblica il flush del turno vivo e la rotta lo forza
prima di leggere: e' la stessa mossa che `routes/chat.ts` fa gia' per lo stesso
motivo, «a tool that stops to ask is written NOW».

Il test guida il throttle VERO con due tool e la scrittura satura a 15 secondi:
nessuno dei casi esistenti lo avrebbe visto, perche' ognuno costruisce a mano
una riga che contiene gia' il tool.
Due difetti della stessa specie: la conferma prometteva una cosa e ne partiva
un'altra.

GLI ALLEGATI. `resolveAttachments` congelava la STRINGA, non il contenuto, e
fra la domanda e lo spawn c'e' l'attesa della persona - minuti, con l'agente
che ha una shell in quel workspace. `rm preventivo.pdf && ln -s <segreto>
preventivo.pdf` (o la semplice riscrittura del file, senza nessun link) e
quello che parte non e' quello che e' stato confermato, con la persona che ha
letto «preventivo.pdf (9 B)». Riprodotto in entrambe le forme. Adesso i byte si
LEGGONO al momento della domanda e si copiano in una cartella privata del
server (0700, sotto `~/.topics`, fuori da ogni workspace): alla CLI va la copia,
la domanda porta nome, peso e sha256 breve, e l'impronta entra nel digest, cosi'
byte diversi sono un messaggio diverso e una domanda nuova. Non un secondo
controllo prima dello spawn: sarebbe la stessa corsa un giro piu' in la'. Le
copie si cancellano a invio finito e scadono a sei ore, cioe' oltre le quattro
che lo strumento puo' stare in attesa.

GOOGLE. `gws gmail users messages send` manda una mail senza passare da
`send_mail`, e la domanda che la persona firmava era `body: {"raw":"Rnjvbto..."}`
tagliato a 300 caratteri: destinatario, oggetto e corpo invisibili, la busta
chiusa che OUTBOUND-03 vieta, riaperta sulla porta accanto. I metodi di Gmail
che spediscono adesso sono rifiutati rimandando a `send_mail` (elenco esplicito,
insensibile a maiuscole e spazi), e ogni altra scrittura viene riassunta in
parole: un `raw` in base64 viene decodificato in mittente, destinatario, oggetto
e testo, e nessun campo viene tagliato senza dirlo. La traccia sulla card nomina
a chi e con che oggetto, invece del solo metodo.

La CLI finta dei test adesso registra anche il CONTENUTO di ogni argomento che
sia un file: guardare solo argv non distingue una copia congelata da un file
sostituito durante l'attesa.
OUTBOUND-04 prometteva che «il percorso passato alla CLI SHALL essere quello
reale, cosi' che un link ripuntato mentre la persona legge la domanda non cambi
cio' che parte»: era falso in quella forma, perche' il percorso reale resta un
nome, e il nome e' proprio cio' che l'attaccante lascia intatto. Adesso la
regola e' quella che il codice applica - i byte letti e copiati al momento della
domanda, l'impronta nella domanda e nell'identita' del messaggio - insieme alle
due che i difetti hanno scoperto: `google_call` non e' una seconda porta della
posta e ogni scrittura si legge in parole (OUTBOUND-04), la riga su cui si
dipinge non dipende da una scrittura differita e «chiesto» vuol dire che il
commento e' stato scritto (OUTBOUND-03).
Due conflitti, stessa causa: main ha aggiunto una regola numerata al protocollo
della board mentre questo ramo ne aggiungeva un'altra. La 5-bis di main (la
scelta consigliata) resta dov'e', e quella sulla posta diventa 5-ter; il
cancello di parita' la ancora come le altre, e il suo test dedicato sparisce
perche' l'elenco dice gia' le stesse tre cose.
`Buffer.from(x, "base64")` non rifiuta niente: davanti a un `raw` che non e' un
messaggio restituisce mojibake, senza nemmeno un'intestazione, e il sommario
diventava «Da: (non indicato) / A: (non indicato) / Oggetto: (nessuno)» con un
corpo vuoto - cioe' MENO del JSON che aveva sostituito. Senza almeno
un'intestazione riconosciuta non e' un messaggio, e il corpo si stampa come sta.

Nel finto segreto del test TOCTOU la parola «finto» mancava: nel repo pubblico
una stringa che si legge come una chiave vera non si lascia in giro nemmeno
dentro un fixture.
Un agente dispatchato legge `OUTBOUND_TOOLS_RULE` e nient'altro: senza queste
due righe scopriva il rifiuto di `gmail users messages send` sbattendoci contro,
e non aveva modo di sapere che il file allegato non si puo' piu' cambiare dopo
la domanda. `docs/board-protocol.md` (la copia per gli umani) dice le stesse due
cose.
…uo test

La riga nomina destinatario e oggetto da ieri, ma niente lo teneva fermo: la
spec lo chiede (OUTBOUND-05) e il mutante che riporta la traccia al solo metodo
passava tutto verde.
La riga citava «Scrittura Google eseguita: ... - riuscita» dentro un commento
inglese, e l'italiano e' proprio il soggetto della citazione: e' il caso che
`allow-italian:` esiste per coprire, e il marcatore vale per la riga su cui sta.
…tro slot liberi

La copia degli allegati prometteva di stare «dove l'agente non arriva»: falso.
Server e agenti girano sotto lo stesso uid, quindi 0700 tiene fuori gli altri
utenti e non l'agente, e due strade sono state riprodotte. La cartella si
chiamava sha256(sessione, nomi e impronte) e la scrittura era saltata se il file
c'era gia': l'agente calcolava il nome, ci piantava il segreto e chiamava
send_mail, e quella copia partiva - nessuna corsa, nessun tempismo. La seconda e'
riscrivere la copia mentre la persona legge, che `ls -t` trova senza calcolare
niente. Ora il nome viene da mkdtemp e vive in una mappa in memoria, ogni copia
si crea con O_EXCL, e dopo il si' le copie si rileggono e si ri-hashano prima
dello spawn: un'impronta diversa ferma l'invio e lascia la sua riga. La finestra
da chiudere era l'attesa della persona, che dura minuti.

Il tetto dei 25 MB si misurava sui byte gia' in RAM: 64 MB letti e poi rifiutati
sono 64 MB di RSS, cioe' il guasto che il tetto esiste per impedire. Ora la
decisione sta sulla stat, la copia va a blocchi, e un allegato che non e' un file
regolare e' un rifiuto: leggere una FIFO non ritorna finche' nessuno ci scrive, e
il test che lo prova prima congelava l'intero processo senza stampare una riga.

Su google_call l'elenco dei metodi che spediscono non poteva bastare: i quattro
campi diventano argv, e la CLI ha quattro helper che spediscono (+send, +reply,
+reply-all, +forward) che non sono percorsi d'API. Con `resource: "+forward"` e i
flag negli altri campi si inoltrava un messaggio qualunque della casella, con i
suoi allegati, a un indirizzo scelto da chi chiama. I quattro campi ora devono
essere nomi d'API; l'elenco resta come secondo strato perche' «usa send_mail» e'
una risposta migliore di «non e' un nome».

Il confine di fiducia e' l'utente della macchina, e spec ed envelope adesso lo
dicono invece di promettere il contrario.
…ondi

Due sessioni dello stesso task esistono per costruzione: il coordinatore e le
sue figlie stanno sullo stesso taskId, e il registro delle domande instradate e'
chiavato li'. Due conferme di invio aperte insieme diventavano un blocco di
tasti solo; la seconda scacciava la prima senza scrivere niente e la risposta
veniva consegnata alla chiave corrente. La persona leggeva un messaggio,
confermava, e ne partiva un altro verso un altro destinatario.

Adesso una domanda ha un id suo - l'id della riga del thread che la porta, che
e' l'unica cosa che chi risponde puo' nominare perche' e' il commento su cui
clicca. La card lo rimanda indietro (`answerTo`) e una risposta che nomina un id
diverso da quello aperto non si consegna, non fa partire niente, e la card
scrive perche': il commento e' gia' salvato, e senza quella riga chi ha cliccato
crede di aver confermato. Una seconda conferma di invio su una card che ne ha
gia' una viva viene RIFIUTATA con la sua ragione - la card disegna un blocco di
risposta rapida solo, quindi mostrarla significa togliere la prima, e un atto
irreversibile non puo' nemmeno restare in coda in silenzio per ore. Una
`ask_user_question` generica invece aspetta il suo turno, perche' il suo bridge
ripassa ogni 25 secondi e non perde niente. Se la vecchia non aspetta piu'
nessuno la nuova subentra, la riga di sostituzione esce anche fra sessioni
diverse, e il rendez-vous sostituito viene annullato.

La finestra fra `verifyFrozen` e la open della CLI non e' microsecondi e il
percorso non e' segreto: ci stanno un resolveCliPath, uno spawn e un avvio di
processo intero, vinti 3 volte su 4 senza nemmeno un ritardo simulato, e il
percorso si elenca. La chiusura naturale - aprire la copia, scollegarla, passare
al figlio /dev/fd/N - non regge con la CLI che chiamiamo davvero: misurata con
`gws gmail +send --dry-run` (nessun invio), torna «--attach '/dev/fd/3' resolves
to '/dev/fd/preventivo.pdf' which is outside the current directory», cioe'
canonicalizza e riapre il file per nome, e dal nome deriva l'intestazione MIME.
Quindi non si finge: spec, intestazione del modulo, commento della rotta ed
envelope dicono la misura vera, e che la conferma vale per due cose oneste -
impedire gli errori e lasciare una traccia.

Lo stesso comando ha mostrato una promessa che il codice non poteva mantenere:
`gws` rifiuta ogni allegato che risolva fuori dalla cartella corrente, e le
copie congelate stanno sotto ~/.topics mentre il server gira dove l'ha avviato
launchd. Nessun allegato confermato sarebbe mai partito. Il figlio adesso parte
con la cartella di appoggio come cwd, e il test lo misura sul PWD del processo.
La traccia sulla card nomina l'impronta confermata invece di un esito che non
puo' provare.
…risposta

Due `send_mail` della stessa sessione corrono insieme: il bridge MCP gestisce
ogni riga JSON-RPC in un callback che non aspetta, e la rotta e' comunque
chiamabile a mano. Riprodotto a 120 ms di distanza sulla rotta vera: il
cancello guardava solo le sessioni DIVERSE, quindi la seconda conferma
sostituiva la prima e la card finiva con due blocchi di tasti.

Adesso la domanda sulla card e' una sola chiunque la chieda, e chi arriva
secondo viene rifiutato PRIMA di aprire qualunque cosa di suo: il gate chiede
alla card prima di `beginAsk`, cosi' non c'e' niente da ripulire e niente che
appartenga a chi stava gia' aspettando. Il registro si svuota per ID della
richiesta e non per sessione: chiavato sulla sessione, la gamba che perdeva
cancellava la voce di quella che aveva vinto, e il clic sulla conferma ancora a
schermo non partiva piu' (`pendingRoutedAsk` tornava null e la card non diceva
niente). «Nessuno aspetta piu' quella domanda» si decide su due fatti, non su
uno: il rendez-vous e' chiavato sulla sessione e non sa distinguere due
richieste della stessa, quindi serve anche il battito delle gambe di poll.

La traccia si scrive `quiet`. `pendingQuestionComment` si ferma sulla prima
riga d'agente che non e' ne' domanda ne' consegna, quindi «Invio NON partito»
scritto sotto la conferma viva le toglieva i tasti e con loro l'`answerTo` del
drawer - proprio nello stato che la corsa produce. `quiet` e' la parola che
esiste per dire «annoto, non rispondo» ed e' letta da quella funzione sola: la
riga resta visibile e resta l'ultima parola della card. Tenerla indietro fino
alla risposta avrebbe significato un agente che dice «non e' partito niente» e
una card che non dice niente.

E la misura di `gws` che il giro prima citava senza produrla e' stata rifatta
con la CLI vera (0.22.5, `gmail +send --dry-run`, nessun invio): `--attach
/dev/fd/3` torna «resolves to '/dev/fd/3' which is outside the current
directory», non il nome del file - identico col file scollegato. Il rifiuto e'
il controllo di contenimento, prima che qualcosa venga aperto; col figlio in
/dev/fd il contenimento passa e la CLI si ferma un passo dopo, «Cannot read
--attach '3': Bad file descriptor», col descrittore dimostrabilmente ereditato.
La conclusione regge, l'evidenza adesso e' quella vera nei tre posti dove si
legge, e cio' che non e' stato misurato e' dichiarato inferenza.
Il testo diceva «una domanda viva di un'altra sessione» dove il codice adesso
rifiuta anche la seconda richiesta della stessa: una riga che descrive una
regola che non e' piu' quella manda a cercare il guasto dalla parte sbagliata.
Il giro prima aveva chiuso solo l'istanza che passava dal ramo «occupato»
dell'instradamento. Con lo STESSO contenuto le due richieste sono la stessa
domanda per quel ramo: la seconda si vedeva consegnare l'askId della prima,
entrava nel rendez-vous (chiavato sulla sessione) e lo sostituiva, la prima
tornava «superseded» e si portava via la voce comune. Riprodotto a 120 ms:
conferma a schermo coi tasti, `pendingRoutedAsk` null, clic che non consegnava
e non diceva niente, e la gamba dopo scriveva una SECONDA conferma.

Adesso il cancello si serializza da se'. Prende un lucchetto sulla superficie
che disegna la domanda - la card, o la sessione quando non ce n'e' una - prima
di qualunque scrittura e prima di aprire qualunque rendez-vous, e lo rilascia
alla fine, anche sugli errori. Chi non lo prende e' rifiutato senza aver
toccato niente. Il lucchetto attraversa le gambe con un gettone che la rotta
restituisce insieme a `pending` e lo strumento riporta indietro: con un payload
identico non c'e' altro che distingua la seconda gamba di una richiesta da una
seconda richiesta.

E una richiesta non possiede piu' un id che non ha creato: `created`
sull'esito dell'instradamento, tenuto attraverso le gambe dentro il lucchetto.
Serve nella finestra reale in cui il lucchetto scade (gamba + margine) mentre
la voce nel registro vive ancora (due minuti di silenzio).

La stessa cosa succedeva senza nessun duplicato: una `ask_user_question`
generica della stessa sessione chiamava `waitForAnswer` ignorando `busy` e
uccideva l'attesa dell'invio. Quando la card e' occupata da una richiesta della
STESSA sessione, quella gamba adesso spende il suo tempo senza registrarsi e
risponde `pending`.

I tasti non restano piu' su un blocco morto. Una domanda che finisce senza una
risposta nel thread - scaduta, annullata, o risposta dal pannello della chat -
viene chiusa con una riga sua, che e' cio' che il lettore dei tasti conta come
parola; e un clic su un blocco che non aspetta piu' nessuno riceve una riga
sulla card invece di diventare un commento che rimette al lavoro l'agente. Una
frase scritta sotto lo stesso blocco resta una nota: e' il contenuto che
coincide con un'opzione a dire che qualcuno ha premuto.

Testi veri: `check:test-globals` dichiarava «~80s» ed era contato fra i cancelli
leggeri - lancia un `bun test` per OGNI file, cioe' la suite unit intera, non e'
in CI, e sul Mac di chi lavora si e' preso 18 minuti prima di essere fermato a
mano. La doc di `clearRoutedAsk` prometteva una protezione che non dava
(«un id che ha scritto qualcun altro non e' un id con cui si puo' chiamare»:
l'id E' lo stesso quando la domanda e' la stessa).

Otto mutanti, ognuno fa rosso almeno un test: lucchetto tolto, gettone ignorato
dal client, adozione di un id non creato, `clearRoutedAsk` per sessione, la
domanda generica che aspetta lo stesso, la chiusura che non scrive niente, la
riga del clic morto tolta, il clic riconosciuto senza guardare le opzioni.
Il rimedio del giro prima pendeva da `routed.busy`, che esiste solo quando la
sessione ha una card: in una chat l'instradamento non risponde, la gamba di
`ask-user` si registrava lo stesso e la conferma d'invio della stessa sessione
tornava «superseded by a newer question» - con il si' letto sul pannello
dell'INVIO consegnato alla domanda generica. La regola e' la stessa con o senza
card, e il lucchetto del cancello la chiave giusta ce l'ha gia': per una
sessione senza card e' `session:<k>`. La gamba occupata spende il suo tempo
senza toccare niente, e la persona continua a vedere la conferma.

E quattro regole che il giro dichiarava non le teneva nessun test: sopravvivevano
il lucchetto senza scadenza, la chiave sulla sessione invece che sulla card, la
gamba occupata che non spende il tempo e il vincolo di sessione tolto. Ora ogni
mutante fa rosso: un orologio iniettabile per la scadenza, due sessioni della
stessa card nella finestra in cui il registro e' gia' vuoto, il tempo misurato
sulla gamba, e la domanda di un'altra sessione che deve invece registrarsi.
…hiesto

Il rimedio del giro prima parcheggia la domanda generica senza registrarsi, ma
il suo pannello resta a schermo - lo dipinge il rilevatore dello stream sul
`tool_use`, non la rotta della gamba, e il commento della rotta lo dice. La
persona clicca QUELLO, e `/api/chat/tool-response` non guardava quale domanda si
stesse rispondendo: `hasPendingAsk(sessionKey)` e' vero, si va dritti a
`deliverAnswer(sessionKey, answers)`, e il si' della generica veniva consegnato
all'INVIO. Misurato con le due rotte vere e senza card: l'invio rifiutava con
«the answer that came back was not about this message» e la generica restava
senza risposta. Un clic, due domande rotte - lo speculare esatto del difetto che
il giro prima aveva chiuso.

La forma giusta la PR ce l'ha gia' sulla strada della board: `answerTo`, il si'
appartiene a QUELLA domanda. Adesso ce l'ha anche la chat. L'attesa che possiede
il rendez-vous nomina la riga su cui la sua domanda e' dipinta - il cancello
d'invio il pannello lo dipinge lui, quindi la nomina - e la rotta consegna solo
se il `toolCallId` cliccato combacia. Se non combacia: niente consegna, niente
parte, nessuna attesa uccisa, e un 409 con una riga leggibile che il form mostra
in linea. Un pannello vecchio o gia' risposto lo dice invece di rubare il turno
a quello vivo.

Il ramo di compatibilita' e' dichiarato: chi apre l'attesa senza nominare una
riga - la gamba generica, che il pannello non lo dipinge - riceve la risposta da
qualunque pannello. Ci arriva solo quando nessuna conferma di quella sessione
tiene il lucchetto, quindi la sua e' l'unica domanda che possa essere in attesa.
E un'attesa senza identita' cancella quella lasciata prima, o un invio
abbandonato renderebbe muta la domanda che gli succede.

E i due predicati di `outboundHoldOfSession` adesso li tiene un test. Non erano
accademici: senza il vincolo di sessione l'invio di UNA sessione parcheggia le
domande generiche di TUTTE le altre; senza la scadenza un agente morto fra due
gambe lascia la sua sessione senza domande fino al riavvio, perche' niente
ripuliva la mappa. L'orologio iniettabile non arrivava qui - l'unico chiamante
non lo passa - quindi la scadenza la prova una rotta, con la gamba dell'invio
indietro di due minuti. Un lucchetto scaduto adesso esce dalla mappa: era
memoria che non tornava piu'.

Sei mutanti, ognuno fa rosso almeno un test: vincolo di sessione tolto, scadenza
tolta, rimozione dalla mappa tolta, l'invio che non nomina il suo pannello,
l'attesa senza identita' che non azzera quella prima, e il cancello di rotta
tolto.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant