MuninMunin
Log indKom gratis i gang
Home/Journal/Fortryd, for en forfatter du ikke kan kigge over skulderen.
Engineering · 10 min read

Fortryd, for en forfatter du ikke kan kigge over skulderen.

MCP's værktøjsannoteringer er hints, og specifikationen siger, at klienter skal behandle dem som ikke-betroede. Et hint beder klienten om at være forsigtig. Et påkrævet `ifVersion` i inputskemaet beder ikke. Her er, hvad en versionsrække rent faktisk indeholder, hvad en gendannelse rent faktisk gør, og hvorfor seks CMS-værktøjer nægter at køre uden det nummer, du sidst læste.

A cobalt blue lever-arch file with tabbed dividers standing on a dark contemporary desk at night, a coiled cable, a used mug, two pens and a sheet of paper askew around it.
Hver version bag et faneblad, hvis du vil have den forrige.

Det interessante spørgsmål om, at en agent skriver til din database, var aldrig, om den må det. Det er, om du tre dage senere kan finde den sætning, den ændrede, se hvad der stod der før, og sætte det tilbage.

Kan du fortryde det, en AI-agent skrev?

Kun hvis skrivningen blev registreret som en version frem for som en overskrivning. I Munin beholder hver eneste CMS-post og hvert videnbasedokument alle sine tidligere tilstande, listet nyeste først af cms_list_versions eller kb_list_versions, og cms_restore_version ruller én af dem tilbage. Fortryd er ikke en funktion i den klient, der kaldte værktøjet. Det er en egenskab ved rækken.

Den skelnen er hele indlægget. En agents skrivning er ikke farligere end et menneskes, fordi en model er sjusket — den er farligere, fordi ingen kiggede med i det øjeblik, det skete. Der er ikke noget usædvanligt ved selve skrivningen. Det usædvanlige er fraværet af den person, der normalt ville have bemærket det. Det fravær er selve skiftet, og det flytter vægten fra overvågning over på skrivningens form.

Hvad indeholder en versionsrække egentlig?

Et komplet øjebliksbillede, ikke en diff. Hver række bærer et id, den post den hører til, versionsnummeret, den status posten havde i det øjeblik, hele data-nyttelasten som den stod, og et tidsstempel. Intet rekonstrueres ved at afspille ændringer forfra, så en gendannelse kan ikke mislykkes, fordi et mellemtrin var forkert formet.

Fordi en posts brødtekst er et ordnet array af typede blokke frem for ét langt tekstfelt, er øjebliksbilledet struktureret hele vejen ned — du får blokkene og deres nøgler tilbage, ikke en mur af markdown, som du bagefter skal tolke på ny. Dette indlægs egen nabo i Journalen er en udmærket demonstration. Hvis agenter er de nye brugere, er MCP HTTP. er blevet redigeret et dusin gange siden juni, og cms_list_versions returnerer et dusin øjebliksbilleder — hver manchet, hver blok, hver omskrevet overskrift, hver enkelt komplet nok til at kunne gendannes for sig.

textcms_list_versions / cms_restore_version
# hvad versionslisten giver dig tilbage
[
  {
    "id":      "cev_hgv0jxkmhppxtlbwfe663i",
    "entryId": "cme_nvjisrqctcbkn36mwmr2mm",
    "version": 11,
    "status":  "published",
    "data":    { ... hele posten som den stod ... },
    "createdAt": "..."
  },
  { "version": 10, ... },
  { "version":  9, ... }
]

# og tilbagerulningen, som selv er en versioneret skrivning
cms_restore_version(
  entryId = "cme_nvjisrqctcbkn36mwmr2mm",
  version = 9,      # den tilstand du vil have tilbage
  ifVersion = 11    # den tilstand du erstatter
)
# -> opretter version 12, som indeholder version 9's data

Læs den sidste linje i uddraget to gange, for det er den del, folk får galt i halsen, når de bygger det her selv. Gendannelse spoler ikke tilbage. Den føjer til. At rulle tilbage til version 9 opretter version 12, der bærer version 9's data, og version 10 og 11 bliver liggende præcis hvor de var.

Forskellen betyder noget første gang en tilbagerulning selv er fejlen. En gendannelse af tilbagespolingstypen ødelægger beviset på det, du forsøgte at fortryde, hvilket betyder, at den anden fortrydelse ikke har noget at gå ud fra. En kæde, der kun vokser, betyder, at en dårlig gendannelse bare er endnu et led i kæden — og cms_restore_version kræver sit eget ifVersion, så selv fortrydelsen er bundet til den tilstand, den troede den rettede.

Hvorfor kræve et versionstoken på en helt almindelig skrivning?

Fordi to agenter på ét endpoint nu er normaltilfældet og ikke undtagelsen. ifVersion er påkrævet — ikke valgfrit — på seks CMS-værktøjer: cms_update_entry, cms_publish_entry, cms_unpublish_entry, cms_schedule_publish, cms_delete_entry og cms_restore_version. En kalder, der ikke har læst den aktuelle tilstand, kan ikke konstruere et gyldigt kald.

De fleste systemer, der leverer optimistisk samtidighed, lægger versionstjekket på godkendelses-trinnet — det øjeblik hvor et menneske skriver under — og overlader almindelige redigeringer til sidste-skrivning-vinder. Det er den nemme halvdel. Den Journal, du læser, redigeres af en planlagt agent, der kører hver morgen, af mig, og en gang imellem af en anden agent, der laver en kureringsrunde, og ingen af de tre kender til hinanden. Sidste-skrivning-vinder mellem dem er ikke en race condition, du rammer en sjælden gang. Det er en race condition på skema.

Læg mærke til, hvad cms_create_entry ikke tager. Der er intet ifVersion på en oprettelse, fordi der ikke er nogen tidligere tilstand at tage fejl af. Tokenet dukker op præcis dér, hvor en kalder ellers kunne køre noget over, hvilket er testen for, om en påkrævet parameter er disciplin eller ceremoni.

To tokens på én skrivning

kb_publish_curation_revision (dashboard-siden) tager både ifCandidateVersion og ifDocumentVersion. Det første binder skrivningen til den foreslåede tekst, der blev gennemgået; det andet til det dokument, den blev sammenlignet med. Hvis bare ét af dem har flyttet sig, skrives der intet. To tokens, fordi en revision er en påstand om to objekter — og en skrivning, der kun tjekker det ene, kan stadig lande oven på en redigering, ingen har læst.

Et hint beder klienten om at være forsigtig. En påkrævet parameter beder ikke.Designreglen under det hele

Er MCP's værktøjsannoteringer nok til at gøre agenters skrivninger sikre?

Nej, og protokollen siger det selv. Værktøjsannoteringer kom i revisionen 2025-03-26 som fire booleans — readOnlyHint, destructiveHint, idempotentHint, openWorldHint — og specifikationen er utvetydig med, at klienter skal behandle dem som ikke-betroede, medmindre de kommer fra en betroet server. Hver eneste egenskab er et hint. En server kan påstå readOnlyHint: true og slette dine filer alligevel.

Munin sætter dem, og de gør nytte: de er det, der lader en vært afgøre, om der skal stå en bekræftelsesdialog foran et kald. Men MCP-vedligeholdernes eget indlæg om værktøjsannoteringer fra marts 2026 trækker grænsen præcist, i en sætning der er værd at citere, fordi den er den reneste formulering af problemet, jeg har læst: "Hints inform decisions; contracts enforce them." Indlægget fortsætter med at sige, at når værdien af et forslag afhænger af, at annoteringen er sand, hører det hjemme i autorisationslaget, i transporten eller i runtime.

Jeg vil tilføje et fjerde hjem, og det er det billigste af de fire. Læg det i inputskemaet. En påkrævet parameter håndhæves af det, der validerer forespørgslen, hvilket betyder, at den holder ens, uanset om kalderen er Claude Code, et cron-job, en hjemmelavet runner eller et script, nogen skrev klokken 02:00. Samme specifikation råder klienter til at holde et menneske i loopet og til at logge værktøjsbrug til revisionsformål — begge formuleret som SHOULD, begge rettet til klienten. Et SHOULD i en andens kodebase holder lige så længe som deres seneste deploy.

  • VersionstokenDet heltal du sidst læste, givet tilbage på skrivningen. Bruges af cms_update_entry og kb_update_document. Billigt, fordi versionen allerede står i det objekt, du lige har hentet.
  • To versionstokenskb_publish_curation_revision (dashboard-siden) binder til den foreslåede tekst og måldokumentet på én gang. Nødvendigt når en skrivning er en påstand om forholdet mellem to rækker frem for om én.
  • IndholdsfingeraftrykEn digest af hele udkastet i stedet for en tæller. outreach_approve_proposal (dashboard-siden) kræver forslagets draftFingerprint; crm_apply_merge_proposal (dashboard-siden) kræver dets mergeFingerprint. Det rigtige valg når det, der ikke må ændre sig, er en komposition — modtager, emne, brødtekst, afsendelsestidspunkt — og et versionsnummer ville lade et felt flytte sig, uden at tælleren rykkede.
  • Kun-foreslå-udsagnsordSlet intet token, fordi værktøjet ikke skriver tingen. crm_propose_merge opretter en række; selve sammenlægningen er et separat kald med sit eget scope. Det, en agent ikke kan udføre, kan den ikke udføre mod en forældet læsning.

De fire er én idé i forskellige klæder: skrivningen bærer bevis på, hvad skribenten sidst så. Det tog os et stykke tid at opdage, at de var den samme idé, og det at give den et navn ændrede, hvordan vi gennemgår nye værktøjer. Spørgsmålet ved et nyt muterende værktøj er ikke længere "hvem må kalde det her" — scopes og audiences afgør den sag — men "hvad skal kalderen have læst først, og hvad bærer kaldet med sig for at bevise det?"

Vidensbasen nåede dertil før CMS'et. Dens kureringskø binder en publicering til præcis den tekst, en gennemgang læste, og disciplinen var åbenlyst rigtig i samme øjeblik vi havde den i ét modul, hvilket er grunden til, at den bredte sig til resten.

Hvordan ved du, hvilken agent der lavede en ændring?

Ved at skille to spørgsmål ad, som ligner ét. Versionskæden svarer på, hvad posten sagde ved version 9. Beslutningsposterne svarer på, hvem der valgte det, hvornår og hvorfor. Munin holder dem adskilt med vilje, fordi et øjebliksbillede skal være billigt nok til at blive skrevet ved hver eneste redigering, og en begrundelse har brug for et sted at lægge prosa.

Derfor dukker aktøren op på beslutningerne frem for på øjebliksbillederne. outreach_dismiss_proposal (dashboard-siden) beholder aktøren og tidsstemplet på forslaget. outreach_withdraw_proposal kræver en reason og registrerer, hvem der trak det tilbage. outreach_revise_proposal registrerer revisionCount, lastRevisedAt, lastRevisionReason, den reviderende aktør og revisedAfterReviewAt — et flag, der udelukkende findes for at fange det tilfælde, hvor nogen allerede havde åbnet udkastet til gennemgang, før det ændrede sig under dem. crm_set_contact_consent logger en CRM-aktivitet, der bærer lawfulBasis og source-etiketten. conv_strip_message_signature beholder den oprindelige brødtekst i metadata.preStripBody, så en oprydningsrunde også kan fortrydes. Selv analytics_revoke_tracker lader tracker-rækken blive stående, efter at nøglen er holdt op med at virke.

Hvert eneste kald knyttes til en aktør, før det når noget af det. ping er det mindst glamourøse værktøj i kataloget, og det returnerer det fundne org-id, organisationsnavnet og aktørtypen — hvilket er måden, du i ét kald finder ud af, at den forbindelse, du troede pegede på én tenant, peger på en anden. Det er det værktøj, jeg bruger oftest, og aldrig til det, det blev bygget til.

Hvad kan en versionskæde ikke gøre for dig?

Tre ting. Den laver ikke en diff mellem to versioner for dig — cms_list_versions returnerer komplette øjebliksbilleder, og sammenligningen er din at lave. Den stopper ved sletning, fordi cms_delete_entry kaskaderer til sine egne versioner. Og den er ikke en gennemgang: den beviser, at en skrivning landede på den tilstand, kalderen læste, ikke at tilstanden var værd at beholde. Det sidste er, hvad køerne er til for.

Tag dem i rækkefølge, for hver enkelt er en følge af et valg frem for et uheld.

Den manglende diff falder ud af at gemme tilstande frem for deltaer. Et øjebliksbillede gendanner præcist, i ét kald, uden afspilning, og at sammenligne to af dem er et problem, du kan løse i det sprog, du har lyst til — hvorimod et gemt delta, der viser sig at være forkert, er en ødelagt kæde, du slet ikke kan gendanne fra. Stillet over for valget mellem en sammenligning, du skriver selv, og en historik, der måske ikke indlæses, tog vi historikken.

At sletningen afslutter kæden er grunden til, at slette-udsagnsordene er de hårdest bundne af dem alle. cms_delete_entry kaskaderer til sine versioner og kb_delete_document kaskaderer til chunks og versioner, så begge kræver ifVersion — det mest destruktive kald i modulet er det, der mest insisterer på at vide, hvad kalderen kiggede på. Og de for alvor uigenkaldelige operationer i CRM og Outreach er slet ikke tilgængelige for en agent som enkeltstående kald. En agent kan oprette et sammenlægningsforslag. At anvende det er et andet værktøj, med et andet scope, og det har brug for fingeraftrykket.

Den tredje er den, der er værd at bygge videre på. Et versionstoken og en gennemgangskø løser tilgrænsende halvdele af det samme problem: tokenet gør skrivningen ærlig om, hvad den så, og køen sætter et menneske foran de skrivninger, hvor det ikke er nok at se rigtigt. Billige skrivninger anvendes direkte, og dyre foreslår kun — og versionstokenet er det, der gør "anvend direkte" til en forsvarlig kategori frem for et skuldertræk, fordi hver eneste af de direkte skrivninger stadig er bundet til en tilstand, nogen kan gå tilbage til.

6
CMS-værktøjer der ikke kører uden den version, du sidst læste
2
versionstokens på én enkelt publicering af en videnbaserevision
12+
versioner gemt bag én publiceret journalpost
4
booleske hints i MCP's ToolAnnotations-grænseflade

Ofte stillede spørgsmål

Kan man rulle en ændring tilbage, som en AI-agent lavede i dit CMS? Ja, hvis platformen versionerer skrivninger i stedet for at overskrive dem. I Munin returnerer cms_list_versions alle tidligere tilstande af en post med den nyeste først, og cms_restore_version sætter én af dem tilbage. Tilbagerulningen er selv en versioneret skrivning, der kræver sit eget ifVersion, så en fortrydelse kan ikke lande på en tilstand, du ikke havde læst.

Hvad er optimistisk samtidighed i et MCP-værktøj? Kalderen giver den version tilbage, den sidst læste, og serveren afviser skrivningen, hvis rækken har flyttet sig siden. Den er optimistisk, fordi intet låses — to agenter kan læse den samme post på én gang, og den anden, der skriver, taber ganske enkelt og må læse igen. På Munins CMS-skriveværktøjer er tokenet en påkrævet parameter, ikke en valgfri.

Er MCP's værktøjsannoteringer en sikkerhedskontrol? Nej. readOnlyHint og destructiveHint er hints, og MCP-specifikationen kræver, at klienter behandler dem som ikke-betroede, medmindre serveren er betroet. De er gode til at drive bekræftelsesdialoger og dårlige til garantier. Håndhævelsen hører hjemme i autorisationslaget, i transporten, i runtime — eller, billigst af alt, i værktøjets påkrævede parametre.

Hvordan ved du, hvilken agent der ændrede en post? Gennem beslutningsposterne frem for øjebliksbillederne. Munins forslagsværktøjer beholder den handlende part, tidsstemplet og en begrundelse: outreach_withdraw_proposal kræver en begrundelse og registrerer, hvem der trak forslaget tilbage, outreach_revise_proposal registrerer antallet af revisioner og den, der reviderede, og crm_set_contact_consent logger en aktivitet med behandlingshjemmel og kilde.

Sletter en gendannelse af en gammel version de nyere? Nej. Gendannelse føjer til. At rulle en post tilbage til version 9 opretter version 12, der indeholder version 9's data, og version 10 og 11 bliver i kæden. Det er det, der gør en fejlagtig tilbagerulning til at redde — den tilstand, du gendannede væk fra, er der stadig.

Har du brug for et versionstoken, når du opretter en post? Nej, og Munin beder ikke om et. cms_create_entry tager intet ifVersion, fordi der ikke er nogen tidligere tilstand, en kalder kunne tage fejl af. Tokenet hører kun hjemme på kald, der erstatter, publicerer, planlægger, gendanner eller sletter noget, der allerede findes.

Hvis du bygger skrivninger til en agent

  • Versioner skrivningen i stedet for at overskrive rækken. Fortryd er en egenskab ved dataene, ikke ved den klient, der foretog kaldet.
  • Gem øjebliksbilleder frem for deltaer. Et øjebliksbillede gendannes i ét kald uden afspilning, og det kan ikke ødelægges af et dårligt mellemtrin.
  • Lad gendannelse føje til frem for at spole tilbage, så en fejlagtig tilbagerulning kan reddes, og kæden aldrig mister et trin.
  • Læg versionstokenet i inputskemaet som en påkrævet parameter. ifVersion er påkrævet på seks af Munins CMS-værktøjer, så en kalder, der ikke har læst den aktuelle tilstand, ikke kan konstruere et gyldigt kald.
  • Brug et fingeraftryk i stedet for en tæller, når det, der ikke må ændre sig, er en komposition — modtager, emne, brødtekst, afsendelsestidspunkt — frem for en enkelt række.
  • Hold øjebliksbilleder og beslutninger adskilt. Versionskæden registrerer, hvad posten sagde; forslaget registrerer aktøren, tidsstemplet og begrundelsen.
  • Behandl MCP's værktøjsannoteringer som UX, ikke som håndhævelse. Specifikationen beder klienter om at mistro dem, og hvert eneste sikkerheds-SHOULD i specifikationen er rettet til kode, du ikke kontrollerer.

Hvert værktøj, der er nævnt her, står i MCP-referencen med sit fulde inputskema, og den migration, der opretter versionstabellen, ligger i repoet.

En forfatter, du ikke kan kigge over skulderen, er fin nok. En forfatter, du ikke kan fortryde, er ikke.

Kjell Rune Monsø, stifter.