From 0e9764368fe3f4ce27a13733e9220d8d73fbaa02 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 11:41:05 +0200 Subject: [PATCH 01/27] docs(verify): design spec for okf verify, closing the audit loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit okf audit produces a worklist and nothing writes the field it selects on: every finding is a dead end. okf verify records a review — a dated { by, at } stamp — as the single governed writer of `verified`, wired to audit's output via stdin. The design rests on decisions taken explicitly during brainstorming, with an independent second opinion integrated: a stamp is a declaration, not a proof (credibility comes from landing in a reviewed diff, never from inferring it off a PR approval — that mechanism mass-promotes and empties the worklist); no guard on okf_write_concept and a CLI-symmetric agent tool (user decisions, documented consequences); verified is the latest stamp per actor, neither a log nor a state; conformance-level validation on write, so a reviewer can stamp a concept a third party left without a description; no --all, no --stale-after, ids only. Three code facts condition everything and are cited: DeriveTier ignores `at`, MaybeStampGenerated never refreshes, and okf_write_concept can already write any stamp — the gap was never "no writer" but "no governed writer beside an ungoverned one". Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- .../specs/2026-08-28-okf-verify-design.md | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-28-okf-verify-design.md diff --git a/docs/superpowers/specs/2026-08-28-okf-verify-design.md b/docs/superpowers/specs/2026-08-28-okf-verify-design.md new file mode 100644 index 00000000..bb05b3dc --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-okf-verify-design.md @@ -0,0 +1,418 @@ +# Design — `okf verify` : enregistrer une relecture, refermer la boucle d'audit + +Date : 2026-08-28 +Statut : validé en brainstorming (design approuvé section par section, second avis +indépendant intégré), prêt pour le plan d'implémentation + +## 1. Objectif + +`okf audit` (spec du 2026-08-21) a donné au bundle sa première question +corpus-level : *« quels concepts ont dépassé leur `stale_after` sans qu'un humain +les ait jamais relus ? »*. Il rend une worklist — mais cette worklist n'a pas de +sortie. Le champ `verified` (§5.2), dont §5.3 dérive mécaniquement le tier de +confiance et dont `okf audit` dérive toute sa sélection, n'a **aucun chemin +d'écriture gouverné** : la seule façon d'enregistrer une relecture est d'éditer +le YAML à la main ou de réécrire un frontmatter entier via `okf_write_concept`. +Résultat : personne ne le fait, rien n'est jamais vérifié, et chaque finding +d'audit est un constat sans remède. + +`okf verify` est le geste qui referme la boucle : constat → relecture → +estampille → le concept change de tier au passage d'audit suivant. + +```sh +okf audit b --stale --trust unverified,machine-confirmed \ + | cut -d' ' -f1 \ + | okf verify b --by human:julien - +``` + +— la question de l'article et sa réponse, jointes en une ligne. La friction que +cette feature supprime est celle d'*enregistrer* la relecture ; la *relecture* +elle-même n'a jamais été automatisable, et tout design qui automatise +l'enregistrement sans la relecture produit la promotion de masse qui vide la +worklist (voir §2 et §10). + +### 1.1 Trois faits du code qui conditionnent le design + +Vérifiés dans la base, pas supposés : + +- **Une estampille exprime deux choses et rien d'autre.** + `Stamp(Actor? By, string? At)` ([Trust.cs:7](../../../src/OKF4net/Trust.cs#L7)) : + pas de sujet, pas de portée, pas de liaison au contenu relu. +- **Le tier ignore la date.** `Trust.DeriveTier` est `Any(IsHuman)` + ([Trust.cs:38-46](../../../src/OKF4net/Trust.cs#L38-L46)) — une estampille + humaine de 2019 vaut `human-reviewed` pour toujours. Notre propre golden le + montre : `metrics/dau` y est à la fois `human-reviewed` et dans la worklist. +- **Rien ne dit quand un contenu a bougé.** `MaybeStampGenerated` n'écrit + `generated` que s'il est **absent** + ([BundleConceptWriter.cs:565-574](../../../src/OKF4net/BundleConceptWriter.cs#L565-L574)) ; + toute mise à jour le reporte inchangé. + +Conséquence assumée partout dans cette spec : une estampille atteste **un moment, +pas une version** du contenu. La question « le contenu a-t-il bougé depuis la +relecture ? » se répond hors bibliothèque, par +`git log -1 --format=%cI -- ` comparé à `max(verified[].at)` — le dossier +est canonique et son historique est celui de git, pas du YAML. **Aucune extension +de schéma** (`digest`, `scope`, `note` dans l'estampille) ne sera acceptée pour +recréer cette information dans le bundle ; c'est la réponse sanctionnée, à +documenter pour qu'elle ne soit pas re-proposée. + +## 2. Le modèle de confiance — décisions actées + +Ces décisions ont été prises explicitement par l'utilisateur pendant le +brainstorming ; elles priment sur toute intuition contraire d'un implémenteur. + +**Une estampille est une déclaration datée et signée, pas une preuve.** Aucun +outil zéro-dépendance ne peut authentifier qui l'a écrite : pas de crypto, pas +de fournisseur d'identité. `--by human:quelquun-dautre` est possible et le +restera. Ce qui rend une estampille crédible, c'est **où elle atterrit** — dans +un diff relu sous protection de branche — pas l'outil qui l'a produite. Le +mécanisme recommandé (documentation, pas code) : le relecteur ou l'auteur lance +`okf verify` localement, la PR contient la ligne +`+ - { by: human:alice, at: … }`, le relecteur voit l'affirmation et peut la +refuser. **Jamais** l'inverse — inférer l'estampille d'une approbation GitHub +transforme « un humain a approuvé ce diff » en « un humain se porte garant de +cette connaissance », deux choses différentes chaque fois qu'une PR touche un +fichier pour une autre raison que le relire (c'est-à-dire presque toujours). + +**Pas de garde sur `okf_write_concept`** (décision utilisateur, 2026-08-28). +`okf_write_concept` écrit un frontmatter complet, estampilles comprises : c'est +son contrat — importer un bundle, corriger un concept, reporter une relecture +existante en font partie. Le brider sur `verified` le casserait pour ces usages +légitimes, et ce n'est pas à un outil d'écriture générique de porter une +politique de confiance. En contrepartie, la documentation dit sans détour que ce +chemin existe. + +**Le tool agent `okf_verify` est symétrique au CLI** (décision utilisateur, +2026-08-28) : mêmes acteurs acceptés, `human:` compris. Cohérence du modèle — +si l'estampille est une déclaration, un agent n'a pas moins le droit de la +transcrire qu'un shell. La conséquence est documentée, pas cachée : un modèle +*peut* écrire une estampille `human:` ; c'est le diff relu qui fait foi. +Symétrie oblige, `okf_verify` est un tool **mutateur** : il entre dans +`WriteToolNames` et disparaît d'un déploiement MCP read-only. + +**Ce que la v1 garantit, et rien de plus** : toute estampille écrite par cette +chaîne d'outils est bien formée (§7), datée en UTC, porte exactement sur les +concepts nommés par l'appelant, et atterrit dans un fichier que git versionne. +Ce qu'elle ne garantit pas — l'identité réelle du signataire, le fait qu'il ait +lu quoi que ce soit — est annoncé comme hors de portée, dans le README et dans +l'aide du verbe. + +## 3. Périmètre + +**Dans le périmètre** — trois unités, une par couche : + +1. `BundleConceptWriter.RecordVerification` + le primitif atomique de + read-modify-write sur le **frontmatter** (il n'existe aujourd'hui que pour le + corps). +2. Le verbe CLI `okf verify`. +3. Le tool agent `okf_verify` (mutateur). + +**Hors périmètre, consigné au ROADMAP** (voir §10 pour les raisons) : l'audit +conscient du temps (exposer les estampilles dans `AuditFinding` pour demander +« human-reviewed, mais depuis quand ? ») ; toute GitHub Action ; `--remove` ; +`--json` ; `--stale-after` ; toute amélioration de l'émetteur YAML. + +## 4. Unité 1 — le cœur + +### 4.1 API publique + +Méthode ajoutée à `BundleConceptWriter` (classe existante) : + +```csharp + /// + /// Enregistre une relecture : ajoute ou remplace l'entrée `verified` de + /// l'acteur sur le concept, en préservant tout le + /// reste du frontmatter et le corps. Erreurs rendues en chaîne (errors-as- + /// data), null en cas de succès — même contrat que WriteConcept. + /// + /// L'id du concept (chemin sans .md). + /// L'acteur §7, requis, bien formé. + /// Horodatage ISO-8601 UTC ; null ⇒ UtcNow formaté. + public string? RecordVerification(string conceptId, string by, string? at = null); +``` + +Un seul écrivain gouverné, appelé par le CLI et par le tool — le partage retenu +pour `ConceptAudit` (calcul commun, présentations distinctes) s'applique ici à +l'écriture. + +### 4.2 Sémantique, point par point + +- **Dernière estampille par acteur — ni journal, ni état.** Si `verified` + contient déjà une entrée dont `by` est **textuellement identique** (comparaison + ordinale du `Raw`) à l'acteur donné, cette entrée est réécrite **en place** + (position préservée — `YamlMapping.Insert` sait déjà le faire pour une clé + existante) ; sinon l'entrée `{ by, at }` est ajoutée en fin de liste. + L'écrivain ne touche **jamais** l'entrée d'un autre acteur : un `process:` + ne peut pas dégrader une relecture humaine en la remplaçant. Pourquoi pas un + journal : l'émetteur YAML coûte trois lignes par estampille et le frontmatter + part dans le contexte des agents à chaque lecture — un vérificateur + `process:nightly` quotidien produirait ~1100 lignes par concept et par an. + Pourquoi pas un état (liste remplacée) : le modèle est pluriel par + construction (`DeriveTier` est un `Any`) et remplacer effacerait le jugement + des autres acteurs. Ce qui est perdu — la cadence des relectures — a déjà sa + place : `log.md` (§9). +- **Convention d'écrivain, pas règle de lecteur.** §5.2 décrit une liste et ne + dit rien de l'unicité par acteur. `ParseVerified` continue d'accepter les + doublons de tout autre producteur — même asymétrie strict-en-entrée / + permissif-en-lecture que la spec d'audit §4.1. +- **Validation à l'écriture : conformité §11, pas mode producteur.** + `RecordVerification` appelle `ValidateConformance()` (type non vide, + [OkfDocument.cs:158](../../../src/OKF4net/OkfDocument.cs#L158)), **pas** + `Validate()`. Divergence délibérée avec `WriteConcept` : `verify` ne produit + pas de contenu, il enregistre la relecture d'un contenu qu'il n'a pas écrit ; + refuser d'enregistrer parce qu'un tiers a omis une `description` substituerait + une politique éditoriale au jugement du relecteur — et rendrait inestampillable + précisément les concepts que la worklist remonte. +- **`by` : requis, bien formé.** `Actor.Parse(by).IsWellFormed` doit être vrai — + la chaîne `human:` nue (qui promeut pourtant le tier, `IsHuman` étant + insensible à la bonne formation) est rejetée à l'écriture. Strict en entrée, + permissif en lecture, comme partout. +- **`at` : toujours écrit.** Fourni ⇒ validé par + `BundleValidator.IsIso8601DateTime` (le prédicat du validateur lui-même, pour + que `verify` ne puisse jamais écrire ce que `validate` avertirait) ; absent ⇒ + `OkfTimestamp.FormatUtc(UtcNow())` via le seam d'horloge existant du writer + ([BundleConceptWriter.cs:81](../../../src/OKF4net/BundleConceptWriter.cs#L81)), + donc épinglable en test. +- **`generated` n'est jamais touché.** Ni écrit, ni rafraîchi : une relecture + n'est pas une génération, et la rafraîchir maquillerait la question « le + contenu a-t-il bougé depuis ? » (§1.1). +- **Atomicité.** Nouveau primitif privé de read-modify-write sur le + frontmatter, calqué sur `AppendToConceptAtomic` + ([BundleConceptWriter.cs:347](../../../src/OKF4net/BundleConceptWriter.cs#L347)) : + lecture, transformation, écriture sous une même détention du verrou par + chemin. Deux `verify` concurrents sur le même concept ne peuvent pas se + perdre une estampille. Les clés inconnues survivent (le `YamlMapping` ordonné + garantit déjà le round-trip). +- **Erreurs-as-data.** Concept introuvable, id malformé, document non conforme, + `by`/`at` invalides ⇒ chaîne d'erreur, jamais d'exception pour un cas attendu. + +## 5. Unité 2 — le verbe CLI + +### 5.1 Grammaire + +``` +okf verify … --by [--at ] [--dry-run] +okf verify - --by # ids lus sur stdin, un par ligne +``` + +| Élément | Règle | +|---|---| +| `…` | Un ou plusieurs ids **explicites**. Aucune forme « tout le bundle ». | +| `-` | Seul id positionnel : les ids arrivent de stdin, un par ligne, lignes vides ignorées, chaque ligne trimée. Pas de mélange `-` + ids explicites. | +| `--by` | Requis, valué, acteur §7 bien formé. Aucun défaut, aucune variable d'environnement, aucune lecture de git config : l'outil n'invente jamais un auteur. | +| `--at` | Optionnel, valué, ISO-8601 ; défaut : UTC maintenant. Sert la transcription différée (CI future) et les goldens déterministes. | +| `--dry-run` | Affiche ce qui serait écrit, n'écrit rien, code 0. | + +Le parsing passe par `CliArgs.Scan(args, "--by", "--at")` — les flags valués +déclarés au scan, le séparateur `--` honoré, comme pour les huit verbes +existants. **Tout-ou-rien** : les ids sont tous validés (existence, bonne forme) +avant la première écriture ; un id inconnu fait échouer la commande entière sans +rien écrire. + +**Pourquoi pas de forme groupée** : `verify` et `validate` diffèrent de deux +lettres et signifient l'inverse (conformité machine / endossement humain). Un +`okf verify monbundle` mal tapé doit échouer bruyamment (`error: missing +`) plutôt que faire quelque chose de plausible. Et une forme `--all` +est le geste exact de la promotion de masse : lancée une fois à l'onboarding, +elle vide la worklist pour toujours et ressemble à un succès. + +**Pourquoi pas `--stale-after`** : une commande qui à la fois affirme la +relecture et fait taire le détecteur est un bouton de renouvellement. La doc du +verbe répond explicitement à « comment sortir ce concept de la worklist de +péremption ? » : mettez à jour le contenu, puis son `stale_after` — deux gestes, +volontairement. + +### 5.2 Sortie + +Une ligne par concept, dans l'ordre donné, formulée comme un **enregistrement** +(« recorded »), pas comme une vérification — le verbe s'appelle `verify` par +fidélité au vocabulaire du champ (`verified`) et du tier (`human-reviewed`), +mais sa sortie ne surjoue pas ce qu'il fait : + +``` +recorded metrics/revenue human:julien 2026-08-28T09:14:00Z +``` + +En `--dry-run`, `would record` remplace `recorded`. Aucune autre sortie sur +stdout. Quand l'acteur avait déjà une entrée, la ligne porte le suffixe +` (replaces 2026-07-01T00:00:00Z)` — le remplacement est visible, pas +silencieux. + +### 5.3 Codes de retour et messages exacts + +- **0** : succès (y compris `--dry-run`) ; **1** : erreur d'invocation, id + inconnu, bundle illisible — via `CliOperationException`, rendue `error: …`. + +| Cas | stderr | +|---|---| +| aucun id | `error: missing ` | +| `--by` absent | `error: verify requires --by ` | +| `--by` sans valeur | `error: --by requires a value` (contrat `CliArgs`) | +| `--by` mal formé | `error: --by is not a well-formed §7 actor: "human:"` | +| `--at` invalide | `error: --at is not ISO-8601: "hier"` | +| id inconnu | `error: unknown concept "metrics/nope"` (et rien n'est écrit) | +| `-` mélangé à des ids | `error: "-" (stdin) cannot be combined with explicit concept ids` | + +### 5.4 Le reflow, assumé et documenté + +Écrire via le modèle ré-émet tout le frontmatter : sur un bundle écrit à la +main, le premier `verify` produit un diff de fichier entier pour un changement +d'une ligne (styles flow → block, commentaires supprimés — le parseur les +ignore). Décision v1 : **accepter et documenter** — passer `okf fmt -w` sur le +bundle une fois, en PR dédiée, après quoi les diffs de `verify` sont minimaux. +Rejeté explicitement : un patcheur textuel chirurgical de `verified`, qui +recréerait le second chemin d'écriture divergent que `BundleConceptWriter` +existe pour éliminer, en contournant verrous, gardes de reparse et validation. +L'amélioration de l'émetteur (séquences inline) est une piste séparée, au +ROADMAP. + +## 6. Unité 3 — le tool agent `okf_verify` + +```csharp +[Description("Record a review of one or more concepts: adds or replaces the caller's { by, at } entry in each concept's `verified` list. The stamp is a dated declaration, not a proof — same rules as the okf verify CLI verb.")] +public string Verify( + [Description("Comma-separated concept ids (paths without .md). Explicit ids only — there is no whole-bundle form.")] string conceptIds, + [Description("The §7 actor recording the review, e.g. human:alice, agent:assistant/1.0, process:nightly. Required, well-formed.")] string by, + [Description("ISO-8601 UTC timestamp; omit for now.")] string? at = null) +``` + +- Symétrique au CLI : mêmes règles (`by` requis bien formé, ids explicites, + tout-ou-rien, `at` validé), mêmes limites documentées (déclaration, pas + preuve). +- **Dans `WriteToolNames`** : c'est un mutateur ; il disparaît en déploiement + read-only, et le test existant qui épingle ce set passe de trois à quatre + entrées. +- Corps sous `RunTool` ; entrées invalides ⇒ message d'usage rendu en chaîne + (modèle `SearchUsageMessage`), jamais d'exception. +- La date vient du seam `UtcNow`/`Today` de `OkfBundleTools` via le writer — + aucune horloge nouvelle. +- Rendu : les mêmes lignes `recorded …` que le CLI, une par concept. Rendu non + partagé avec le CLI (dont les octets seront verrouillés par goldens), règle + établie par la spec d'audit §5. + +## 7. Tests + +### 7.1 Cœur (`RecordVerificationTests`, nouveau) + +1. Première estampille sur un concept sans `verified` : liste créée, `{by, at}` + exacts, tout le reste du frontmatter et le corps byte-identiques par + ailleurs ; clés inconnues préservées. +2. Même acteur re-vérifie : entrée remplacée **en place** (position dans la + liste inchangée), pas d'ajout. +3. Acteur différent : ajout en fin, entrées existantes intactes. Cas des + doublons pré-existants du même acteur (écrits par un autre producteur, que le + lecteur permissif accepte) : **seule la première occurrence textuelle est + remplacée**, les suivantes sont préservées — l'écrivain ne supprime jamais + une entrée qu'il ne remplace pas, même redondante. Épinglé par ce test. +4. `by` mal formé (`human:`) rejeté ; `at` non ISO rejeté ; concept inconnu + rejeté — tous en erreurs-chaîne. +5. Concept non conforme §11 (type vide) : rejeté ; concept conforme mais sans + `description` : **accepté** (la divergence §4.2, épinglée). +6. `at` absent ⇒ `UtcNow` du writer, épinglé par le seam. +7. Le tier observé par `ConceptAudit` bascule : unverified → machine-confirmed + (acteur `process:`) → human-reviewed (acteur `human:`) après estampille. +8. Concurrence : deux `RecordVerification` en parallèle sur le même concept, + acteurs distincts ⇒ les deux estampilles présentes. +9. `generated` absent avant ⇒ toujours absent après ; présent avant ⇒ + byte-identique après. + +### 7.2 CLI (`CliTests` + goldens) + +10. Cas nominal multi-ids, `--at` épinglé : lignes `recorded` exactes, code 0. +11. Golden : `verify` sur une copie de `tests/fixtures/okf_v02` (TempDir — on ne + modifie jamais une fixture), `--at` figé, sortie et fichier résultant + comparés à un golden neuf écrit à la main, LF, provenance documentée dans + `tests/fixtures/README.md` (pas de binaire de référence : verbe OKF4net). +12. stdin : `printf "a\nb\n" | okf verify b - --by …` estampille les deux ; + lignes vides ignorées ; `-` + id explicite ⇒ erreur exacte. +13. Chaque message d'erreur de §5.3, byte-exact. +14. Tout-ou-rien : deux ids dont un inconnu ⇒ code 1, **aucun** des deux + fichiers modifié. +15. `--dry-run` : sortie `would record`, fichiers byte-identiques. +16. Enchaînement de la boucle : `audit` (worklist non vide) → `verify` → + `audit` (worklist réduite) — le test raconte la feature. +17. `--help` liste `verify` ; parsing : flags valués avant le positionnel ; + `--` honoré. + +### 7.3 Agents/MCP + +18. `okf_verify` enregistré dans `GetTools()` ; **présent** dans + `WriteToolNames` (le test des trois mutateurs passe à quatre) ; absent du + toolset read-only. +19. Estampille réellement écrite via le pipeline AIFunction (liaison des + arguments), et via une session MCP `CallToolAsync`. +20. `by` mal formé / ids vides ⇒ message d'usage, pas d'exception ; bundle + supprimé après construction ⇒ `Error: …` via `RunTool`. +21. Schéma : `conceptIds` et `by` requis, `at` optionnel — épinglé comme pour + `okf_audit`. + +## 8. Documentation + +- README : le verbe (avec l'enchaînement `audit | verify` en exemple), la ligne + du tableau §5.2-§5.3 → `RecordVerification`, et l'encadré « déclaration, pas + preuve » : ce que l'estampille garantit, ce qu'elle ne garantit pas, le fait + qu'`okf_write_concept` peut aussi en écrire une, et le mécanisme recommandé + (l'estampille dans le diff relu, jamais inférée d'une approbation). +- CHANGELOG sous `Unreleased`. +- Site (`web/`) : ligne dans les deux tables de verbes + chapitre docs/Cli, avec + sortie réelle capturée ; tables de tools (12e tool) dans les README Agents et + Mcp + pages du site. +- `CLAUDE.md` : une ligne — `RecordVerification` est l'écrivain gouverné unique + de `verified` ; ne pas en forker un second. +- ROADMAP : l'audit conscient du temps (le follow-up à plus forte valeur : il + transforme les estampilles d'alibi permanent en signal qui décroît, et ne + demande aucun chemin d'écriture) ; GitHub Action éventuelle ; émetteur YAML. + +## 9. Contraintes respectées + +Zéro dépendance (BCL seul, aucun `PackageReference`) ; SPDX + file-scoped +namespaces + XML doc + nullable + warnings-as-errors ; Native AOT sans reflexion +nouvelle ; aucune fixture existante modifiée, goldens neufs manuscrits et +documentés ; aucune sortie existante ne bouge (aucun golden actuel ne couvre +`verify`) ; spec v0.2 : aucun champ nouveau, aucune clé nouvelle dans +l'estampille — la seule convention ajoutée (unicité par acteur à l'écriture) est +côté écrivain et documentée comme telle. + +## 10. Alternatives écartées + +**A. GitHub Action qui estampille les fichiers touchés par une PR approuvée.** +L'idée d'origine de l'article — écartée comme mécanisme : « touché par un commit +approuvé » n'est pas « lu et endossé par une personne ». Un `okf fmt -w` +bundle-wide, une régénération d'index ou un bump de `stale_after` promouvrait +tout le corpus au tier maximal, et la worklist vide ressemblerait à un succès. +Le lieu (la review) était le bon ; le mécanisme retenu est d'inverser le sens : +l'estampille est *dans* le diff qu'on approuve. + +**B. Garde sur `okf_write_concept`** (refuser d'introduire une estampille +`human:` absente du disque). Proposée par le second avis, écartée par décision +utilisateur : le tool réécrit des frontmatters entiers par contrat, et une +politique de confiance n'appartient pas à un écrivain générique. Compensation : +documentation explicite, et le modèle « déclaration, pas preuve » assumé +jusqu'au bout. + +**C. Tool agent interdit de `human:`** (ou pas de tool du tout). Écartée par la +même décision : symétrie complète avec le CLI. L'asymétrie aurait été une +demi-mesure — la voie `okf_write_concept` restant ouverte à côté. + +**D. `verified` comme journal (append toujours) ou comme état (remplacement +total).** Écartées toutes deux — §4.2. Retenu : dernière estampille par acteur. + +**E. `--stale-after` dans la v1.** Le point que le second avis donnait lui-même +comme le plus contestable de sa proposition. Écarté : affirmer la relecture et +faire taire le détecteur dans le même geste fabrique un bouton de renouvellement. +Coupé **en le disant** : la réponse à « comment sortir de la worklist » est dans +la doc, pas dans un flag. + +**F. Renommer le verbe (`review`, `attest`, `sign`).** Plus honnêtes en +apparence, écartés : `attest` collisionne avec le vocabulaire §10, `sign` +surpromet (pas de crypto), et s'éloigner du nom du champ (`verified`) et du tier +(`human-reviewed`) violerait la règle « un seul vocabulaire partout ». Le nom +reste `verify` ; l'honnêteté est payée dans la sortie (« recorded ») et la doc. + +**G. Patcheur textuel du frontmatter pour des diffs minimaux.** Écarté — §5.4 : +second chemin d'écriture divergent, exactement ce que `BundleConceptWriter` +existe pour empêcher. + +**H. Faire l'audit conscient du temps d'abord.** Défendable (aucun chemin +d'écriture requis, et il corrige l'alibi permanent), mais il raffine le constat +sans donner de sortie à la worklist. Ordonné juste derrière, au ROADMAP. From 9a085108e27f95dcbdfe01462984ce7e471cf1d5 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 11:48:29 +0200 Subject: [PATCH 02/27] docs(verify): fix five defects found reviewing the spec against the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read line by line against the source rather than re-read as prose. All five were verified in the code, not inferred: - The grammar `okf verify …` is inexpressible today: CliArgs holds a single `string? _positional`. Every existing verb takes exactly one positional, so nothing had needed more. Added as unit 0, with the honest note that the positional *list* existed and I collapsed it during a /simplify pass six days ago — correct then, and restoring it does not undo that. - OkfCli.Run takes stdout and stderr only, and nothing in the CLI reads Console.In. The `-` form is the feature's headline, and the suite drives the CLI in-process, so without a stdin seam the most important path would be the one path no test covers. Signature change, acted as a breaking change with a CHANGELOG entry. - The per-actor replacement cited YamlMapping.Insert, a mapping API, to justify replacing a stamp inside a sequence. YamlSequence is immutable: the sequence is rebuilt and re-inserted under the key. Two levels, two mechanisms — the spec now says so. - BundleConceptWriter.UtcNow is documented as consulted only when AutoStampGenerated is set; RecordVerification broadens that. Deliberate (one clock in the writer), so the XML doc must change with it. - "verify and validate differ by two letters" was simply false. The real argument — shared prefix, mutual autocompletion, both taking a bundle first — is stronger anyway. Three tests added for unit 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- .../specs/2026-08-28-okf-verify-design.md | 103 ++++++++++++++++-- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-08-28-okf-verify-design.md b/docs/superpowers/specs/2026-08-28-okf-verify-design.md index bb05b3dc..110b7bf2 100644 --- a/docs/superpowers/specs/2026-08-28-okf-verify-design.md +++ b/docs/superpowers/specs/2026-08-28-okf-verify-design.md @@ -99,14 +99,55 @@ l'aide du verbe. ## 3. Périmètre -**Dans le périmètre** — trois unités, une par couche : +**Dans le périmètre** — quatre unités, la première étant un prérequis +d'infrastructure que la relecture de cette spec a révélé nécessaire : +0. **Deux extensions du CLI, sans lesquelles la grammaire de §5 est + inexprimable** (voir §3.1). 1. `BundleConceptWriter.RecordVerification` + le primitif atomique de read-modify-write sur le **frontmatter** (il n'existe aujourd'hui que pour le corps). 2. Le verbe CLI `okf verify`. 3. Le tool agent `okf_verify` (mutateur). +### 3.1 Unité 0 — les deux prérequis du CLI + +**`CliArgs` ne porte qu'un seul positionnel.** `_positional` est un `string?` +([OkfCli.cs:151](../../../src/OKF4net.Cli/OkfCli.cs#L151)) et `Positional(what)` +le rend seul ; les huit verbes existants prennent tous exactement un positionnel +(`` ou ``). `verify` est le premier à en vouloir N +(` …`). Le scanner doit donc exposer, en plus, la **liste ordonnée** +des positionnels suivants — `Rest()` ou équivalent, tokens dans l'ordre, ceux +d'après `--` inclus. + +Note d'honnêteté, parce qu'un futur relecteur la posera : cette liste **a +existé**, et a été réduite à un champ unique le 2026-08-22 lors d'une passe +`/simplify`, au motif exact que rien ne lisait jamais au-delà du premier +élément. C'était vrai à ce moment-là. La restaurer n'annule pas cette +simplification, elle répond à un besoin qui n'existait pas encore — et le champ +unique redevient ce qu'il aurait dû rester : un cas particulier de la liste, pas +son remplaçant. + +**`OkfCli.Run` ne reçoit pas stdin.** Sa signature est +`Run(string[] args, TextWriter stdout, TextWriter stderr)` +([Program.cs:17](../../../src/OKF4net.Cli/Program.cs#L17)) et rien dans le CLI +ne lit `Console.In` aujourd'hui. Or la forme `-` de §5.1 — la ligne qui referme +la boucle — en dépend, et les tests pilotent le CLI **en processus** via +`TestPaths.Run` : sans seam, le chemin stdin ne serait testable qu'en lançant un +sous-processus, ce que la suite ne fait nulle part. + +Décision : ajouter un paramètre `TextReader stdin` à `OkfCli.Run`, câblé à +`Console.In` par `Program.Main` et à un `StringReader` par les tests. C'est un +changement de signature d'une API publique (`OkfCli.Run` est le point d'entrée +unique, documenté comme tel) : il casse tout appelant externe, doit figurer au +CHANGELOG comme rupture, et `TestPaths.Run` gagne une surcharge pour que les +~60 appels existants restent inchangés. + +Alternative écartée : lire `Console.In` directement dans `CmdVerify`. Moins de +surface remuée, mais le chemin le plus important de la feature deviendrait le +seul non couvert par la suite — exactement le trou que la spec d'audit a payé +cher ailleurs. + **Hors périmètre, consigné au ROADMAP** (voir §10 pour les raisons) : l'audit conscient du temps (exposer les estampilles dans `AuditFinding` pour demander « human-reviewed, mais depuis quand ? ») ; toute GitHub Action ; `--remove` ; @@ -139,9 +180,18 @@ l'écriture. - **Dernière estampille par acteur — ni journal, ni état.** Si `verified` contient déjà une entrée dont `by` est **textuellement identique** (comparaison - ordinale du `Raw`) à l'acteur donné, cette entrée est réécrite **en place** - (position préservée — `YamlMapping.Insert` sait déjà le faire pour une clé - existante) ; sinon l'entrée `{ by, at }` est ajoutée en fin de liste. + ordinale du `Raw`) à l'acteur donné, cette entrée est réécrite **à sa + position** ; sinon l'entrée `{ by, at }` est ajoutée en fin de liste. + Mécaniquement : `YamlSequence` est immuable (`Items` est un + `IReadOnlyList` fixé au constructeur, + [YamlValue.cs:255-266](../../../src/OKF4net/Yaml/YamlValue.cs#L255-L266)), donc + on reconstruit une séquence en recopiant les items dans l'ordre et en + substituant celui qui correspond, puis on la repose sous la clé `verified` via + `YamlMapping.Insert` — qui remplace en place et **préserve la position de la + clé** dans le frontmatter + ([YamlMapping.cs:59-74](../../../src/OKF4net/Yaml/YamlMapping.cs#L59-L74)). + Deux niveaux, deux mécanismes : ne pas confondre la position de l'estampille + dans la séquence avec celle de `verified` dans le mapping. L'écrivain ne touche **jamais** l'entrée d'un autre acteur : un `process:` ne peut pas dégrader une relecture humaine en la remplaçant. Pourquoi pas un journal : l'émetteur YAML coûte trois lignes par estampille et le frontmatter @@ -168,11 +218,18 @@ l'écriture. insensible à la bonne formation) est rejetée à l'écriture. Strict en entrée, permissif en lecture, comme partout. - **`at` : toujours écrit.** Fourni ⇒ validé par - `BundleValidator.IsIso8601DateTime` (le prédicat du validateur lui-même, pour - que `verify` ne puisse jamais écrire ce que `validate` avertirait) ; absent ⇒ - `OkfTimestamp.FormatUtc(UtcNow())` via le seam d'horloge existant du writer + `BundleValidator.IsIso8601DateTime` (public, + [Validate.cs:618](../../../src/OKF4net/Validate.cs#L618) — le prédicat du + validateur lui-même, pour que `verify` ne puisse jamais écrire ce que + `validate` avertirait) ; absent ⇒ `OkfTimestamp.FormatUtc(UtcNow())` via le + seam d'horloge existant du writer ([BundleConceptWriter.cs:81](../../../src/OKF4net/BundleConceptWriter.cs#L81)), donc épinglable en test. + **Élargissement de contrat à acter** : la doc de ce seam dit aujourd'hui + « consulté uniquement quand `AutoStampGenerated` est vrai ». `RecordVerification` + le consultera indépendamment de ce flag — c'est voulu (une seule horloge dans + le writer, épinglée une seule fois en test), mais le commentaire XML doit être + corrigé dans le même changement, sinon il ment. - **`generated` n'est jamais touché.** Ni écrit, ni rafraîchi : une relecture n'est pas une génération, et la rafraîchir maquillerait la question « le contenu a-t-il bougé depuis ? » (§1.1). @@ -209,10 +266,12 @@ existants. **Tout-ou-rien** : les ids sont tous validés (existence, bonne forme avant la première écriture ; un id inconnu fait échouer la commande entière sans rien écrire. -**Pourquoi pas de forme groupée** : `verify` et `validate` diffèrent de deux -lettres et signifient l'inverse (conformité machine / endossement humain). Un -`okf verify monbundle` mal tapé doit échouer bruyamment (`error: missing -`) plutôt que faire quelque chose de plausible. Et une forme `--all` +**Pourquoi pas de forme groupée** : `verify` et `validate` partagent leur +préfixe, s'autocomplètent l'un vers l'autre et signifient l'inverse (conformité +machine / endossement humain), alors que les deux prennent un bundle en premier +argument. Un `okf verify monbundle` — frappe erronée de `validate`, ou complétion +malheureuse — doit donc échouer bruyamment (`error: missing `) +plutôt que faire quelque chose de plausible sur tout le corpus. Et une forme `--all` est le geste exact de la promotion de masse : lancée une fois à l'onboarding, elle vide la worklist pour toujours et ressemble à un succès. @@ -346,6 +405,24 @@ public string Verify( 21. Schéma : `conceptIds` et `by` requis, `at` optionnel — épinglé comme pour `okf_audit`. +### 7.4 Unité 0 — les prérequis CLI + +Numérotés à la suite bien que la tâche vienne en premier : la numérotation sert +à référencer un cas depuis le plan, pas à ordonner le travail. + +22. `CliArgs` : plusieurs positionnels rendus **dans l'ordre** ; un seul ⇒ la + liste a un élément et `Positional(what)` continue de rendre le premier + (aucun des huit verbes existants ne change de comportement) ; aucun ⇒ + `Positional` lève toujours `missing `. +23. `CliArgs` : les tokens après `--` entrent dans la liste des positionnels et + **jamais** dans les flags — la règle établie le 2026-08-22 vaut aussi pour + les positionnels au-delà du premier. Cas : `verify b -- --by` traite + `--by` comme un id, pas comme un flag. +24. `OkfCli.Run` : le `TextReader` injecté est bien la source de la forme `-` + (un `StringReader` en test produit les mêmes estampilles qu'une liste d'ids + explicites), et un verbe qui ne lit pas stdin n'y touche jamais — aucune + lecture bloquante introduite sur les huit verbes existants. + ## 8. Documentation - README : le verbe (avec l'enchaînement `audit | verify` en exemple), la ligne @@ -353,7 +430,9 @@ public string Verify( preuve » : ce que l'estampille garantit, ce qu'elle ne garantit pas, le fait qu'`okf_write_concept` peut aussi en écrire une, et le mécanisme recommandé (l'estampille dans le diff relu, jamais inférée d'une approbation). -- CHANGELOG sous `Unreleased`. +- CHANGELOG sous `Unreleased`, avec **une entrée de rupture** pour la signature + de `OkfCli.Run` (§3.1) : `OKF4net.Cli` est publié, et le point d'entrée gagne + un paramètre. - Site (`web/`) : ligne dans les deux tables de verbes + chapitre docs/Cli, avec sortie réelle capturée ; tables de tools (12e tool) dans les README Agents et Mcp + pages du site. From 4ddf3f00da747eccbc763bb940dc75bd887b767a Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 13:38:34 +0200 Subject: [PATCH 03/27] docs(verify): implementation plan, in six tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six tasks, each ending on an independently testable deliverable: the two CLI prerequisites the spec review surfaced (ordered positionals, a stdin seam), RecordVerification in the core, the verb, its golden, the agent tool, then the documentation. Self-review fixed three internal inconsistencies before the plan left my hands, all of the same family — code that cited an API it could not reach, or contradicted its own tests: - VerificationOutcome had no `At`, yet both consumers needed the timestamp actually written: OkfTimestamp is internal to OKF4net, so neither the CLI nor the Agents assembly can format one. The record now reports it; the agent tool passes `at` straight through. - CmdVerify validated --by before resolving the ids, which contradicted its own Theory: `verify ` with no id must say "missing ", not complain about --by. Values are now read first (so an unvalued flag still names itself) and validated after the ids. - Task 2's dry-run printed a timestamp it would never write. The plan also records the one deliberate behaviour change it forces: with several positionals, "the token after -- wins" stops making sense, so -- becomes POSIX's "end of options" and keeps what came before it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- .../plans/2026-08-28-okf-verify.md | 1314 +++++++++++++++++ 1 file changed, 1314 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-okf-verify.md diff --git a/docs/superpowers/plans/2026-08-28-okf-verify.md b/docs/superpowers/plans/2026-08-28-okf-verify.md new file mode 100644 index 00000000..642aa637 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-okf-verify.md @@ -0,0 +1,1314 @@ +# `okf verify` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enregistrer une relecture — une estampille datée `{ by, at }` dans le champ `verified` d'un concept — pour que la worklist d'`okf audit` ait enfin une sortie. + +**Architecture:** Un écrivain gouverné unique dans le cœur (`BundleConceptWriter.RecordVerification`, read-modify-write atomique sur le frontmatter), consommé par un verbe CLI et par un tool agent mutateur. Deux prérequis d'infrastructure CLI (positionnels multiples, seam stdin) précèdent le tout. + +**Tech Stack:** C# / net10.0, xunit, zéro dépendance tierce, Native AOT pour le CLI. + +**Spec:** [docs/superpowers/specs/2026-08-28-okf-verify-design.md](../specs/2026-08-28-okf-verify-design.md) + +## Global Constraints + +- **Zéro dépendance tierce** dans `src/OKF4net/` et `src/OKF4net.Cli/` : BCL uniquement, aucun `PackageReference` ajouté. +- Tout nouveau fichier commence par `// SPDX-License-Identifier: LGPL-3.0-or-later`. +- Namespaces file-scoped, XML doc sur toute API publique, nullable activé, `TreatWarningsAsErrors` — un warning casse le build. +- **Ne jamais modifier un fichier existant sous `tests/fixtures/`.** Les goldens neufs sont écrits à la main, en LF, avec leur provenance documentée dans `tests/fixtures/README.md`. +- **Errors-as-data** : aucune exception pour un cas attendu (concept absent, acteur mal formé, date invalide). Le CLI traduit en `error: …` + code 1. +- Aucune sortie existante ne change, à **une exception assumée** (Task 0, règle du `--`), qui a son entrée de CHANGELOG et son test. +- Baseline avant de commencer : `dotnet test OKF4net.sln` = **1055 tests, 0 échec**. Vérifier avant la Task 0. +- `dotnet format OKF4net.sln` avant le dernier commit (la CI lance `--verify-no-changes`). + +## Écart assumé par rapport à la spec + +La spec §4.1 donne `RecordVerification` rendant `string?` (null = succès). Le plan +rend à la place un **`VerificationOutcome`** structuré. Raison : les deux +consommateurs ont des besoins différents — le CLI doit formater sa propre ligne +et connaître l'horodatage remplacé, le tool agent veut un message prêt à rendre. +Renvoyer une chaîne obligerait le CLI à renifler le préfixe `Error: ` pour +décider de son code retour, exactement le genre de couplage par convention de +chaîne qu'on évite ailleurs. Errors-as-data est préservé : le type ne lève pas. + +## Structure des fichiers + +| Fichier | Rôle | Task | +|---|---|---| +| `src/OKF4net.Cli/OkfCli.cs` | `CliArgs` : positionnels multiples ; `Run` : paramètre stdin | 0 | +| `src/OKF4net.Cli/Program.cs` | câble `Console.In` | 0 | +| `tests/OKF4net.Tests/TestPaths.cs` | surcharge `Run` avec stdin | 0 | +| `src/OKF4net/BundleConceptWriter.cs` | `RecordVerification`, `UpsertStamp`, `BuildConformantContent` | 1 | +| `tests/OKF4net.Tests/RecordVerificationTests.cs` (créé) | tests du cœur | 1 | +| `src/OKF4net.Cli/OkfCli.cs` | `Usage`, dispatch, `CmdVerify` | 2 | +| `tests/OKF4net.Tests/CliTests.cs` | tests CLI | 2 | +| `tests/fixtures/golden/verify.out` (créé) | golden de sortie | 3 | +| `tests/OKF4net.Tests/GoldenParityTests.cs`, `tests/fixtures/README.md` | parité + provenance | 3 | +| `src/OKF4net.Agents/OkfBundleTools.cs` | tool `okf_verify`, `WriteToolNames` | 4 | +| `tests/OKF4net.Tests/Agents/*`, `tests/OKF4net.Tests/Mcp/*` | tests tool + MCP | 4 | +| `README.md`, `CHANGELOG.md`, `CLAUDE.md`, `ROADMAP.md`, `web/src/pages/**` | documentation | 5 | + +--- + +### Task 0: Les deux prérequis du CLI + +**Files:** +- Modify: `src/OKF4net.Cli/OkfCli.cs` (classe `CliArgs`, signature `Run`) +- Modify: `src/OKF4net.Cli/Program.cs` +- Modify: `tests/OKF4net.Tests/TestPaths.cs` +- Test: `tests/OKF4net.Tests/CliTests.cs` + +**Interfaces:** +- Produces: `CliArgs.Positionals` (`IReadOnlyList`, ordonnée) à côté de `Positional(string what)` inchangé ; `OkfCli.Run(string[] args, TextReader stdin, TextWriter stdout, TextWriter stderr)` ; `TestPaths.RunWithStdin(string stdin, params string[] args)`. + +**Changement de comportement assumé.** Avec un seul positionnel, la règle était +« le token après `--` gagne le créneau ». Avec une liste, cette règle n'a plus de +sens (lequel gagne ?), et elle perdrait le bundle sur `okf verify b -- id1 id2`. +La règle devient donc celle de POSIX : `--` **termine la lecture des options**, +les positionnels qui le précèdent sont conservés, ceux qui le suivent s'ajoutent. +Seul cas divergent : `okf a -- b` rendait `b` comme positionnel, rendra +`a`. Aucun test existant ne couvre ce cas (vérifié : les deux tests du séparateur +n'ont pas de positionnel avant `--`), d'où le test 3 ci-dessous et l'entrée de +CHANGELOG en Task 5. + +- [ ] **Step 1: Écrire les tests qui échouent** + +Ajouter à `tests/OKF4net.Tests/CliTests.cs` : + +```csharp + /// + /// `--` ends option parsing; it does not discard the positionals that came + /// before it. With a single positional slot the old rule ("the token after + /// the separator wins") was indistinguishable from this one; with a verb + /// that takes several, it would silently drop the bundle. + /// + [Fact] + public void Separator_keeps_positionals_from_both_sides() + { + var r = Run("audit", V02BundlePath, "--", "--json"); + + // The bundle before `--` is still the positional; `--json` after it is + // an argument, not a flag, so the output is the text report. + Assert.Equal(0, r.Code); + Assert.StartsWith($"bundle: {V02BundlePath}", r.Out); + Assert.DoesNotContain("\"conceptCount\"", r.Out); + } + + /// + /// The CLI reads standard input only through the reader handed to + /// OkfCli.Run, so a test can drive the `-` form in-process instead + /// of spawning a subprocess. + /// + [Fact] + public void Run_reads_ids_from_the_injected_stdin() + { + // `fmt` is the simplest verb with a positional; passing the path via + // stdin is not supported, so this asserts the plumbing only: a reader + // is accepted and the verb that ignores it behaves unchanged. + var r = TestPaths.RunWithStdin("ignored\n", "fmt", Path.Combine(BundlePath, "tables", "users.md")); + + Assert.Equal(0, r.Code); + Assert.Contains("title: Orders", r.Out); + } +``` + +- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent** + +Run: `dotnet test OKF4net.sln --filter "FullyQualifiedName~CliTests.Separator_keeps|FullyQualifiedName~CliTests.Run_reads_ids"` +Expected: le premier ÉCHOUE (le séparateur écrase encore), le second ne compile pas (`RunWithStdin` n'existe pas). + +- [ ] **Step 3: Positionnels multiples dans `CliArgs`** + +Dans `src/OKF4net.Cli/OkfCli.cs`, remplacer le champ unique et sa lecture : + +```csharp + /// + /// The positional tokens, in order. `--` ends option parsing without + /// discarding what came before it, so a verb taking several positionals + /// (`verify …`) keeps them all. + /// + private readonly List _positionals = []; +``` + +Dans `Scan`, la branche du séparateur devient un ajout, non un écrasement : + +```csharp + if (token == "--") + { + // Everything past the separator is positional, never a flag. + // It APPENDS: the tokens before it are positionals too. + for (var j = i + 1; j < args.Length; j++) + { + scanned._positionals.Add(args[j]); + } + + break; + } +``` + +et la branche positionnelle ordinaire : + +```csharp + scanned._positionals.Add(token); +``` + +Enfin les deux accesseurs : + +```csharp + /// The first positional argument, or throws naming . + internal string Positional(string what) => + _positionals.Count > 0 ? _positionals[0] : throw new CliOperationException($"missing {what}"); + + /// Every positional argument, in order — the first is what returns. + internal IReadOnlyList Positionals => _positionals; +``` + +- [ ] **Step 4: Seam stdin sur `Run`** + +Dans `OkfCli.cs`, la signature et le passage aux verbes : + +```csharp + public static int Run(string[] args, TextReader stdin, TextWriter stdout, TextWriter stderr) +``` + +Mettre à jour le commentaire XML de `Run` : ajouter un `` +disant que seuls les verbes le documentant le lisent (aujourd'hui `verify`), et +que les autres ne le touchent jamais — aucune lecture bloquante n'est +introduite. Dans le `switch`, seul `CmdVerify` (Task 2) recevra `stdin` ; les +sept autres appels restent inchangés. + +Dans `src/OKF4net.Cli/Program.cs` : + +```csharp + return OkfCli.Run(args, Console.In, Console.Out, Console.Error); +``` + +Dans `tests/OKF4net.Tests/TestPaths.cs`, garder `Run` intact pour les ~60 +appels existants et ajouter la variante : + +```csharp + /// + /// Runs the CLI in-process like , with + /// as its standard input — for the verbs that read ids from a pipe. + /// + internal static (int Code, string Out, string Err) RunWithStdin(string stdin, params string[] args) + { + var o = new StringWriter(); + var e = new StringWriter(); + return (OkfCli.Run(args, new StringReader(stdin), o, e), o.ToString(), e.ToString()); + } +``` + +et faire passer `Run` par `TextReader.Null` : + +```csharp + return (OkfCli.Run(args, TextReader.Null, o, e), o.ToString(), e.ToString()); +``` + +- [ ] **Step 5: Lancer la suite complète** + +Run: `dotnet test OKF4net.sln` +Expected: 1055 + 2 nouveaux, 0 échec. **Aucun golden ne bouge** : la règle du `--` ne change que le cas `a -- b`, qu'aucun golden n'exerce. + +- [ ] **Step 6: Commit** + +```bash +git add src/OKF4net.Cli/OkfCli.cs src/OKF4net.Cli/Program.cs tests/OKF4net.Tests/TestPaths.cs tests/OKF4net.Tests/CliTests.cs +git commit -m "refactor(cli): ordered positionals and a stdin seam" +``` + +--- + +### Task 1: Le cœur — `RecordVerification` + +**Files:** +- Modify: `src/OKF4net/BundleConceptWriter.cs` +- Test: `tests/OKF4net.Tests/RecordVerificationTests.cs` (créé) + +**Interfaces:** +- Consumes: `ValidateConceptTarget`, `_bundleLock`, `WriteValidatedContentLocked`, `RunTool`, `UtcNow`, `OkfEncodings.Strict`, `OkfTimestamp.FormatUtc`, `BundleValidator.IsIso8601DateTime`, `OkfDocument.Parse`/`ValidateConformance`/`Serialize`, `Frontmatter.AsMapping`, `Actor.Parse`, `YamlMapping.Insert/Get`, `YamlSequence.Items`, `YamlString`. +- Produces: `VerificationOutcome(bool Recorded, string Message, string? ReplacedAt)` et `BundleConceptWriter.RecordVerification(string conceptId, string by, string? at = null) → VerificationOutcome`. + +- [ ] **Step 1: Écrire les tests qui échouent** + +Créer `tests/OKF4net.Tests/RecordVerificationTests.cs` : + +```csharp +// SPDX-License-Identifier: LGPL-3.0-or-later +namespace OKF4net.Tests; + +/// +/// Tests for : the single +/// governed writer of the §5.2 verified field. Every test pins the +/// clock through the writer's own UtcNow seam so no assertion depends +/// on the day the suite runs. +/// +public class RecordVerificationTests +{ + private const string Fm = "---\ntype: Metric\ntitle: Daily Active Users\n"; + + private static BundleConceptWriter WriterOver(TempDir tmp) => + new(tmp.Path) { UtcNow = () => new DateTime(2026, 8, 28, 9, 14, 0, DateTimeKind.Utc) }; + + private static string Read(TempDir tmp, string rel) => File.ReadAllText(Path.Combine(tmp.Path, rel)); + + [Fact] + public void First_stamp_creates_the_list_and_leaves_everything_else_alone() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "custom_key: kept\n---\n\n# Body\n"); + + var outcome = WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + + Assert.True(outcome.Recorded); + Assert.Null(outcome.ReplacedAt); + + var text = Read(tmp, "metrics/dau.md"); + Assert.Contains("by: human:ada", text); + Assert.Contains("at: 2026-08-28T09:14:00Z", text); + Assert.Contains("custom_key: kept", text); + Assert.Contains("# Body", text); + } + + [Fact] + public void Same_actor_replaces_its_own_stamp_in_place() + { + using var tmp = new TempDir(); + tmp.Write( + "metrics/dau.md", + Fm + "verified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n" + + " - { by: process:nightly, at: 2026-02-02T00:00:00Z }\n---\n\nbody\n"); + + var outcome = WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + + Assert.True(outcome.Recorded); + Assert.Equal("2026-01-01T00:00:00Z", outcome.ReplacedAt); + + var doc = OkfDocument.Parse(Read(tmp, "metrics/dau.md")); + var stamps = doc.Frontmatter.Verified; + Assert.Equal(2, stamps.Count); + // Position preserved: ada stays first, nightly untouched. + Assert.Equal("human:ada", stamps[0].By!.Value.Raw); + Assert.Equal("2026-08-28T09:14:00Z", stamps[0].At); + Assert.Equal("process:nightly", stamps[1].By!.Value.Raw); + Assert.Equal("2026-02-02T00:00:00Z", stamps[1].At); + } + + [Fact] + public void A_different_actor_is_appended_and_never_touches_another_entry() + { + using var tmp = new TempDir(); + tmp.Write( + "metrics/dau.md", + Fm + "verified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n---\n\nbody\n"); + + WriterOver(tmp).RecordVerification("metrics/dau", "process:nightly"); + + var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; + Assert.Equal(2, stamps.Count); + Assert.Equal("human:ada", stamps[0].By!.Value.Raw); + Assert.Equal("2026-01-01T00:00:00Z", stamps[0].At); + Assert.Equal("process:nightly", stamps[1].By!.Value.Raw); + } + + /// + /// A permissive reader accepts duplicate entries for one actor (§5.2 says + /// nothing about uniqueness), so the writer replaces the FIRST match only + /// and never deletes an entry it is not replacing. + /// + [Fact] + public void Only_the_first_duplicate_of_an_actor_is_replaced() + { + using var tmp = new TempDir(); + tmp.Write( + "metrics/dau.md", + Fm + "verified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n" + + " - { by: human:ada, at: 2026-02-02T00:00:00Z }\n---\n\nbody\n"); + + WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + + var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; + Assert.Equal(2, stamps.Count); + Assert.Equal("2026-08-28T09:14:00Z", stamps[0].At); + Assert.Equal("2026-02-02T00:00:00Z", stamps[1].At); + } + + /// + /// `verified: { by, at }` — a single mapping rather than a list — is a + /// shape accepts (Trust.cs:32), so the + /// writer must normalize it instead of throwing or overwriting it. + /// + [Fact] + public void A_single_mapping_verified_is_normalized_to_a_list() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "verified: { by: process:nightly, at: 2026-01-01T00:00:00Z }\n---\n\nbody\n"); + + WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + + var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; + Assert.Equal(2, stamps.Count); + Assert.Equal("process:nightly", stamps[0].By!.Value.Raw); + Assert.Equal("human:ada", stamps[1].By!.Value.Raw); + } + + [Theory] + [InlineData("human:", "not a well-formed")] + [InlineData("", "not a well-formed")] + public void A_malformed_actor_is_refused(string by, string expected) + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + + var outcome = WriterOver(tmp).RecordVerification("metrics/dau", by); + + Assert.False(outcome.Recorded); + Assert.Contains(expected, outcome.Message); + Assert.DoesNotContain("verified", Read(tmp, "metrics/dau.md")); + } + + [Fact] + public void A_non_iso_at_is_refused() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + + var outcome = WriterOver(tmp).RecordVerification("metrics/dau", "human:ada", "hier"); + + Assert.False(outcome.Recorded); + Assert.Contains("ISO-8601", outcome.Message); + } + + [Fact] + public void An_unknown_concept_is_refused_without_creating_it() + { + using var tmp = new TempDir(); + + var outcome = WriterOver(tmp).RecordVerification("metrics/nope", "human:ada"); + + Assert.False(outcome.Recorded); + Assert.Contains("does not exist", outcome.Message); + Assert.False(File.Exists(Path.Combine(tmp.Path, "metrics", "nope.md"))); + } + + /// + /// Conformance-level validation (§11, non-empty type), NOT producer-grade: + /// refusing to record a human's review because a third party omitted a + /// `description` would make exactly the concepts the worklist surfaces + /// unstampable. See the design spec §4.2. + /// + [Fact] + public void A_concept_missing_description_is_still_stampable() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + + var outcome = WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + + Assert.True(outcome.Recorded); + Assert.Contains("by: human:ada", Read(tmp, "metrics/dau.md")); + } + + [Fact] + public void A_concept_without_type_is_refused() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntitle: No type\n---\n\nbody\n"); + + var outcome = WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + + Assert.False(outcome.Recorded); + Assert.Contains("type", outcome.Message); + } + + [Fact] + public void Generated_is_never_written_or_refreshed() + { + using var tmp = new TempDir(); + tmp.Write("a.md", Fm + "generated: { by: okf4net/0.3.0, at: 2020-01-01T00:00:00Z }\n---\n\nbody\n"); + tmp.Write("b.md", Fm + "---\n\nbody\n"); + + var writer = WriterOver(tmp); + writer.RecordVerification("a", "human:ada"); + writer.RecordVerification("b", "human:ada"); + + Assert.Contains("at: 2020-01-01T00:00:00Z", Read(tmp, "a.md")); + Assert.DoesNotContain("generated", Read(tmp, "b.md")); + } + + /// The tier okf audit reads moves as a direct consequence. + [Fact] + public void The_trust_tier_moves_after_a_stamp() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + var writer = WriterOver(tmp); + + Assert.Equal(TrustTier.Unverified, Bundle.Load(tmp.Path).Concepts[0].Document.Frontmatter.TrustTier); + + writer.RecordVerification("metrics/dau", "process:nightly"); + Assert.Equal(TrustTier.MachineConfirmed, Bundle.Load(tmp.Path).Concepts[0].Document.Frontmatter.TrustTier); + + writer.RecordVerification("metrics/dau", "human:ada"); + Assert.Equal(TrustTier.HumanReviewed, Bundle.Load(tmp.Path).Concepts[0].Document.Frontmatter.TrustTier); + } + + /// + /// Two verifications of the same concept must not lose a stamp: the read, + /// the transform and the write all happen inside one hold of the writer's + /// bundle lock. + /// + [Fact] + public void Concurrent_verifications_of_one_concept_both_land() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + var writer = WriterOver(tmp); + + Parallel.Invoke( + () => writer.RecordVerification("metrics/dau", "human:ada"), + () => writer.RecordVerification("metrics/dau", "process:nightly")); + + var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; + Assert.Equal(2, stamps.Count); + } +} +``` + +- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent** + +Run: `dotnet test OKF4net.sln --filter "FullyQualifiedName~RecordVerificationTests"` +Expected: échec de compilation — `RecordVerification` et `VerificationOutcome` n'existent pas. + +- [ ] **Step 3: Implémenter** + +Dans `src/OKF4net/BundleConceptWriter.cs`, ajouter le type de résultat au-dessus de la classe : + +```csharp +/// +/// The outcome of : +/// errors-as-data, never thrown. carries the +/// timestamp of the actor's previous stamp when this one replaced it, so a +/// caller can show that a review superseded an earlier one. +/// +/// Whether the stamp was written. +/// A confirmation, or the reason nothing was written. +/// +/// The timestamp actually written. Callers outside this assembly cannot format +/// one themselves — OkfTimestamp is internal — so the writer reports the +/// value it used rather than leaving them to guess it. +/// +/// The superseded at, or null when the stamp is new. +public readonly record struct VerificationOutcome(bool Recorded, string Message, string At, string? ReplacedAt); +``` + +Puis, dans la classe, la méthode et ses deux aides privées : + +```csharp + /// + /// Records a review: adds — or replaces, in place — the { by, at } + /// entry of in the concept's §5.2 verified + /// list, preserving every other frontmatter key and the body. The read, + /// the edit and the write happen inside one hold of the bundle lock. + /// + /// A stamp is a dated declaration, not an authentication result: this + /// method cannot and does not check that the caller is who + /// names. What makes a stamp credible is where it + /// lands — a reviewed diff — not the tool that wrote it. + /// + /// The concept id (path without .md). Must already exist. + /// The §7 actor recording the review; must be well-formed. + /// ISO-8601 timestamp; null uses . + public VerificationOutcome RecordVerification(string conceptId, string by, string? at = null) + { + if (string.IsNullOrWhiteSpace(conceptId)) + { + return new VerificationOutcome(false, "Error: invalid concept id — it must not be empty.", string.Empty, null); + } + + if (conceptId.Contains('\0')) + { + return new VerificationOutcome(false, "Error: invalid concept id — it must not contain a null character.", string.Empty, null); + } + + // Strict on input, permissive on read: `human:` with no id promotes the + // tier (Actor.IsHuman ignores well-formedness), so it must never be + // written here even though a parser would accept it. + if (by is null || !Actor.Parse(by).IsWellFormed) + { + return new VerificationOutcome(false, $"Error: '{by}' is not a well-formed §7 actor.", string.Empty, null); + } + + var stampedAt = at ?? OkfTimestamp.FormatUtc(UtcNow()); + if (!BundleValidator.IsIso8601DateTime(stampedAt)) + { + return new VerificationOutcome(false, $"Error: '{stampedAt}' is not an ISO-8601 timestamp.", stampedAt, null); + } + + string? replacedAt = null; + var result = RunTool(() => + { + var targetError = ValidateConceptTarget(conceptId, out var target); + if (targetError is not null) + { + return targetError; + } + + lock (_bundleLock) + { + if (!File.Exists(target.TargetPath)) + { + return $"Error: concept '{conceptId}' does not exist."; + } + + var text = OkfEncodings.Strict.GetString(File.ReadAllBytes(target.TargetPath)); + var document = OkfDocument.Parse(text); + var map = document.Frontmatter.AsMapping(); + + map.Insert("verified", UpsertStamp(map.Get("verified"), by, stampedAt, out replacedAt)); + + var (content, buildError) = BuildConformantContent(map, document.Body); + if (buildError is not null) + { + return buildError; + } + + return WriteValidatedContentLocked(target.Id, target.TargetPath, content!, existedBefore: true); + } + }); + + return result.StartsWith("Error:", StringComparison.Ordinal) + ? new VerificationOutcome(false, result, stampedAt, null) + : new VerificationOutcome(true, $"Recorded {conceptId} verified by {by} at {stampedAt}.", stampedAt, replacedAt); + } + + /// + /// Returns the verified sequence with 's stamp + /// added, or replaced at its existing position. + /// is immutable, so the list is rebuilt; only the FIRST entry matching the + /// actor is replaced — a permissive reader accepts duplicates, and this + /// writer never deletes an entry it is not replacing. + /// + private static YamlSequence UpsertStamp(YamlValue? existing, string by, string at, out string? replacedAt) + { + replacedAt = null; + + var items = existing switch + { + YamlSequence sequence => new List(sequence.Items), + // `verified: { by, at }` — a bare mapping — is a shape ParseVerified + // accepts, so normalize it into the list rather than discarding it. + YamlMapping single => [single], + _ => [], + }; + + var stamp = new YamlMapping(); + stamp.Insert("by", new YamlString(by)); + stamp.Insert("at", new YamlString(at)); + + for (var i = 0; i < items.Count; i++) + { + if (items[i] is YamlMapping mapping + && string.Equals(mapping.Get("by")?.AsDisplayString(), by, StringComparison.Ordinal)) + { + replacedAt = mapping.Get("at")?.AsDisplayString(); + items[i] = stamp; + return new YamlSequence(items); + } + } + + items.Add(stamp); + return new YamlSequence(items); + } + + /// + /// Serializes after §11 conformance validation only (non-empty type), + /// unlike 's + /// producer-grade check. Deliberate: recording a review is not producing + /// content, and refusing a reviewer because a third party omitted a + /// description would make precisely the concepts an audit surfaces + /// unstampable. Throws , caught by + /// the caller's wrapper. + /// + private static (string? Content, string? Error) BuildConformantContent(YamlMapping frontmatter, string body) + { + var document = new OkfDocument(Frontmatter.FromMapping(frontmatter), body); + document.ValidateConformance(); + return (document.Serialize(), null); + } +``` + +Enfin, corriger le commentaire XML du seam d'horloge, qui devient faux : + +```csharp + /// + /// Clock seam for the generated auto-stamp and for + /// 's at; overridable in tests. + /// + internal Func UtcNow { get; set; } = () => DateTime.UtcNow; +``` + +- [ ] **Step 4: Lancer les tests** + +Run: `dotnet test OKF4net.sln --filter "FullyQualifiedName~RecordVerificationTests"` puis la suite complète. +Expected: PASS — 13 méthodes (14 cas, la `[Theory]` en comptant deux). + +- [ ] **Step 5: Commit** + +```bash +git add src/OKF4net/BundleConceptWriter.cs tests/OKF4net.Tests/RecordVerificationTests.cs +git commit -m "feat(core): RecordVerification, the governed writer of verified" +``` + +--- + +### Task 2: Le verbe CLI `okf verify` + +**Files:** +- Modify: `src/OKF4net.Cli/OkfCli.cs` +- Test: `tests/OKF4net.Tests/CliTests.cs` + +**Interfaces:** +- Consumes: Task 0 (`CliArgs.Positionals`, `Run(…, TextReader stdin, …)`), Task 1 (`RecordVerification`, `VerificationOutcome`). +- Produces: le verbe et son format de sortie, que la Task 3 fige en golden. + +- [ ] **Step 1: Écrire les tests qui échouent** + +Ajouter à `tests/OKF4net.Tests/CliTests.cs` : + +```csharp + private static string NewBundleWithTwoConcepts(TempDir tmp) + { + tmp.Write("metrics/dau.md", "---\ntype: Metric\ntitle: DAU\n---\n\nbody\n"); + tmp.Write("metrics/rev.md", "---\ntype: Metric\ntitle: Revenue\n---\n\nbody\n"); + return tmp.Path; + } + + [Fact] + public void Verify_records_a_stamp_on_each_named_concept() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + + var r = Run("verify", bundle, "metrics/dau", "metrics/rev", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); + + Assert.Equal(0, r.Code); + Assert.Equal( + "recorded metrics/dau human:ada 2026-08-28T09:14:00Z\n" + + "recorded metrics/rev human:ada 2026-08-28T09:14:00Z\n", + r.Out); + Assert.Contains("by: human:ada", File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + + [Fact] + public void Verify_reports_the_timestamp_it_superseded() + { + using var tmp = new TempDir(); + tmp.Write( + "metrics/dau.md", + "---\ntype: Metric\nverified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n---\n\nbody\n"); + + var r = Run("verify", tmp.Path, "metrics/dau", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); + + Assert.Equal(0, r.Code); + Assert.Equal( + "recorded metrics/dau human:ada 2026-08-28T09:14:00Z (replaces 2026-01-01T00:00:00Z)\n", + r.Out); + } + + /// The line that closes the loop: audit's ids piped into verify. + [Fact] + public void Verify_reads_ids_from_stdin_when_the_id_is_a_dash() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + + var r = TestPaths.RunWithStdin( + "metrics/dau\n\nmetrics/rev\n", + "verify", bundle, "-", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); + + Assert.Equal(0, r.Code); + // The blank line is ignored, both concepts are stamped, order preserved. + Assert.Equal( + "recorded metrics/dau human:ada 2026-08-28T09:14:00Z\n" + + "recorded metrics/rev human:ada 2026-08-28T09:14:00Z\n", + r.Out); + } + + /// + /// All-or-nothing: every id is resolved before anything is written, so one + /// unknown id leaves the whole bundle untouched. + /// + [Fact] + public void Verify_writes_nothing_when_one_id_is_unknown() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run("verify", bundle, "metrics/dau", "metrics/nope", "--by", "human:ada"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: unknown concept \"metrics/nope\"\n", r.Err); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + + [Fact] + public void Verify_dry_run_writes_nothing() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run("verify", bundle, "metrics/dau", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z", "--dry-run"); + + Assert.Equal(0, r.Code); + Assert.Equal("would record metrics/dau human:ada 2026-08-28T09:14:00Z\n", r.Out); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + + [Theory] + [InlineData(new[] { "verify", "BUNDLE" }, "error: missing \n")] + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau" }, "error: verify requires --by \n")] + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:" }, "error: --by is not a well-formed §7 actor: \"human:\"\n")] + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "hier" }, "error: --at is not ISO-8601: \"hier\"\n")] + public void Verify_rejects_bad_invocations(string[] args, string expected) + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var resolved = args.Select(a => a == "BUNDLE" ? bundle : a).ToArray(); + + var r = Run(resolved); + + Assert.Equal(1, r.Code); + Assert.Equal(expected, r.Err); + } + + [Fact] + public void Verify_refuses_to_mix_stdin_with_explicit_ids() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + + var r = Run("verify", bundle, "-", "metrics/dau", "--by", "human:ada"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: \"-\" (stdin) cannot be combined with explicit concept ids\n", r.Err); + } + + /// The loop, end to end: audit lists it, verify clears it. + [Fact] + public void Audit_then_verify_removes_the_concept_from_the_unverified_worklist() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + + var before = Run("audit", tmp.Path, "--trust", "unverified"); + Assert.Contains("metrics/dau", before.Out); + + Run("verify", tmp.Path, "metrics/dau", "--by", "human:ada"); + + var after = Run("audit", tmp.Path, "--trust", "unverified"); + Assert.Equal("", after.Out); + } + + [Fact] + public void Help_lists_verify_after_audit() + { + var r = Run("--help"); + + var lines = r.Out.Split('\n').Select(l => l.TrimStart()).ToList(); + var auditIndex = lines.FindIndex(l => l.StartsWith("audit ", StringComparison.Ordinal)); + var verifyIndex = lines.FindIndex(l => l.StartsWith("verify ", StringComparison.Ordinal)); + + Assert.True(auditIndex >= 0 && verifyIndex == auditIndex + 1); + } +``` + +- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent** + +Run: `dotnet test OKF4net.sln --filter "FullyQualifiedName~CliTests.Verify|FullyQualifiedName~CliTests.Audit_then_verify"` +Expected: `unknown subcommand: verify` sur chaque cas. + +- [ ] **Step 3: Implémenter le verbe** + +Dans `src/OKF4net.Cli/OkfCli.cs` — ligne d'usage, **juste après `audit`** : + +```csharp + " verify … Record a review of one or more concepts (--by )\n" + +``` + +Bloc OPTIONS, après la ligne `--as-of` : + +```csharp + " --by Who is recording the review, for `verify` (required)\n" + + " --dry-run Show what `verify` would record, write nothing\n" + +``` + +Commentaire de classe : « Eight subcommands » devient neuf, en citant `verify`. + +Dispatch, après `"audit"` : + +```csharp + "verify" => CmdVerify(rest, stdin, stdout), +``` + +(`Run` passe `stdin` à ce seul verbe.) + +Puis la méthode : + +```csharp + /// Implements the verify subcommand. + private static int CmdVerify(string[] args, TextReader stdin, TextWriter stdout) + { + var parsed = CliArgs.Scan(args, "--by", "--at"); + + // Both values are READ first, so a flag present without a value names + // itself ("--by requires a value") rather than surfacing later as a + // missing argument. They are VALIDATED after the ids, so that the most + // structural mistake — no concept named at all — is reported first. + var by = parsed.Value("--by"); + var at = parsed.Value("--at"); + + var positionals = parsed.Positionals; + var path = positionals.Count > 0 ? positionals[0] : throw new CliOperationException("missing "); + var ids = positionals.Skip(1).ToList(); + if (ids.Count == 0) + { + throw new CliOperationException("missing "); + } + + if (ids.Contains("-")) + { + if (ids.Count > 1) + { + throw new CliOperationException("\"-\" (stdin) cannot be combined with explicit concept ids"); + } + + ids = ReadIdsFrom(stdin); + if (ids.Count == 0) + { + throw new CliOperationException("no concept ids on standard input"); + } + } + + // Validated only now: an invocation naming no concept at all is the + // more structural mistake, and its message must come first. + if (by is null) + { + throw new CliOperationException("verify requires --by "); + } + + if (!Actor.Parse(by).IsWellFormed) + { + throw new CliOperationException($"--by is not a well-formed §7 actor: \"{by}\""); + } + + if (at is not null && !BundleValidator.IsIso8601DateTime(at)) + { + throw new CliOperationException($"--at is not ISO-8601: \"{at}\""); + } + + var bundle = Load(path); + + // All-or-nothing: every id is resolved against the loaded bundle before + // anything is written, so one typo cannot leave a half-stamped bundle. + foreach (var id in ids) + { + if (!ConceptId.TryParse(id, out var parsedId) || bundle.Get(parsedId!) is null) + { + throw new CliOperationException($"unknown concept \"{id}\""); + } + } + + var writer = new BundleConceptWriter(path); + + if (parsed.Has("--dry-run")) + { + // The CLI cannot format a timestamp itself (OkfTimestamp is + // internal to OKF4net) and must not invent one it will not write, + // so an unpinned dry run says so rather than showing a fake date. + foreach (var id in ids) + { + stdout.Write($"would record {id} {by} {at ?? "(now)"}\n"); + } + + return 0; + } + + foreach (var id in ids) + { + var outcome = writer.RecordVerification(id, by, at); + if (!outcome.Recorded) + { + throw new CliOperationException(outcome.Message.Replace("Error: ", string.Empty, StringComparison.Ordinal)); + } + + // outcome.At is the timestamp the writer actually used — the CLI + // reports it rather than recomputing one that could differ. + var replaces = outcome.ReplacedAt is { } previous ? $" (replaces {previous})" : string.Empty; + stdout.Write($"recorded {id} {by} {outcome.At}{replaces}\n"); + } + + return 0; + } + + /// Reads concept ids from , one per line, ignoring blank lines. + private static List ReadIdsFrom(TextReader stdin) + { + var ids = new List(); + while (stdin.ReadLine() is { } line) + { + var trimmed = line.Trim(); + if (trimmed.Length > 0) + { + ids.Add(trimmed); + } + } + + return ids; + } +``` + +**Pourquoi le CLI ne calcule jamais l'horodatage.** `OkfTimestamp` est +`internal` à `OKF4net` : cet assembly ne peut pas en formater un. C'est la +raison d'être du champ `At` de `VerificationOutcome` (Task 1) — le writer +rapporte la valeur qu'il a écrite, le CLI l'affiche. En `--dry-run`, rien n'est +écrit, donc rien n'est à rapporter : la sortie montre `(now)` plutôt qu'une date +inventée que la vraie exécution ne produirait pas forcément. + +- [ ] **Step 4: Lancer les tests** + +Run: `dotnet test OKF4net.sln` +Expected: PASS. Aucun golden existant ne bouge. + +- [ ] **Step 5: Commit** + +```bash +git add src/OKF4net.Cli/OkfCli.cs tests/OKF4net.Tests/CliTests.cs +git commit -m "feat(cli): add the okf verify verb" +``` + +--- + +### Task 3: Le golden + +**Files:** +- Create: `tests/fixtures/golden/verify.out` +- Modify: `tests/OKF4net.Tests/GoldenParityTests.cs`, `tests/fixtures/README.md` + +**Interfaces:** +- Consumes: Task 2 (le format de sortie). + +**Rappel de règle** : aucune fixture existante n'est modifiée. Le bundle de +travail est une **copie temporaire** de `tests/fixtures/okf_v02` (`verify` +écrit, il ne peut donc pas viser une fixture en place). Le golden est **écrit à +la main**, vérifié contre le format de la spec §5.2, jamais capturé d'un binaire +de référence — `verify` n'existe pas en amont. + +- [ ] **Step 1: Écrire le golden** + +`tests/fixtures/golden/verify.out`, fins de ligne **LF** : + +``` +recorded metrics/dau human:ada 2026-08-28T09:14:00Z +recorded metrics/legacy human:ada 2026-08-28T09:14:00Z +``` + +- [ ] **Step 2: Écrire le test de parité** + +Ajouter à `tests/OKF4net.Tests/GoldenParityTests.cs` : + +```csharp + /// + /// `verify` writes, so it runs against a throwaway copy of the v0.2 fixture + /// rather than the fixture itself. The golden is hand-authored and verified + /// against the design spec's output format — there is no upstream `verify` + /// to capture. The date is pinned with --at so it cannot drift. + /// + [Fact] + public void Verify_output_matches_golden() + { + using var tmp = new TempDir(); + CopyDirectory(Path.Combine(TestPaths.RepoRoot(), "tests", "fixtures", "okf_v02"), tmp.Path); + + var r = Run("verify", tmp.Path, "metrics/dau", "metrics/legacy", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); + + Assert.Equal(0, r.Code); + // Concept ids only — always '/'-normalized — so no separator + // normalization is needed on any platform. + Assert.Equal(Golden("verify.out"), r.Out); + } +``` + +- [ ] **Step 3: Documenter la provenance** + +Dans `tests/fixtures/README.md`, à la liste des goldens : + +```markdown +- `golden/verify.out` — output of `okf verify metrics/dau + metrics/legacy --by human:ada --at 2026-08-28T09:14:00Z`. **Hand-authored**, + verified against the design spec's stated output format rather than captured + from a reference CLI: `verify` is an OKF4net verb with no upstream + counterpart. The bundle is a throwaway copy because the verb writes. +``` + +- [ ] **Step 4: Lancer les tests** + +Run: `dotnet test OKF4net.sln --filter "FullyQualifiedName~GoldenParityTests"` +Expected: PASS. **En cas d'écart, corriger le code, jamais le golden** — sauf si l'écart révèle une erreur de ce plan, auquel cas corriger le golden ET le dire dans le message de commit. + +- [ ] **Step 5: Commit** + +```bash +git add tests/fixtures/golden/verify.out tests/OKF4net.Tests/GoldenParityTests.cs tests/fixtures/README.md +git commit -m "test(verify): pin the verb's output with a golden" +``` + +--- + +### Task 4: Le tool agent `okf_verify` + +**Files:** +- Modify: `src/OKF4net.Agents/OkfBundleTools.cs` +- Test: `tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs` (créé), `tests/OKF4net.Tests/Agents/OkfBundleToolsTests.cs`, `tests/OKF4net.Tests/Agents/AIFunctionExposureTests.cs`, `tests/OKF4net.Tests/Mcp/OkfMcpServerTests.cs` + +**Interfaces:** +- Consumes: Task 1 (`RecordVerification`, `VerificationOutcome`). +- Produces: le tool `okf_verify`, **mutateur** (dans `WriteToolNames`). + +**Fallout attendu** : ajouter un 12e tool casse les tests qui figent le nombre et +l'ordre (`AIFunctionExposureTests`, `OkfBundleToolsTests`, `OkfMcpServerTests`) et +fait passer `WriteToolNames` de trois à quatre entrées. Ajuster les comptes sans +affaiblir une seule assertion (garder les égalités exactes, ne pas les +transformer en `Contains`). + +- [ ] **Step 1: Écrire les tests qui échouent** + +Créer `tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs` : + +```csharp +// SPDX-License-Identifier: LGPL-3.0-or-later +using Microsoft.Extensions.AI; +using OKF4net.Agents; + +namespace OKF4net.Tests.Agents; + +/// +/// Tests for okf_verify. The tool is symmetric with the CLI verb — same +/// actors accepted, `human:` included — a deliberate decision: a stamp is a +/// declaration, and its credibility comes from landing in a reviewed diff, not +/// from the tool that wrote it. Being a mutator, it belongs to +/// and disappears from a read-only +/// deployment. +/// +public class OkfVerifyToolTests +{ + private static OkfBundleTools ToolsOver(TempDir tmp) => + new(tmp.Path) { UtcNow = () => new DateTime(2026, 8, 28, 9, 14, 0, DateTimeKind.Utc) }; + + [Fact] + public void Verify_records_a_stamp() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + + var text = ToolsOver(tmp).Verify("metrics/dau", "human:ada"); + + Assert.Contains("Recorded metrics/dau", text); + Assert.Contains("by: human:ada", File.ReadAllText(Path.Combine(tmp.Path, "metrics", "dau.md"))); + } + + [Fact] + public void Verify_is_registered_and_is_a_write_tool() + { + using var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + + Assert.Contains("okf_verify", ToolsOver(tmp).GetTools().OfType().Select(t => t.Name)); + Assert.Contains("okf_verify", OkfBundleTools.WriteToolNames); + } + + [Fact] + public void Verify_returns_a_usage_message_for_a_malformed_actor() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + + Assert.Contains("Usage: okf_verify", ToolsOver(tmp).Verify("metrics/dau", "human:")); + } + + [Fact] + public void Verify_reports_an_unknown_concept_without_writing() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + + var text = ToolsOver(tmp).Verify("metrics/nope", "human:ada"); + + Assert.Contains("does not exist", text); + Assert.False(File.Exists(Path.Combine(tmp.Path, "metrics", "nope.md"))); + } + + [Fact] + public void Verify_stamps_every_id_in_a_comma_separated_list() + { + using var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + tmp.Write("b.md", "---\ntype: Metric\n---\n\nbody\n"); + + var text = ToolsOver(tmp).Verify("a, b", "human:ada"); + + Assert.Contains("Recorded a", text); + Assert.Contains("Recorded b", text); + } +} +``` + +- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent** + +Run: `dotnet test OKF4net.sln --filter "FullyQualifiedName~OkfVerifyToolTests"` +Expected: échec de compilation — `Verify` n'existe pas. + +- [ ] **Step 3: Implémenter le tool** + +Dans `src/OKF4net.Agents/OkfBundleTools.cs` — la constante d'usage, à côté des autres : + +```csharp + private const string VerifyUsageMessage = + "Usage: okf_verify records a review — comma-separated concept ids, plus a well-formed " + + "§7 actor (human:, agent:/, process:). Example: " + + "okf_verify(\"metrics/dau, metrics/revenue\", \"human:ada\")."; +``` + +`okf_verify` dans `WriteToolNames` : + +```csharp + "okf_verify", +``` + +La méthode : + +```csharp + /// + /// Records a review of one or more concepts: adds — or replaces — the + /// caller's { by, at } entry in each concept's verified list. + /// A stamp is a dated declaration, not a proof: this tool cannot check that + /// the caller is who names, exactly like the CLI verb. + /// + /// Comma-separated concept ids; each must already exist. + /// The §7 actor recording the review. + /// ISO-8601 timestamp; omit for now. + [Description("Record a review of one or more concepts: adds or replaces the caller's { by, at } entry in each concept's `verified` list. The stamp is a dated declaration, not a proof — the same rules as the okf verify CLI verb.")] + public string Verify( + [Description("Comma-separated concept ids (paths without .md). Explicit ids only — there is no whole-bundle form.")] string conceptIds, + [Description("The §7 actor recording the review, e.g. human:ada, agent:assistant/1.0, process:nightly.")] string by, + [Description("ISO-8601 UTC timestamp; omit for now.")] string? at = null) + { + var ids = (conceptIds ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + if (ids.Count == 0 || by is null || !Actor.Parse(by).IsWellFormed) + { + return VerifyUsageMessage; + } + + return RunTool(() => + { + var lines = new StringBuilder(); + + // `at` is passed through untouched, null included: the writer owns + // the clock seam (OkfTimestamp is internal to OKF4net, so this + // assembly could not format a timestamp anyway) and reports the one + // it used. The tool invents no date. + foreach (var id in ids) + { + var outcome = _writer.RecordVerification(id, by, at); + lines.Append(outcome.Message).Append('\n'); + } + + InvalidateBundle(); + return lines.ToString(); + }); + } +``` + +Enregistrer dans `GetTools()`, après `okf_write_concept` : + +```csharp + AIFunctionFactory.Create(Verify, "okf_verify"), +``` + +- [ ] **Step 4: Réparer le fallout et lancer les tests** + +Ajuster les comptes dans `AIFunctionExposureTests` (11 → 12, liste ordonnée), +`OkfBundleToolsTests` (`WriteToolNames` : 3 → 4 entrées, sous-ensemble read-only +8 → 8 — `okf_verify` étant mutateur, il **n'entre pas** dans le read-only), +`OkfMcpServerTests` (total 11 → 12, read-only inchangé). Ajouter un test +d'invocation MCP `okf_verify` via `CallToolAsync`, sur le modèle du test +`okf_audit` existant. + +Run: `dotnet test OKF4net.sln` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/OKF4net.Agents/OkfBundleTools.cs tests/OKF4net.Tests/Agents tests/OKF4net.Tests/Mcp +git commit -m "feat(agents): expose okf_verify as a write tool" +``` + +--- + +### Task 5: Documentation + +**Files:** +- Modify: `README.md`, `CHANGELOG.md`, `CLAUDE.md`, `ROADMAP.md`, `web/src/pages/Cli.tsx`, `web/src/pages/Home.tsx`, `web/src/pages/docs/Cli.tsx`, `web/src/pages/Library.tsx`, `web/src/pages/docs/Library.tsx`, `src/OKF4net.Agents/README.md`, `src/OKF4net.Mcp/README.md` + +- [ ] **Step 1: README** + +Ajouter `verify` à la liste des verbes (après `audit`), une section montrant la +boucle complète (`okf audit … | cut -d' ' -f1 | okf verify … -`), la ligne du +tableau §5.2 → `RecordVerification`, et **l'encadré d'honnêteté** : ce que +l'estampille garantit (bien formée, datée, sur les concepts nommés), ce qu'elle +ne garantit pas (l'identité du signataire, qu'il ait lu), le fait +qu'`okf_write_concept` peut aussi en écrire une, et le mécanisme recommandé — +l'estampille **dans** le diff relu, jamais inférée d'une approbation. + +- [ ] **Step 2: CHANGELOG** — sous `Unreleased` : + +`### Added` : le verbe et le tool. `### Changed` : **deux ruptures** — la +signature de `OkfCli.Run` (paramètre `TextReader`), et la règle du `--` qui +conserve désormais les positionnels antérieurs (`okf a -- b` rend `a`, non +plus `b`). + +- [ ] **Step 3: CLAUDE.md et ROADMAP.md** + +`CLAUDE.md` : ajouter `verify` à la liste des verbes, et une ligne disant que +`RecordVerification` est l'écrivain gouverné unique de `verified` — ne pas en +forker un second. `ROADMAP.md` : `okf verify` livré ; et l'**audit conscient du +temps** comme suite immédiate (exposer les estampilles dans `AuditFinding` pour +demander « human-reviewed, mais depuis quand, et le contenu a-t-il bougé ? », +la question se répondant par `git log -1 -- ` contre `max(verified[].at)`). + +- [ ] **Step 4: Site** + +Verbe dans les deux tables (`Home.tsx`, `Cli.tsx`), chapitre dans +`docs/Cli.tsx` avec **sortie réellement capturée** (lancer la commande, ne pas +l'inventer), ligne `RecordVerification` dans les deux pages bibliothèque, et +tables de tools (12e tool) dans `src/OKF4net.Agents/README.md`, +`src/OKF4net.Mcp/README.md` et les pages correspondantes. + +- [ ] **Step 5: Vérifier et committer** + +```bash +dotnet format OKF4net.sln +dotnet test OKF4net.sln +cd web && npm run typecheck && npm run test && npm run build +``` + +```bash +git add README.md CHANGELOG.md CLAUDE.md ROADMAP.md web/ src/OKF4net.Agents/README.md src/OKF4net.Mcp/README.md +git commit -m "docs(verify): document the verb, the tool and what a stamp does not prove" +``` From f43f1a9ca81cff84b49113654b2c858da917d7bc Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 13:54:53 +0200 Subject: [PATCH 04/27] docs(verify): fix the plan against an independent audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent pass over the plan, run against the code rather than the prose, found three blocking defects. All three reproduced before fixing: - A lone `-` is swallowed as a flag: CliArgs' flag branch is `token.StartsWith('-')`, which `"-"` matches, so it never reaches the positional list. `okf fmt -` today answers `error: missing `. The stdin form — the line that closes the audit loop — was therefore unreachable, and both its tests would have asserted the wrong thing. Guarded on `token.Length > 1`; third CHANGELOG entry. - The golden did not match what the plan's own code emits: okf_v02/metrics/dau.md already carries a human:ada stamp, so verifying it takes the replace path and the line gains "(replaces …)". Corrected — and the golden is better for it, pinning both paths at once. - A test asserted `title: Orders` while formatting users.md, whose title is Users. Two "high" findings were about promises the code did not keep: the agent tool looped over ids writing as it went, so a bad third id left the first two stamped, contradicting the spec's "all-or-nothing"; and the CLI's pre-flight checked existence only, while a document with no `type` loads fine and is refused at write time. Both now resolve every id before the first write, with a test each. The audit also demolished a rationale I had written three times: OkfTimestamp is NOT out of reach — OKF4net grants InternalsVisibleTo to both `okf` and OKF4net.Agents. The `At` field survives on its real merit (one clock, held by the writer that tests pin), not on a false constraint. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- .../plans/2026-08-28-okf-verify.md | 243 +++++++++++++++--- 1 file changed, 201 insertions(+), 42 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-okf-verify.md b/docs/superpowers/plans/2026-08-28-okf-verify.md index 642aa637..5868ccef 100644 --- a/docs/superpowers/plans/2026-08-28-okf-verify.md +++ b/docs/superpowers/plans/2026-08-28-okf-verify.md @@ -15,7 +15,7 @@ - **Zéro dépendance tierce** dans `src/OKF4net/` et `src/OKF4net.Cli/` : BCL uniquement, aucun `PackageReference` ajouté. - Tout nouveau fichier commence par `// SPDX-License-Identifier: LGPL-3.0-or-later`. - Namespaces file-scoped, XML doc sur toute API publique, nullable activé, `TreatWarningsAsErrors` — un warning casse le build. -- **Ne jamais modifier un fichier existant sous `tests/fixtures/`.** Les goldens neufs sont écrits à la main, en LF, avec leur provenance documentée dans `tests/fixtures/README.md`. +- **Ne jamais modifier un bundle de fixtures ni un golden existant** sous `tests/fixtures/`. Les goldens neufs sont écrits à la main, en LF avec LF final, avec leur provenance documentée dans `tests/fixtures/README.md` — ce fichier-là, qui est de la documentation, se modifie normalement. - **Errors-as-data** : aucune exception pour un cas attendu (concept absent, acteur mal formé, date invalide). Le CLI traduit en `error: …` + code 1. - Aucune sortie existante ne change, à **une exception assumée** (Task 0, règle du `--`), qui a son entrée de CHANGELOG et son test. - Baseline avant de commencer : `dotnet test OKF4net.sln` = **1055 tests, 0 échec**. Vérifier avant la Task 0. @@ -67,9 +67,12 @@ sens (lequel gagne ?), et elle perdrait le bundle sur `okf verify b -- id1 id2`. La règle devient donc celle de POSIX : `--` **termine la lecture des options**, les positionnels qui le précèdent sont conservés, ceux qui le suivent s'ajoutent. Seul cas divergent : `okf a -- b` rendait `b` comme positionnel, rendra -`a`. Aucun test existant ne couvre ce cas (vérifié : les deux tests du séparateur -n'ont pas de positionnel avant `--`), d'où le test 3 ci-dessous et l'entrée de -CHANGELOG en Task 5. +`a`. Trois tests du séparateur existent (`CliTests.cs:745`, `:762`, `:782`) et +aucun ne change de résultat sous la nouvelle règle — le troisième a bien un +positionnel avant `--`, mais rien après, donc les deux règles coïncident. En +revanche, la doc XML de ce troisième test décrit une distinction que ce +changement efface (« clear the positionals » contre « only override ») : la +réécrire dans la même tâche, sinon elle explique un comportement disparu. - [ ] **Step 1: Écrire les tests qui échouent** @@ -95,23 +98,40 @@ Ajouter à `tests/OKF4net.Tests/CliTests.cs` : } /// - /// The CLI reads standard input only through the reader handed to - /// OkfCli.Run, so a test can drive the `-` form in-process instead - /// of spawning a subprocess. + /// A verb that does not document reading standard input must never touch + /// it — otherwise `okf fmt file` inside a pipeline would block on a reader + /// nobody is feeding. A StringReader could not prove this (it records + /// nothing), so the reader here throws if anything reads it. /// [Fact] - public void Run_reads_ids_from_the_injected_stdin() + public void A_verb_that_does_not_read_stdin_never_touches_it() { - // `fmt` is the simplest verb with a positional; passing the path via - // stdin is not supported, so this asserts the plumbing only: a reader - // is accepted and the verb that ignores it behaves unchanged. - var r = TestPaths.RunWithStdin("ignored\n", "fmt", Path.Combine(BundlePath, "tables", "users.md")); + var r = TestPaths.RunWithReader( + new ThrowingReader(), + "fmt", + Path.Combine(BundlePath, "tables", "users.md")); Assert.Equal(0, r.Code); - Assert.Contains("title: Orders", r.Out); + Assert.Contains("title: Users", r.Out); + } + + /// A reader that fails the test if the CLI reads from it at all. + private sealed class ThrowingReader : TextReader + { + public override int Peek() => throw new InvalidOperationException("stdin was read"); + + public override int Read() => throw new InvalidOperationException("stdin was read"); + + public override string? ReadLine() => throw new InvalidOperationException("stdin was read"); } ``` +Le titre asserté est `Users` : c'est le frontmatter de `tables/users.md` +([appendix_a/tables/users.md:3](../../../tests/fixtures/appendix_a/tables/users.md#L3)). + +`TestPaths` gagne donc **deux** aides : `RunWithStdin(string, params string[])` +pour le contenu, et `RunWithReader(TextReader, params string[])` pour ce test. + - [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent** Run: `dotnet test OKF4net.sln --filter "FullyQualifiedName~CliTests.Separator_keeps|FullyQualifiedName~CliTests.Run_reads_ids"` @@ -130,6 +150,23 @@ Dans `src/OKF4net.Cli/OkfCli.cs`, remplacer le champ unique et sa lecture : private readonly List _positionals = []; ``` +**Un `-` seul est un positionnel, pas un flag.** La branche des flags est +`if (token.StartsWith('-'))`, et `"-"` y entre : il est enregistré comme flag et +n'atteint jamais la liste des positionnels. Vérifié en conditions réelles — +`okf fmt -` répond aujourd'hui `error: missing `. Sans ce correctif, toute +la forme stdin de §5.1 est inatteignable : `ids.Contains("-")` serait +systématiquement faux. Le garde à ajouter, dans la même branche : + +```csharp + // A lone "-" is POSIX's "read from standard input" — an + // argument, not an option. Only a token with something after + // the dash is a flag. + if (token.Length > 1 && token.StartsWith('-')) +``` + +Aucun test existant ne passe un `-` seul (vérifié par recherche), donc rien ne +casse ; c'est une **troisième** entrée « Changed » au CHANGELOG en Task 5. + Dans `Scan`, la branche du séparateur devient un ajout, non un écrasement : ```csharp @@ -191,11 +228,19 @@ appels existants et ajouter la variante : /// Runs the CLI in-process like , with /// as its standard input — for the verbs that read ids from a pipe. /// - internal static (int Code, string Out, string Err) RunWithStdin(string stdin, params string[] args) + internal static (int Code, string Out, string Err) RunWithStdin(string stdin, params string[] args) => + RunWithReader(new StringReader(stdin), args); + + /// + /// Runs the CLI in-process with an arbitrary reader — + /// lets a test prove a verb never touches standard input by handing it one + /// that throws. + /// + internal static (int Code, string Out, string Err) RunWithReader(TextReader stdin, params string[] args) { var o = new StringWriter(); var e = new StringWriter(); - return (OkfCli.Run(args, new StringReader(stdin), o, e), o.ToString(), e.ToString()); + return (OkfCli.Run(args, stdin, o, e), o.ToString(), e.ToString()); } ``` @@ -227,7 +272,7 @@ git commit -m "refactor(cli): ordered positionals and a stdin seam" **Interfaces:** - Consumes: `ValidateConceptTarget`, `_bundleLock`, `WriteValidatedContentLocked`, `RunTool`, `UtcNow`, `OkfEncodings.Strict`, `OkfTimestamp.FormatUtc`, `BundleValidator.IsIso8601DateTime`, `OkfDocument.Parse`/`ValidateConformance`/`Serialize`, `Frontmatter.AsMapping`, `Actor.Parse`, `YamlMapping.Insert/Get`, `YamlSequence.Items`, `YamlString`. -- Produces: `VerificationOutcome(bool Recorded, string Message, string? ReplacedAt)` et `BundleConceptWriter.RecordVerification(string conceptId, string by, string? at = null) → VerificationOutcome`. +- Produces: `VerificationOutcome(bool Recorded, string Message, string At, string? ReplacedAt)` et `BundleConceptWriter.RecordVerification(string conceptId, string by, string? at = null) → VerificationOutcome`. **Les quatre membres comptent** : la Task 2 lit `At` et `ReplacedAt`, la Task 4 lit `Message`. - [ ] **Step 1: Écrire les tests qui échouent** @@ -494,9 +539,11 @@ Dans `src/OKF4net/BundleConceptWriter.cs`, ajouter le type de résultat au-dessu /// Whether the stamp was written. /// A confirmation, or the reason nothing was written. /// -/// The timestamp actually written. Callers outside this assembly cannot format -/// one themselves — OkfTimestamp is internal — so the writer reports the -/// value it used rather than leaving them to guess it. +/// The timestamp actually written. Callers could format their own — the CLI and +/// the Agents layer both see OkfTimestamp through InternalsVisibleTo +/// — but two clocks are one too many: only the writer holds the seam tests pin, +/// so it reports what it wrote instead of letting a caller compute a value that +/// could differ from the file's. /// /// The superseded at, or null when the stamp is new. public readonly record struct VerificationOutcome(bool Recorded, string Message, string At, string? ReplacedAt); @@ -753,6 +800,27 @@ Ajouter à `tests/OKF4net.Tests/CliTests.cs` : Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); } + /// + /// Existence is not enough for the pre-flight: a document with no `type` + /// loads into the bundle but is refused at write time, so without the + /// conformance check here the concepts named before it would already be + /// stamped. + /// + [Fact] + public void Verify_writes_nothing_when_one_concept_is_not_conformant() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + tmp.Write("metrics/broken.md", "---\ntitle: No type\n---\n\nbody\n"); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run("verify", bundle, "metrics/dau", "metrics/broken", "--by", "human:ada"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: concept \"metrics/broken\" has no `type` and is not §11-conformant\n", r.Err); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + [Fact] public void Verify_dry_run_writes_nothing() { @@ -911,23 +979,32 @@ Puis la méthode : var bundle = Load(path); - // All-or-nothing: every id is resolved against the loaded bundle before - // anything is written, so one typo cannot leave a half-stamped bundle. + // Every id is resolved AND checked for §11 conformance before anything + // is written. Existence alone would not be enough: Bundle indexes any + // document that parses, including one with no `type`, which + // RecordVerification then refuses at write time — so a mistyped id in + // third position would leave the first two stamped. Both checks here, + // and "all-or-nothing" is true rather than nearly true. foreach (var id in ids) { - if (!ConceptId.TryParse(id, out var parsedId) || bundle.Get(parsedId!) is null) + if (!ConceptId.TryParse(id, out var parsedId) || bundle.Get(parsedId!) is not { } concept) { throw new CliOperationException($"unknown concept \"{id}\""); } + + if (concept.Document.Frontmatter.Get("type") is not { IsEmptyValue: false }) + { + throw new CliOperationException($"concept \"{id}\" has no `type` and is not §11-conformant"); + } } var writer = new BundleConceptWriter(path); if (parsed.Has("--dry-run")) { - // The CLI cannot format a timestamp itself (OkfTimestamp is - // internal to OKF4net) and must not invent one it will not write, - // so an unpinned dry run says so rather than showing a fake date. + // A dry run writes nothing, so there is no timestamp to report. It + // could format one (OkfTimestamp is reachable here), but printing a + // date the real run would not reproduce is worse than saying "now". foreach (var id in ids) { stdout.Write($"would record {id} {by} {at ?? "(now)"}\n"); @@ -970,12 +1047,13 @@ Puis la méthode : } ``` -**Pourquoi le CLI ne calcule jamais l'horodatage.** `OkfTimestamp` est -`internal` à `OKF4net` : cet assembly ne peut pas en formater un. C'est la -raison d'être du champ `At` de `VerificationOutcome` (Task 1) — le writer -rapporte la valeur qu'il a écrite, le CLI l'affiche. En `--dry-run`, rien n'est -écrit, donc rien n'est à rapporter : la sortie montre `(now)` plutôt qu'une date -inventée que la vraie exécution ne produirait pas forcément. +**Pourquoi le CLI ne calcule jamais l'horodatage.** Ce n'est pas qu'il ne peut +pas : `OKF4net.csproj` accorde `InternalsVisibleTo` à `okf` comme à +`OKF4net.Agents`, et `OkfCli.cs` importe déjà `OKF4net.Internal`. C'est qu'une +seconde horloge serait une horloge de trop — seul le writer porte le seam que +les tests épinglent, donc lui seul date, et il rapporte ce qu'il a écrit via +`outcome.At`. En `--dry-run`, rien n'est écrit : afficher `(now)` est plus +honnête qu'une date que la vraie exécution ne reproduirait pas. - [ ] **Step 4: Lancer les tests** @@ -1011,10 +1089,20 @@ de référence — `verify` n'existe pas en amont. `tests/fixtures/golden/verify.out`, fins de ligne **LF** : ``` -recorded metrics/dau human:ada 2026-08-28T09:14:00Z +recorded metrics/dau human:ada 2026-08-28T09:14:00Z (replaces 2026-07-03T00:00:00Z) recorded metrics/legacy human:ada 2026-08-28T09:14:00Z ``` +Les deux lignes ne sont pas identiques par accident : `metrics/dau` porte déjà +`{ by: human:ada, at: 2026-07-03T00:00:00Z }` +([okf_v02/metrics/dau.md:10](../../../tests/fixtures/okf_v02/metrics/dau.md#L10)), +donc `UpsertStamp` prend le chemin du remplacement et la ligne porte son suffixe ; +`metrics/legacy` n'a aucune estampille, donc ajout simple. Ce golden épingle les +**deux** chemins d'un coup, ce qui est mieux qu'un golden n'exerçant que l'ajout. +Le fichier doit se terminer par un LF final (le CLI écrit `\n` après la dernière +ligne) : `.editorconfig` met `insert_final_newline = unset` sous +`tests/fixtures/**`, donc aucun outil ne l'ajoutera à ta place. + - [ ] **Step 2: Écrire le test de parité** Ajouter à `tests/OKF4net.Tests/GoldenParityTests.cs` : @@ -1150,6 +1238,58 @@ public class OkfVerifyToolTests Assert.False(File.Exists(Path.Combine(tmp.Path, "metrics", "nope.md"))); } + /// + /// All-or-nothing across the whole list: one unknown id leaves every other + /// concept untouched. A single-id test cannot catch this. + /// + [Fact] + public void Verify_writes_nothing_when_one_id_of_several_is_unknown() + { + using var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + var before = File.ReadAllText(Path.Combine(tmp.Path, "a.md")); + + var text = ToolsOver(tmp).Verify("a, nope", "human:ada"); + + Assert.Contains("does not exist", text); + Assert.DoesNotContain("Recorded a", text); + Assert.Equal(before, File.ReadAllText(Path.Combine(tmp.Path, "a.md"))); + } + + /// + /// The schema is what decides what a bare call means, like okf_audit's: + /// the two ids/actor parameters required, the timestamp optional. + /// + [Fact] + public void Verify_schema_requires_ids_and_actor_but_not_at() + { + var tools = new OkfBundleTools(Path.Combine(TestPaths.RepoRoot(), "tests", "fixtures", "okf_v02")); + var function = tools.GetTools().OfType().Single(t => t.Name == "okf_verify"); + var properties = function.JsonSchema.GetProperty("properties"); + + foreach (var name in new[] { "conceptIds", "by", "at" }) + { + Assert.True(properties.TryGetProperty(name, out _), $"schema should declare '{name}'."); + } + + var required = function.JsonSchema.GetProperty("required").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains("conceptIds", required); + Assert.Contains("by", required); + Assert.DoesNotContain("at", required); + } + + /// A bundle that vanishes after construction surfaces as an error string, never an exception. + [Fact] + public void Verify_returns_an_error_string_when_the_bundle_is_gone() + { + var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + var tools = ToolsOver(tmp); + tmp.Dispose(); + + Assert.StartsWith("Error: ", tools.Verify("a", "human:ada")); + } + [Fact] public void Verify_stamps_every_id_in_a_comma_separated_list() { @@ -1216,24 +1356,38 @@ La méthode : return RunTool(() => { + // All-or-nothing, like the CLI: every id is resolved before the + // first write, so a typo in the third id cannot leave the first two + // stamped. Without this, `okf_verify("a, nope", …)` writes to `a` + // and then reports a failure — the worst of both. + var bundle = GetBundle(); + foreach (var id in ids) + { + if (!ConceptId.TryParse(id, out var parsedId) || bundle.Get(parsedId!) is null) + { + return $"Error: concept '{id}' does not exist."; + } + } + var lines = new StringBuilder(); // `at` is passed through untouched, null included: the writer owns - // the clock seam (OkfTimestamp is internal to OKF4net, so this - // assembly could not format a timestamp anyway) and reports the one - // it used. The tool invents no date. + // the clock seam and reports the timestamp it used, so the tool + // never dates anything itself. foreach (var id in ids) { - var outcome = _writer.RecordVerification(id, by, at); - lines.Append(outcome.Message).Append('\n'); + lines.Append(_writer.RecordVerification(id, by, at).Message).Append('\n'); } - InvalidateBundle(); return lines.ToString(); }); } ``` +`InvalidateBundle()` est inutile ici : `_writer` est construit avec +`onWriteCommitted: () => _bundle = null`, donc le cache est déjà purgé à chaque +écriture — `WriteConcept` ne l'appelle pas non plus. + Enregistrer dans `GetTools()`, après `okf_write_concept` : ```csharp @@ -1278,10 +1432,15 @@ l'estampille **dans** le diff relu, jamais inférée d'une approbation. - [ ] **Step 2: CHANGELOG** — sous `Unreleased` : -`### Added` : le verbe et le tool. `### Changed` : **deux ruptures** — la -signature de `OkfCli.Run` (paramètre `TextReader`), et la règle du `--` qui -conserve désormais les positionnels antérieurs (`okf a -- b` rend `a`, non -plus `b`). +`### Added` : le verbe et le tool. `### Changed` : **trois changements** — la +signature de `OkfCli.Run` (paramètre `TextReader` ; `OKF4net.Cli` n'a pas de +`PackageId` et n'est pas publié comme bibliothèque, donc pas de « casse les +appelants externes » : le seul site d'appel hors `Program.cs` est +`TestPaths.cs`) ; la règle du `--`, qui conserve désormais les positionnels +antérieurs (`okf a -- b` rend `a`, non plus `b`) ; et un `-` seul, qui +devient un argument (« lire stdin ») au lieu d'être avalé comme flag. +Mettre aussi à jour `CLAUDE.md`, qui documente encore +`OkfCli.Run(args, out, err)`. - [ ] **Step 3: CLAUDE.md et ROADMAP.md** From 1bb4bc907c8a105604315045a37c299f9da51d80 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 14:01:01 +0200 Subject: [PATCH 05/27] docs(verify): rework the plan after an external review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external pass agreed with the internal audit on the `title: Orders` slip and went further on five points. All five were verified in the code before acting. The heaviest is a design fix, not a patch. All-or-nothing was a pre-flight in each caller followed by sequential writes, so a second document that turned out unreadable still left the first stamped — and the agent tool had no pre-flight at all. The core now exposes ONE batch method, RecordVerifications, that resolves, reads, parses, validates and prepares every concept before writing any of them, under a single hold of the lock. Both consumers inherit the guarantee instead of each re-implementing half of it, and there is no single-concept variant to keep in step. The residual limit — an in-process lock, no multi-file atomic write — is documented rather than implied. The other four: - Using BundleValidator.IsIso8601DateTime as a WRITE gate was my own mistake, and the same one the spec warns about elsewhere: that predicate validates the date and ignores everything after the `T` (Validate.cs:618) because reading frontmatter is deliberately permissive. It would have written `2026-08-28` or a +02:00 offset as a stamp the field documents as UTC. Strict parse now, with the escaped 'T'/'Z' format string, plus date-only and offset cases. - The tool rendered the writer's prose while the spec asks for the CLI's exact line; both now emit `recorded `, asserted by equality rather than Contains. - The golden compared stdout only, so a run printing the right line and writing the wrong stamp stayed green. A second golden pins the written file. - The core preservation test checked substrings; it now compares the key list, the body and the parsed stamp. The `generated` test could not fail at all — AutoStampGenerated defaults to false — so it now also exercises the configuration OkfBundleTools actually uses. Also fixed while there: an AIFunction.InvokeAsync assertion written in flow style when the emitter writes block style, and a Task 0 red step that could never be observed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- .../plans/2026-08-28-okf-verify.md | 359 +++++++++++++----- 1 file changed, 259 insertions(+), 100 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-okf-verify.md b/docs/superpowers/plans/2026-08-28-okf-verify.md index 5868ccef..0a58324a 100644 --- a/docs/superpowers/plans/2026-08-28-okf-verify.md +++ b/docs/superpowers/plans/2026-08-28-okf-verify.md @@ -135,7 +135,15 @@ pour le contenu, et `RunWithReader(TextReader, params string[])` pour ce test. - [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent** Run: `dotnet test OKF4net.sln --filter "FullyQualifiedName~CliTests.Separator_keeps|FullyQualifiedName~CliTests.Run_reads_ids"` -Expected: le premier ÉCHOUE (le séparateur écrase encore), le second ne compile pas (`RunWithStdin` n'existe pas). +**Écris et lance le test du séparateur SEUL d'abord** : il doit échouer, et +c'est la seule preuve que la régression existe avant le correctif. Les deux +autres tests utilisent `RunWithReader`, qui n'existe pas encore — les ajouter +maintenant empêcherait la compilation de tout le projet de tests, donc le +premier ne s'exécuterait jamais et on ne verrait rien échouer. Les ajouter +après le Step 4. + +Expected (test du séparateur seul) : ÉCHEC — la sortie est le JSON, parce que +`--json` placé après `--` est encore honoré comme flag. - [ ] **Step 3: Positionnels multiples dans `CliArgs`** @@ -271,8 +279,24 @@ git commit -m "refactor(cli): ordered positionals and a stdin seam" - Test: `tests/OKF4net.Tests/RecordVerificationTests.cs` (créé) **Interfaces:** -- Consumes: `ValidateConceptTarget`, `_bundleLock`, `WriteValidatedContentLocked`, `RunTool`, `UtcNow`, `OkfEncodings.Strict`, `OkfTimestamp.FormatUtc`, `BundleValidator.IsIso8601DateTime`, `OkfDocument.Parse`/`ValidateConformance`/`Serialize`, `Frontmatter.AsMapping`, `Actor.Parse`, `YamlMapping.Insert/Get`, `YamlSequence.Items`, `YamlString`. -- Produces: `VerificationOutcome(bool Recorded, string Message, string At, string? ReplacedAt)` et `BundleConceptWriter.RecordVerification(string conceptId, string by, string? at = null) → VerificationOutcome`. **Les quatre membres comptent** : la Task 2 lit `At` et `ReplacedAt`, la Task 4 lit `Message`. +- Consumes: `ValidateConceptTarget`, `_bundleLock`, `WriteValidatedContentLocked`, `RunTool`, `UtcNow`, `OkfEncodings.Strict`, `OkfTimestamp.FormatUtc`, `OkfDocument.Parse`/`ValidateConformance`/`Serialize`, `Frontmatter.AsMapping`, `Actor.Parse`, `YamlMapping.Insert/Get`, `YamlSequence.Items`, `YamlString`. +- Produces: `VerificationRecord(string ConceptId, string At, string? ReplacedAt)`, `VerificationOutcome(bool Recorded, string Message, IReadOnlyList Records)` et **une seule** méthode publique : `BundleConceptWriter.RecordVerifications(IReadOnlyList conceptIds, string by, string? at = null) → VerificationOutcome`. + +**Pourquoi une opération de lot, et pas une par concept.** Une boucle +d'écritures unitaires n'est pas tout-ou-rien : un second document non conforme, +illisible ou disparu laisse le premier déjà estampillé. Prévalider dans +l'appelant ne suffit pas non plus — la fenêtre entre le contrôle et l'écriture +reste ouverte, et il faudrait la refermer dans le CLI *et* dans le tool, deux +fois. Le lot résout, lit, parse, valide et **prépare le contenu de tous les +concepts** avant d'en écrire un seul, le tout sous une seule détention du +verrou. Les deux consommateurs appellent la même méthode et héritent de la +garantie ; il n'y a pas de version « un seul concept » à maintenir en parallèle +(un id unique est une liste de un). + +Limite à documenter, pas à cacher : le verrou est un verrou C# in-process, et +.NET n'offre pas d'écriture multi-fichiers atomique. Un acteur externe qui +modifie le bundle pendant le lot n'est pas arrêté — même modèle de menace, déjà +documenté, que la garde reparse-point du writer. - [ ] **Step 1: Écrire les tests qui échouent** @@ -303,16 +327,22 @@ public class RecordVerificationTests using var tmp = new TempDir(); tmp.Write("metrics/dau.md", Fm + "custom_key: kept\n---\n\n# Body\n"); - var outcome = WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); Assert.True(outcome.Recorded); - Assert.Null(outcome.ReplacedAt); - - var text = Read(tmp, "metrics/dau.md"); - Assert.Contains("by: human:ada", text); - Assert.Contains("at: 2026-08-28T09:14:00Z", text); - Assert.Contains("custom_key: kept", text); - Assert.Contains("# Body", text); + Assert.Null(outcome.Records.Single().ReplacedAt); + + // Substring checks would miss a dropped key or a mangled body, so the + // whole document is compared: the frontmatter is exactly the original + // keys in order plus `verified`, and the body is untouched. + var after = OkfDocument.Parse(Read(tmp, "metrics/dau.md")); + Assert.Equal(["type", "title", "custom_key", "verified"], after.Frontmatter.AsMapping().Keys); + Assert.Equal("kept", after.Frontmatter.Get("custom_key")!.AsDisplayString()); + Assert.Equal("# Body\n", after.Body); + + var stamp = Assert.Single(after.Frontmatter.Verified); + Assert.Equal("human:ada", stamp.By!.Value.Raw); + Assert.Equal("2026-08-28T09:14:00Z", stamp.At); } [Fact] @@ -324,10 +354,10 @@ public class RecordVerificationTests Fm + "verified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n" + " - { by: process:nightly, at: 2026-02-02T00:00:00Z }\n---\n\nbody\n"); - var outcome = WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); Assert.True(outcome.Recorded); - Assert.Equal("2026-01-01T00:00:00Z", outcome.ReplacedAt); + Assert.Equal("2026-01-01T00:00:00Z", outcome.Records.Single().ReplacedAt); var doc = OkfDocument.Parse(Read(tmp, "metrics/dau.md")); var stamps = doc.Frontmatter.Verified; @@ -347,7 +377,7 @@ public class RecordVerificationTests "metrics/dau.md", Fm + "verified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n---\n\nbody\n"); - WriterOver(tmp).RecordVerification("metrics/dau", "process:nightly"); + WriterOver(tmp).RecordVerifications(["metrics/dau"], "process:nightly"); var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; Assert.Equal(2, stamps.Count); @@ -370,7 +400,7 @@ public class RecordVerificationTests Fm + "verified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n" + " - { by: human:ada, at: 2026-02-02T00:00:00Z }\n---\n\nbody\n"); - WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; Assert.Equal(2, stamps.Count); @@ -389,7 +419,7 @@ public class RecordVerificationTests using var tmp = new TempDir(); tmp.Write("metrics/dau.md", Fm + "verified: { by: process:nightly, at: 2026-01-01T00:00:00Z }\n---\n\nbody\n"); - WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; Assert.Equal(2, stamps.Count); @@ -405,7 +435,7 @@ public class RecordVerificationTests using var tmp = new TempDir(); tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); - var outcome = WriterOver(tmp).RecordVerification("metrics/dau", by); + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], by); Assert.False(outcome.Recorded); Assert.Contains(expected, outcome.Message); @@ -418,10 +448,10 @@ public class RecordVerificationTests using var tmp = new TempDir(); tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); - var outcome = WriterOver(tmp).RecordVerification("metrics/dau", "human:ada", "hier"); + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada", "hier"); Assert.False(outcome.Recorded); - Assert.Contains("ISO-8601", outcome.Message); + Assert.Contains("yyyy-MM-ddTHH:mm:ssZ", outcome.Message); } [Fact] @@ -429,7 +459,7 @@ public class RecordVerificationTests { using var tmp = new TempDir(); - var outcome = WriterOver(tmp).RecordVerification("metrics/nope", "human:ada"); + var outcome = WriterOver(tmp).RecordVerifications(["metrics/nope"], "human:ada"); Assert.False(outcome.Recorded); Assert.Contains("does not exist", outcome.Message); @@ -448,7 +478,7 @@ public class RecordVerificationTests using var tmp = new TempDir(); tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); - var outcome = WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); Assert.True(outcome.Recorded); Assert.Contains("by: human:ada", Read(tmp, "metrics/dau.md")); @@ -460,7 +490,7 @@ public class RecordVerificationTests using var tmp = new TempDir(); tmp.Write("metrics/dau.md", "---\ntitle: No type\n---\n\nbody\n"); - var outcome = WriterOver(tmp).RecordVerification("metrics/dau", "human:ada"); + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); Assert.False(outcome.Recorded); Assert.Contains("type", outcome.Message); @@ -473,9 +503,20 @@ public class RecordVerificationTests tmp.Write("a.md", Fm + "generated: { by: okf4net/0.3.0, at: 2020-01-01T00:00:00Z }\n---\n\nbody\n"); tmp.Write("b.md", Fm + "---\n\nbody\n"); + // AutoStampGenerated defaults to false, so a bare writer would pass this + // test even if RecordVerifications went through the auto-stamping path. + // OkfBundleTools turns it ON, which is the configuration that matters. + var stamping = new BundleConceptWriter(tmp.Path) + { + AutoStampGenerated = true, + UtcNow = () => new DateTime(2026, 8, 28, 9, 14, 0, DateTimeKind.Utc), + }; + stamping.RecordVerifications(["b"], "human:ada"); + Assert.DoesNotContain("generated", Read(tmp, "b.md")); + var writer = WriterOver(tmp); - writer.RecordVerification("a", "human:ada"); - writer.RecordVerification("b", "human:ada"); + writer.RecordVerifications(["a"], "human:ada"); + writer.RecordVerifications(["b"], "human:ada"); Assert.Contains("at: 2020-01-01T00:00:00Z", Read(tmp, "a.md")); Assert.DoesNotContain("generated", Read(tmp, "b.md")); @@ -491,10 +532,10 @@ public class RecordVerificationTests Assert.Equal(TrustTier.Unverified, Bundle.Load(tmp.Path).Concepts[0].Document.Frontmatter.TrustTier); - writer.RecordVerification("metrics/dau", "process:nightly"); + writer.RecordVerifications(["metrics/dau"], "process:nightly"); Assert.Equal(TrustTier.MachineConfirmed, Bundle.Load(tmp.Path).Concepts[0].Document.Frontmatter.TrustTier); - writer.RecordVerification("metrics/dau", "human:ada"); + writer.RecordVerifications(["metrics/dau"], "human:ada"); Assert.Equal(TrustTier.HumanReviewed, Bundle.Load(tmp.Path).Concepts[0].Document.Frontmatter.TrustTier); } @@ -511,8 +552,8 @@ public class RecordVerificationTests var writer = WriterOver(tmp); Parallel.Invoke( - () => writer.RecordVerification("metrics/dau", "human:ada"), - () => writer.RecordVerification("metrics/dau", "process:nightly")); + () => writer.RecordVerifications(["metrics/dau"], "human:ada"), + () => writer.RecordVerifications(["metrics/dau"], "process:nightly")); var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; Assert.Equal(2, stamps.Count); @@ -527,55 +568,64 @@ Expected: échec de compilation — `RecordVerification` et `VerificationOutcome - [ ] **Step 3: Implémenter** -Dans `src/OKF4net/BundleConceptWriter.cs`, ajouter le type de résultat au-dessus de la classe : +Dans `src/OKF4net/BundleConceptWriter.cs`, les deux types de résultat au-dessus de la classe : ```csharp -/// -/// The outcome of : -/// errors-as-data, never thrown. carries the -/// timestamp of the actor's previous stamp when this one replaced it, so a -/// caller can show that a review superseded an earlier one. -/// -/// Whether the stamp was written. -/// A confirmation, or the reason nothing was written. +/// One concept stamped by . +/// The concept that was stamped. /// -/// The timestamp actually written. Callers could format their own — the CLI and -/// the Agents layer both see OkfTimestamp through InternalsVisibleTo -/// — but two clocks are one too many: only the writer holds the seam tests pin, -/// so it reports what it wrote instead of letting a caller compute a value that -/// could differ from the file's. +/// The timestamp written. Callers could format their own — the CLI and the +/// Agents layer both see OkfTimestamp through InternalsVisibleTo — +/// but two clocks are one too many: only the writer holds the seam tests pin, +/// so it reports what it wrote. /// /// The superseded at, or null when the stamp is new. -public readonly record struct VerificationOutcome(bool Recorded, string Message, string At, string? ReplacedAt); +public readonly record struct VerificationRecord(string ConceptId, string At, string? ReplacedAt); + +/// +/// The outcome of : +/// errors-as-data, never thrown. All-or-nothing — when +/// is false, nothing was written and +/// is empty. +/// +/// Whether the batch was written. +/// A confirmation, or the reason nothing was written. +/// One entry per stamped concept, in the order given. +public readonly record struct VerificationOutcome(bool Recorded, string Message, IReadOnlyList Records); ``` -Puis, dans la classe, la méthode et ses deux aides privées : +Puis, dans la classe, la méthode de lot et ses aides privées : ```csharp /// - /// Records a review: adds — or replaces, in place — the { by, at } - /// entry of in the concept's §5.2 verified - /// list, preserving every other frontmatter key and the body. The read, - /// the edit and the write happen inside one hold of the bundle lock. + /// Records a review of every concept in : + /// adds — or replaces, at its position — the { by, at } entry of + /// in each concept's §5.2 verified list, + /// preserving every other frontmatter key and the body. + /// + /// All-or-nothing: every concept is resolved, read, edited and validated + /// before the first byte is written, all inside one hold of the bundle + /// lock, so a bad third id cannot leave the first two stamped. The lock is + /// an in-process one and .NET has no multi-file atomic write, so an + /// external actor mutating the bundle mid-batch is not stopped — the same + /// documented limit as this class's reparse-point guard. /// /// A stamp is a dated declaration, not an authentication result: this /// method cannot and does not check that the caller is who /// names. What makes a stamp credible is where it /// lands — a reviewed diff — not the tool that wrote it. /// - /// The concept id (path without .md). Must already exist. + /// Concept ids (paths without .md); each must already exist. /// The §7 actor recording the review; must be well-formed. - /// ISO-8601 timestamp; null uses . - public VerificationOutcome RecordVerification(string conceptId, string by, string? at = null) + /// + /// Timestamp in the library's own UTC shape (yyyy-MM-ddTHH:mm:ssZ); + /// null uses . + /// + public VerificationOutcome RecordVerifications(IReadOnlyList conceptIds, string by, string? at = null) { - if (string.IsNullOrWhiteSpace(conceptId)) + if (conceptIds is null || conceptIds.Count == 0) { - return new VerificationOutcome(false, "Error: invalid concept id — it must not be empty.", string.Empty, null); - } - - if (conceptId.Contains('\0')) - { - return new VerificationOutcome(false, "Error: invalid concept id — it must not contain a null character.", string.Empty, null); + return Failed("Error: no concept id given."); } // Strict on input, permissive on read: `human:` with no id promotes the @@ -583,50 +633,89 @@ Puis, dans la classe, la méthode et ses deux aides privées : // written here even though a parser would accept it. if (by is null || !Actor.Parse(by).IsWellFormed) { - return new VerificationOutcome(false, $"Error: '{by}' is not a well-formed §7 actor.", string.Empty, null); + return Failed($"Error: '{by}' is not a well-formed §7 actor."); } + // NOT BundleValidator.IsIso8601DateTime: that predicate validates the + // date and ignores everything after the `T` (Validate.cs:618), because + // reading frontmatter is deliberately permissive. Writing is not: a + // stamp this library produces is UTC in one exact shape, and accepting + // "2026-08-28" or a +02:00 offset here would write a value the field's + // own documentation calls UTC. var stampedAt = at ?? OkfTimestamp.FormatUtc(UtcNow()); - if (!BundleValidator.IsIso8601DateTime(stampedAt)) + if (!DateTime.TryParseExact( + stampedAt, + "yyyy-MM-dd'T'HH:mm:ss'Z'", + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out _)) { - return new VerificationOutcome(false, $"Error: '{stampedAt}' is not an ISO-8601 timestamp.", stampedAt, null); + return Failed($"Error: '{stampedAt}' is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ."); } - string? replacedAt = null; - var result = RunTool(() => + var records = new List(conceptIds.Count); + var message = RunTool(() => { - var targetError = ValidateConceptTarget(conceptId, out var target); - if (targetError is not null) + // Resolved outside the lock, like AppendToConceptAtomic does. + var targets = new List(conceptIds.Count); + foreach (var conceptId in conceptIds) { - return targetError; + var targetError = ValidateConceptTarget(conceptId, out var target); + if (targetError is not null) + { + return targetError; + } + + targets.Add(target); } lock (_bundleLock) { - if (!File.Exists(target.TargetPath)) + // PREPARE every concept — read, parse, upsert, validate — before + // writing any of them. This is what makes the batch all-or-nothing. + var prepared = new List<(ConceptTarget Target, string Content)>(targets.Count); + for (var i = 0; i < targets.Count; i++) { - return $"Error: concept '{conceptId}' does not exist."; - } + var target = targets[i]; + if (!File.Exists(target.TargetPath)) + { + return $"Error: concept '{conceptIds[i]}' does not exist."; + } + + var text = OkfEncodings.Strict.GetString(File.ReadAllBytes(target.TargetPath)); + var document = OkfDocument.Parse(text); + var map = document.Frontmatter.AsMapping(); - var text = OkfEncodings.Strict.GetString(File.ReadAllBytes(target.TargetPath)); - var document = OkfDocument.Parse(text); - var map = document.Frontmatter.AsMapping(); + map.Insert("verified", UpsertStamp(map.Get("verified"), by, stampedAt, out var replacedAt)); - map.Insert("verified", UpsertStamp(map.Get("verified"), by, stampedAt, out replacedAt)); + var (content, buildError) = BuildConformantContent(map, document.Body); + if (buildError is not null) + { + return buildError; + } - var (content, buildError) = BuildConformantContent(map, document.Body); - if (buildError is not null) + prepared.Add((target, content!)); + records.Add(new VerificationRecord(conceptIds[i], stampedAt, replacedAt)); + } + + foreach (var (target, content) in prepared) { - return buildError; + var writeResult = WriteValidatedContentLocked(target.Id, target.TargetPath, content, existedBefore: true); + if (writeResult.StartsWith("Error:", StringComparison.Ordinal)) + { + return writeResult; + } } - return WriteValidatedContentLocked(target.Id, target.TargetPath, content!, existedBefore: true); + return $"Recorded {prepared.Count} verification(s) by {by} at {stampedAt}."; } }); - return result.StartsWith("Error:", StringComparison.Ordinal) - ? new VerificationOutcome(false, result, stampedAt, null) - : new VerificationOutcome(true, $"Recorded {conceptId} verified by {by} at {stampedAt}.", stampedAt, replacedAt); + return message.StartsWith("Error:", StringComparison.Ordinal) + ? Failed(message) + : new VerificationOutcome(true, message, records); + + static VerificationOutcome Failed(string message) => new(false, message, []); } /// @@ -839,7 +928,11 @@ Ajouter à `tests/OKF4net.Tests/CliTests.cs` : [InlineData(new[] { "verify", "BUNDLE" }, "error: missing \n")] [InlineData(new[] { "verify", "BUNDLE", "metrics/dau" }, "error: verify requires --by \n")] [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:" }, "error: --by is not a well-formed §7 actor: \"human:\"\n")] - [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "hier" }, "error: --at is not ISO-8601: \"hier\"\n")] + // Three shapes a permissive reader accepts and a writer must not: garbage, + // a bare date, and a non-UTC offset. + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "hier" }, "error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"hier\"\n")] + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "2026-08-28" }, "error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"2026-08-28\"\n")] + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "2026-08-28T09:14:00+02:00" }, "error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"2026-08-28T09:14:00+02:00\"\n")] public void Verify_rejects_bad_invocations(string[] args, string expected) { using var tmp = new TempDir(); @@ -972,9 +1065,17 @@ Puis la méthode : throw new CliOperationException($"--by is not a well-formed §7 actor: \"{by}\""); } - if (at is not null && !BundleValidator.IsIso8601DateTime(at)) + // The writer applies the same strict UTC rule; checking here too turns a + // generic write error into a message naming the flag. Deliberately NOT + // BundleValidator.IsIso8601DateTime, which only validates the date part. + if (at is not null && !DateTime.TryParseExact( + at, + "yyyy-MM-dd'T'HH:mm:ss'Z'", + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out _)) { - throw new CliOperationException($"--at is not ISO-8601: \"{at}\""); + throw new CliOperationException($"--at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"{at}\""); } var bundle = Load(path); @@ -1013,18 +1114,20 @@ Puis la méthode : return 0; } - foreach (var id in ids) + // One batch call: the writer prepares every concept before writing any, + // so nothing is half-stamped if a later one turns out unwritable. + var outcome = writer.RecordVerifications(ids, by, at); + if (!outcome.Recorded) { - var outcome = writer.RecordVerification(id, by, at); - if (!outcome.Recorded) - { - throw new CliOperationException(outcome.Message.Replace("Error: ", string.Empty, StringComparison.Ordinal)); - } + throw new CliOperationException(outcome.Message.Replace("Error: ", string.Empty, StringComparison.Ordinal)); + } - // outcome.At is the timestamp the writer actually used — the CLI + foreach (var record in outcome.Records) + { + // record.At is the timestamp the writer actually used — the CLI // reports it rather than recomputing one that could differ. - var replaces = outcome.ReplacedAt is { } previous ? $" (replaces {previous})" : string.Empty; - stdout.Write($"recorded {id} {by} {outcome.At}{replaces}\n"); + var replaces = record.ReplacedAt is { } previous ? $" (replaces {previous})" : string.Empty; + stdout.Write($"recorded {record.ConceptId} {by} {record.At}{replaces}\n"); } return 0; @@ -1103,6 +1206,14 @@ Le fichier doit se terminer par un LF final (le CLI écrit `\n` après la derni ligne) : `.editorconfig` met `insert_final_newline = unset` sous `tests/fixtures/**`, donc aucun outil ne l'ajoutera à ta place. +**Second golden : `tests/fixtures/golden/verify-dau.md`**, le contenu du concept +après écriture. Ne pas l'écrire de tête : lancer la commande une fois sur une +copie, lire le fichier produit, et **vérifier à la main** que chaque ligne est +justifiée avant de la figer — l'estampille `human:ada` porte le nouvel +horodatage à sa position d'origine, `process:nightly` est intacte, `generated` +n'a pas bougé, et le reste du frontmatter comme le corps sont identiques à la +fixture d'origine. C'est ce contrôle-là qui vaut, pas la capture. + - [ ] **Step 2: Écrire le test de parité** Ajouter à `tests/OKF4net.Tests/GoldenParityTests.cs` : @@ -1126,6 +1237,11 @@ Ajouter à `tests/OKF4net.Tests/GoldenParityTests.cs` : // Concept ids only — always '/'-normalized — so no separator // normalization is needed on any platform. Assert.Equal(Golden("verify.out"), r.Out); + + // stdout alone would stay green if the verb printed the right line and + // wrote the wrong stamp, touched `generated`, or mangled the document. + // The written file is the artefact that matters, so it is pinned too. + Assert.Equal(Golden("verify-dau.md"), File.ReadAllText(Path.Combine(tmp.Path, "metrics", "dau.md"))); } ``` @@ -1203,7 +1319,9 @@ public class OkfVerifyToolTests var text = ToolsOver(tmp).Verify("metrics/dau", "human:ada"); - Assert.Contains("Recorded metrics/dau", text); + // Byte-identical to the CLI verb's line — the two renderers are + // separate on purpose, so only an exact assertion keeps them aligned. + Assert.Equal("recorded metrics/dau human:ada 2026-08-28T09:14:00Z\n", text); Assert.Contains("by: human:ada", File.ReadAllText(Path.Combine(tmp.Path, "metrics", "dau.md"))); } @@ -1252,7 +1370,7 @@ public class OkfVerifyToolTests var text = ToolsOver(tmp).Verify("a, nope", "human:ada"); Assert.Contains("does not exist", text); - Assert.DoesNotContain("Recorded a", text); + Assert.DoesNotContain("recorded a", text); Assert.Equal(before, File.ReadAllText(Path.Combine(tmp.Path, "a.md"))); } @@ -1278,6 +1396,35 @@ public class OkfVerifyToolTests Assert.DoesNotContain("at", required); } + /// + /// Invoked through the framework's own binding, not by calling the C# + /// method: the arguments arrive as JSON and must reach the parameters for + /// the stamp to land. A tool can be registered, schema-correct and still + /// unusable from a host if that binding is wrong. + /// + [Fact] + public async Task Verify_stamps_when_invoked_through_the_AIFunction_binding() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + var tools = ToolsOver(tmp); + var function = tools.GetTools().OfType().Single(t => t.Name == "okf_verify"); + + // Check AIFunction.InvokeAsync's exact overload against the installed + // Microsoft.Extensions.AI before writing this call — the argument type + // has changed across versions, and inventing a signature here is the + // failure mode this plan exists to avoid. The arguments are: + // conceptIds = "metrics/dau", by = "human:ada", at = "2026-08-28T09:14:00Z". + await function.InvokeAsync(/* the version's argument shape */); + + // The emitter writes sequences in BLOCK style — a bare `-`, then the + // mapping indented under it (verified by running `okf fmt`) — so assert + // the two lines, never a flow-style `- { by: …, at: … }`. + var text = File.ReadAllText(Path.Combine(tmp.Path, "metrics", "dau.md")); + Assert.Contains("by: human:ada", text); + Assert.Contains("at: 2026-08-28T09:14:00Z", text); + } + /// A bundle that vanishes after construction surfaces as an error string, never an exception. [Fact] public void Verify_returns_an_error_string_when_the_bundle_is_gone() @@ -1299,8 +1446,8 @@ public class OkfVerifyToolTests var text = ToolsOver(tmp).Verify("a, b", "human:ada"); - Assert.Contains("Recorded a", text); - Assert.Contains("Recorded b", text); + Assert.Contains("recorded a human:ada", text); + Assert.Contains("recorded b human:ada", text); } } ``` @@ -1369,14 +1516,26 @@ La méthode : } } - var lines = new StringBuilder(); - + // One batch call — all-or-nothing comes from the writer, so the + // pre-resolution above is only there to give a nicer message. // `at` is passed through untouched, null included: the writer owns // the clock seam and reports the timestamp it used, so the tool // never dates anything itself. - foreach (var id in ids) + var outcome = _writer.RecordVerifications(ids, by, at); + if (!outcome.Recorded) + { + return outcome.Message; + } + + // The same line shape as the CLI verb, deliberately re-implemented + // rather than shared: the CLI's bytes are golden-locked and must not + // move because an agent-facing string was tuned. The tool's tests + // assert this exact shape so the two cannot drift unnoticed. + var lines = new StringBuilder(); + foreach (var record in outcome.Records) { - lines.Append(_writer.RecordVerification(id, by, at).Message).Append('\n'); + var replaces = record.ReplacedAt is { } previous ? $" (replaces {previous})" : string.Empty; + lines.Append($"recorded {record.ConceptId} {by} {record.At}{replaces}").Append('\n'); } return lines.ToString(); From cd2b7bc51ac643b972645ba53be5f859f1557cf3 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 21:17:27 +0200 Subject: [PATCH 06/27] docs(verify): refuse a concept named twice, rather than collapsing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spotted while writing the audit brief: `okf verify b a a` prepared the same file twice from the same original content, wrote it twice, and reported two `recorded` lines for the single stamp that survives — a result that reads like two reviews. Silently deduplicating would hide a mistake in the caller's list, so it is refused instead, per the owner's call. Guarded in the writer (all callers inherit it) and again in the CLI, so the message matches its siblings: the writer's errors end with a period, the CLI's do not. Three tests — core, CLI, tool — each asserting that nothing was written. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- .../plans/2026-08-28-okf-verify.md | 76 ++++++++++++++++++- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-okf-verify.md b/docs/superpowers/plans/2026-08-28-okf-verify.md index 0a58324a..85b834a7 100644 --- a/docs/superpowers/plans/2026-08-28-okf-verify.md +++ b/docs/superpowers/plans/2026-08-28-okf-verify.md @@ -427,6 +427,26 @@ public class RecordVerificationTests Assert.Equal("human:ada", stamps[1].By!.Value.Raw); } + /// + /// A concept named twice is refused rather than collapsed: preparing the + /// same file twice from the same original content would write it twice and + /// report two lines for one surviving stamp — a result that reads like two + /// reviews. Nothing is written. + /// + [Fact] + public void A_duplicate_concept_id_is_refused() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + var before = Read(tmp, "metrics/dau.md"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau", "metrics/dau"], "human:ada"); + + Assert.False(outcome.Recorded); + Assert.Contains("named more than once", outcome.Message); + Assert.Equal(before, Read(tmp, "metrics/dau.md")); + } + [Theory] [InlineData("human:", "not a well-formed")] [InlineData("", "not a well-formed")] @@ -628,6 +648,19 @@ Puis, dans la classe, la méthode de lot et ses aides privées : return Failed("Error: no concept id given."); } + // Duplicates are refused, not silently collapsed. Preparing the same + // file twice would build both versions from the same original content + // and write it twice, reporting two `recorded` lines for the single + // stamp that survives — a result that reads like two reviews. Naming a + // concept twice is a mistake in the caller's list; say so. + var duplicate = conceptIds + .GroupBy(id => id, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + return Failed($"Error: concept '{duplicate.Key}' is named more than once."); + } + // Strict on input, permissive on read: `human:` with no id promotes the // tier (Actor.IsHuman ignores well-formedness), so it must never be // written here even though a parser would accept it. @@ -910,6 +943,20 @@ Ajouter à `tests/OKF4net.Tests/CliTests.cs` : Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); } + [Fact] + public void Verify_refuses_a_concept_named_twice() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run("verify", bundle, "metrics/dau", "metrics/dau", "--by", "human:ada"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: concept 'metrics/dau' is named more than once\n", r.Err); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + [Fact] public void Verify_dry_run_writes_nothing() { @@ -1080,12 +1127,20 @@ Puis la méthode : var bundle = Load(path); + // Refused here as well as in the writer, so the message reads like its + // siblings (the writer's ends with a period; the CLI's do not). + var duplicate = ids.GroupBy(id => id, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1); + if (duplicate is not null) + { + throw new CliOperationException($"concept '{duplicate.Key}' is named more than once"); + } + // Every id is resolved AND checked for §11 conformance before anything // is written. Existence alone would not be enough: Bundle indexes any - // document that parses, including one with no `type`, which - // RecordVerification then refuses at write time — so a mistyped id in - // third position would leave the first two stamped. Both checks here, - // and "all-or-nothing" is true rather than nearly true. + // document that parses, including one with no `type`, which the writer + // then refuses at write time — so a mistyped id in third position would + // leave the first two stamped. Both checks here, and "all-or-nothing" + // is true rather than nearly true. foreach (var id in ids) { if (!ConceptId.TryParse(id, out var parsedId) || bundle.Get(parsedId!) is not { } concept) @@ -1360,6 +1415,19 @@ public class OkfVerifyToolTests /// All-or-nothing across the whole list: one unknown id leaves every other /// concept untouched. A single-id test cannot catch this. /// + [Fact] + public void Verify_refuses_a_concept_named_twice() + { + using var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + var before = File.ReadAllText(Path.Combine(tmp.Path, "a.md")); + + var text = ToolsOver(tmp).Verify("a, a", "human:ada"); + + Assert.Contains("named more than once", text); + Assert.Equal(before, File.ReadAllText(Path.Combine(tmp.Path, "a.md"))); + } + [Fact] public void Verify_writes_nothing_when_one_id_of_several_is_unknown() { From dd69b56689ba66f055ffc96adbbc8f95bdc64e55 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 21:22:44 +0200 Subject: [PATCH 07/27] docs(verify): fix the plan and realign the spec after the external audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight findings, all verified in the code first. Four were blocking. The deepest is a claim I had no right to make. "All-or-nothing" was true of validation and false of writing: N files cannot be written atomically in .NET, so a failure on the third leaves the first two stamped — and I returned Recorded=false with an empty Records, which actively told the caller nothing had happened. The guarantee is now stated for what it is (rejected as a whole before the first byte; not a transaction), and a mid-batch write failure reports what did land, in Records and by name in the message. A caller must read Records, not just Recorded — the type says so. The other three blockers were mechanical and are the reason this pass exists: `CultureInfo`/`DateTimeStyles` used without `using System.Globalization` (not an implicit using here, as Audit.cs and Lifecycle.cs show); XML crefs still naming the singular RecordVerification, which GenerateDocumentationFile + warnings-as-errors turns into a build error; and a `/* the version's argument shape */` placeholder in the AIFunction invocation test — inexcusable, since the exact call already exists at AIFunctionExposureTests.cs:223. The spec was left describing the API and the validation of two drafts ago, so it now carries the batch contract, its honest limits, the duplicate rejection, and the strict UTC rule — with the reason the permissive IsIso8601DateTime is the wrong gate for a writer. Also: the second golden was never declared in the file inventory nor in the fixtures README provenance, and the red step's expected failure named the wrong symptom (the separator overwrites the positional, so the bundle path is lost — it does not print JSON). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- .../plans/2026-08-28-okf-verify.md | 124 +++++++++++++----- .../specs/2026-08-28-okf-verify-design.md | 62 ++++++--- 2 files changed, 135 insertions(+), 51 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-okf-verify.md b/docs/superpowers/plans/2026-08-28-okf-verify.md index 85b834a7..a3abfeb9 100644 --- a/docs/superpowers/plans/2026-08-28-okf-verify.md +++ b/docs/superpowers/plans/2026-08-28-okf-verify.md @@ -4,7 +4,7 @@ **Goal:** Enregistrer une relecture — une estampille datée `{ by, at }` dans le champ `verified` d'un concept — pour que la worklist d'`okf audit` ait enfin une sortie. -**Architecture:** Un écrivain gouverné unique dans le cœur (`BundleConceptWriter.RecordVerification`, read-modify-write atomique sur le frontmatter), consommé par un verbe CLI et par un tool agent mutateur. Deux prérequis d'infrastructure CLI (positionnels multiples, seam stdin) précèdent le tout. +**Architecture:** Un écrivain gouverné unique dans le cœur (`BundleConceptWriter.RecordVerifications`, read-modify-write atomique sur le frontmatter), consommé par un verbe CLI et par un tool agent mutateur. Deux prérequis d'infrastructure CLI (positionnels multiples, seam stdin) précèdent le tout. **Tech Stack:** C# / net10.0, xunit, zéro dépendance tierce, Native AOT pour le CLI. @@ -38,11 +38,12 @@ chaîne qu'on évite ailleurs. Errors-as-data est préservé : le type ne lève | `src/OKF4net.Cli/OkfCli.cs` | `CliArgs` : positionnels multiples ; `Run` : paramètre stdin | 0 | | `src/OKF4net.Cli/Program.cs` | câble `Console.In` | 0 | | `tests/OKF4net.Tests/TestPaths.cs` | surcharge `Run` avec stdin | 0 | -| `src/OKF4net/BundleConceptWriter.cs` | `RecordVerification`, `UpsertStamp`, `BuildConformantContent` | 1 | +| `src/OKF4net/BundleConceptWriter.cs` | `RecordVerifications`, `UpsertStamp`, `BuildConformantContent` | 1 | | `tests/OKF4net.Tests/RecordVerificationTests.cs` (créé) | tests du cœur | 1 | | `src/OKF4net.Cli/OkfCli.cs` | `Usage`, dispatch, `CmdVerify` | 2 | | `tests/OKF4net.Tests/CliTests.cs` | tests CLI | 2 | | `tests/fixtures/golden/verify.out` (créé) | golden de sortie | 3 | +| `tests/fixtures/golden/verify-dau.md` (créé) | golden du concept après écriture | 3 | | `tests/OKF4net.Tests/GoldenParityTests.cs`, `tests/fixtures/README.md` | parité + provenance | 3 | | `src/OKF4net.Agents/OkfBundleTools.cs` | tool `okf_verify`, `WriteToolNames` | 4 | | `tests/OKF4net.Tests/Agents/*`, `tests/OKF4net.Tests/Mcp/*` | tests tool + MCP | 4 | @@ -142,8 +143,12 @@ maintenant empêcherait la compilation de tout le projet de tests, donc le premier ne s'exécuterait jamais et on ne verrait rien échouer. Les ajouter après le Step 4. -Expected (test du séparateur seul) : ÉCHEC — la sortie est le JSON, parce que -`--json` placé après `--` est encore honoré comme flag. +Expected (test du séparateur seul) : ÉCHEC. Attention au **diagnostic** : avec +le scanner actuel, la branche du séparateur *écrase* le positionnel, donc +`audit -- --json` prend `--json` comme chemin de bundle et échoue au +chargement (code 1, `error:` sur stderr). Ce n'est pas « la sortie est du +JSON » — c'est la perte du bundle qui fait échouer le test, et c'est bien la +régression que le correctif supprime. - [ ] **Step 3: Positionnels multiples dans `CliArgs`** @@ -272,7 +277,7 @@ git commit -m "refactor(cli): ordered positionals and a stdin seam" --- -### Task 1: Le cœur — `RecordVerification` +### Task 1: Le cœur — `RecordVerifications` **Files:** - Modify: `src/OKF4net/BundleConceptWriter.cs` @@ -307,7 +312,7 @@ Créer `tests/OKF4net.Tests/RecordVerificationTests.cs` : namespace OKF4net.Tests; /// -/// Tests for : the single +/// Tests for : the single /// governed writer of the §5.2 verified field. Every test pins the /// clock through the writer's own UtcNow seam so no assertion depends /// on the day the suite runs. @@ -588,7 +593,16 @@ Expected: échec de compilation — `RecordVerification` et `VerificationOutcome - [ ] **Step 3: Implémenter** -Dans `src/OKF4net/BundleConceptWriter.cs`, les deux types de résultat au-dessus de la classe : +Dans `src/OKF4net/BundleConceptWriter.cs`, **ajouter d'abord l'import** — le +projet a `ImplicitUsings`, mais `System.Globalization` n'en fait pas partie +(`Audit.cs` et `Lifecycle.cs` l'importent explicitement), et le parse strict de +`at` a besoin de `CultureInfo` et `DateTimeStyles` : + +```csharp +using System.Globalization; +``` + +Puis les deux types de résultat au-dessus de la classe : ```csharp /// One concept stamped by . @@ -604,13 +618,19 @@ public readonly record struct VerificationRecord(string ConceptId, string At, st /// /// The outcome of : -/// errors-as-data, never thrown. All-or-nothing — when -/// is false, nothing was written and -/// is empty. +/// errors-as-data, never thrown. +/// +/// Read , not just . Every +/// concept is validated before the first byte is written, so a rejected batch +/// — unknown id, malformed actor, non-conformant document — writes nothing. +/// But writing several files cannot be atomic: if the third write fails on +/// I/O, the first two are already on disk. is then +/// false while lists what actually landed, and +/// names them. /// -/// Whether the batch was written. -/// A confirmation, or the reason nothing was written. -/// One entry per stamped concept, in the order given. +/// Whether the whole batch was written. +/// A confirmation, or what went wrong and how far it got. +/// One entry per concept actually stamped, in the order given. public readonly record struct VerificationOutcome(bool Recorded, string Message, IReadOnlyList Records); ``` @@ -623,12 +643,19 @@ Puis, dans la classe, la méthode de lot et ses aides privées : /// in each concept's §5.2 verified list, /// preserving every other frontmatter key and the body. /// - /// All-or-nothing: every concept is resolved, read, edited and validated - /// before the first byte is written, all inside one hold of the bundle - /// lock, so a bad third id cannot leave the first two stamped. The lock is - /// an in-process one and .NET has no multi-file atomic write, so an - /// external actor mutating the bundle mid-batch is not stopped — the same - /// documented limit as this class's reparse-point guard. + /// Fully validated before the first write: every concept is resolved, read, + /// edited and validated inside one hold of the bundle lock, before a single + /// byte is written. A batch is therefore REJECTED as a whole — an unknown + /// id, a malformed actor or a non-conformant document writes nothing. + /// + /// It is NOT a transaction. Writing several files cannot be atomic in + /// .NET, so a failure during the write phase (I/O, permissions, a reparse + /// point appearing after the late re-check) leaves the concepts already + /// written stamped. That case reports Recorded = false with + /// Records listing what did land — see . + /// The lock is also in-process, so an external actor mutating the bundle + /// mid-batch is not stopped: the same documented limit as this class's + /// reparse-point guard. /// /// A stamp is a dated declaration, not an authentication result: this /// method cannot and does not check that the caller is who @@ -705,7 +732,7 @@ Puis, dans la classe, la méthode de lot et ses aides privées : lock (_bundleLock) { // PREPARE every concept — read, parse, upsert, validate — before - // writing any of them. This is what makes the batch all-or-nothing. + // writing any of them — so a batch is REJECTED as a whole, even if var prepared = new List<(ConceptTarget Target, string Content)>(targets.Count); for (var i = 0; i < targets.Count; i++) { @@ -731,12 +758,22 @@ Puis, dans la classe, la méthode de lot et ses aides privées : records.Add(new VerificationRecord(conceptIds[i], stampedAt, replacedAt)); } - foreach (var (target, content) in prepared) + // Writing N files cannot be atomic, so a failure here — I/O, + // permissions, a reparse point appearing between the late + // re-check and the write — leaves the earlier concepts stamped. + // That is reported rather than hidden: `written` is trimmed to + // what actually landed, and the message names it. + for (var i = 0; i < prepared.Count; i++) { + var (target, content) = prepared[i]; var writeResult = WriteValidatedContentLocked(target.Id, target.TargetPath, content, existedBefore: true); if (writeResult.StartsWith("Error:", StringComparison.Ordinal)) { - return writeResult; + var stamped = records.Take(i).Select(r => r.ConceptId).ToList(); + records.RemoveRange(i, records.Count - i); + return stamped.Count == 0 + ? writeResult + : $"{writeResult} — already written: {string.Join(", ", stamped)}"; } } @@ -744,8 +781,11 @@ Puis, dans la classe, la méthode de lot et ses aides privées : } }); + // On failure, Records is NOT emptied: it carries whatever reached disk + // before the failure, so a caller can tell "nothing happened" from + // "three of five were stamped and then it broke". return message.StartsWith("Error:", StringComparison.Ordinal) - ? Failed(message) + ? new VerificationOutcome(false, message, records) : new VerificationOutcome(true, message, records); static VerificationOutcome Failed(string message) => new(false, message, []); @@ -905,7 +945,7 @@ Ajouter à `tests/OKF4net.Tests/CliTests.cs` : } /// - /// All-or-nothing: every id is resolved before anything is written, so one + /// Fully validated first: every id is resolved before anything is written, so one /// unknown id leaves the whole bundle untouched. /// [Fact] @@ -1139,7 +1179,7 @@ Puis la méthode : // is written. Existence alone would not be enough: Bundle indexes any // document that parses, including one with no `type`, which the writer // then refuses at write time — so a mistyped id in third position would - // leave the first two stamped. Both checks here, and "all-or-nothing" + // leave the first two stamped. Both checks here, so a rejected batch // is true rather than nearly true. foreach (var id in ids) { @@ -1309,7 +1349,17 @@ Dans `tests/fixtures/README.md`, à la liste des goldens : metrics/legacy --by human:ada --at 2026-08-28T09:14:00Z`. **Hand-authored**, verified against the design spec's stated output format rather than captured from a reference CLI: `verify` is an OKF4net verb with no upstream - counterpart. The bundle is a throwaway copy because the verb writes. + counterpart. The bundle is a throwaway copy because the verb writes. The + first line carries a `(replaces …)` suffix because `okf_v02/metrics/dau.md` + already holds a `human:ada` stamp, so that run exercises the replace path + while the second line exercises the append path. +- `golden/verify-dau.md` — `metrics/dau.md` as it stands **after** that same + run. Pins what stdout cannot: that the stamp landed at the existing entry's + position, that `process:nightly` survived untouched, that `generated` was not + written or refreshed, and that every other key and the body are byte-identical + to the source fixture. Produced by running the command once on a copy, then + **read line by line and justified by hand** before being frozen — the + inspection is the provenance, not the capture. ``` - [ ] **Step 4: Lancer les tests** @@ -1320,7 +1370,7 @@ Expected: PASS. **En cas d'écart, corriger le code, jamais le golden** — sauf - [ ] **Step 5: Commit** ```bash -git add tests/fixtures/golden/verify.out tests/OKF4net.Tests/GoldenParityTests.cs tests/fixtures/README.md +git add tests/fixtures/golden/verify.out tests/fixtures/golden/verify-dau.md tests/OKF4net.Tests/GoldenParityTests.cs tests/fixtures/README.md git commit -m "test(verify): pin the verb's output with a golden" ``` @@ -1478,12 +1528,16 @@ public class OkfVerifyToolTests var tools = ToolsOver(tmp); var function = tools.GetTools().OfType().Single(t => t.Name == "okf_verify"); - // Check AIFunction.InvokeAsync's exact overload against the installed - // Microsoft.Extensions.AI before writing this call — the argument type - // has changed across versions, and inventing a signature here is the - // failure mode this plan exists to avoid. The arguments are: - // conceptIds = "metrics/dau", by = "human:ada", at = "2026-08-28T09:14:00Z". - await function.InvokeAsync(/* the version's argument shape */); + // Same shape as okf_read_concept's invocation test + // (AIFunctionExposureTests.cs:223) — including the null-forgiving `!`, + // which that call needs too. + var arguments = new AIFunctionArguments(new Dictionary + { + ["conceptIds"] = "metrics/dau", + ["by"] = "human:ada", + ["at"] = "2026-08-28T09:14:00Z", + }!); + await function.InvokeAsync(arguments); // The emitter writes sequences in BLOCK style — a bare `-`, then the // mapping indented under it (verified by running `okf fmt`) — so assert @@ -1571,7 +1625,7 @@ La méthode : return RunTool(() => { - // All-or-nothing, like the CLI: every id is resolved before the + // Pre-resolved like the CLI: every id is checked before the // first write, so a typo in the third id cannot leave the first two // stamped. Without this, `okf_verify("a, nope", …)` writes to `a` // and then reports a failure — the worst of both. @@ -1584,7 +1638,7 @@ La méthode : } } - // One batch call — all-or-nothing comes from the writer, so the + // One batch call — the validation guarantee comes from the writer, so the // pre-resolution above is only there to give a nicer message. // `at` is passed through untouched, null included: the writer owns // the clock seam and reports the timestamp it used, so the tool diff --git a/docs/superpowers/specs/2026-08-28-okf-verify-design.md b/docs/superpowers/specs/2026-08-28-okf-verify-design.md index 110b7bf2..f9200d0b 100644 --- a/docs/superpowers/specs/2026-08-28-okf-verify-design.md +++ b/docs/superpowers/specs/2026-08-28-okf-verify-design.md @@ -104,7 +104,7 @@ d'infrastructure que la relecture de cette spec a révélé nécessaire : 0. **Deux extensions du CLI, sans lesquelles la grammaire de §5 est inexprimable** (voir §3.1). -1. `BundleConceptWriter.RecordVerification` + le primitif atomique de +1. `BundleConceptWriter.RecordVerifications` + le primitif atomique de read-modify-write sur le **frontmatter** (il n'existe aujourd'hui que pour le corps). 2. Le verbe CLI `okf verify`. @@ -166,12 +166,36 @@ Méthode ajoutée à `BundleConceptWriter` (classe existante) : /// reste du frontmatter et le corps. Erreurs rendues en chaîne (errors-as- /// data), null en cas de succès — même contrat que WriteConcept. /// - /// L'id du concept (chemin sans .md). + /// Les ids des concepts (chemins sans .md), sans doublon. /// L'acteur §7, requis, bien formé. - /// Horodatage ISO-8601 UTC ; null ⇒ UtcNow formaté. - public string? RecordVerification(string conceptId, string by, string? at = null); + /// Horodatage UTC `yyyy-MM-ddTHH:mm:ssZ` ; null ⇒ UtcNow formaté. + public VerificationOutcome RecordVerifications( + IReadOnlyList conceptIds, string by, string? at = null); + +// où : +public readonly record struct VerificationRecord(string ConceptId, string At, string? ReplacedAt); +public readonly record struct VerificationOutcome( + bool Recorded, string Message, IReadOnlyList Records); ``` +**Une opération de lot, pas une par concept** (décidé à la rédaction du plan, +sur retour de revue). Une boucle d'écritures unitaires laisse le premier concept +estampillé quand le second échoue, et obligerait le CLI **et** le tool à +refermer cette fenêtre chacun de leur côté. Le lot résout, lit, parse, valide et +prépare **tous** les contenus avant d'en écrire un seul : un lot est donc rejeté +en bloc — id inconnu, acteur mal formé, document non conforme n'écrivent rien. + +Ce n'est pas pour autant une transaction, et la spec ne le prétend pas : écrire +N fichiers n'est pas atomique en .NET. Une défaillance pendant la phase +d'écriture (I/O, droits, reparse point apparu) laisse estampillés les concepts +déjà écrits. Ce cas rend `Recorded = false` **avec** `Records` listant ce qui a +réellement atterri, et le message les nomme. Un appelant doit lire `Records`, pas +seulement `Recorded`. + +Les ids en double sont **refusés**, pas dédupliqués : préparer deux fois le même +fichier depuis le même contenu d'origine produirait deux lignes `recorded` pour +une seule estampille survivante. + Un seul écrivain gouverné, appelé par le CLI et par le tool — le partage retenu pour `ConceptAudit` (calcul commun, présentations distinctes) s'applique ici à l'écriture. @@ -206,7 +230,7 @@ l'écriture. doublons de tout autre producteur — même asymétrie strict-en-entrée / permissif-en-lecture que la spec d'audit §4.1. - **Validation à l'écriture : conformité §11, pas mode producteur.** - `RecordVerification` appelle `ValidateConformance()` (type non vide, + `RecordVerifications` appelle `ValidateConformance()` (type non vide, [OkfDocument.cs:158](../../../src/OKF4net/OkfDocument.cs#L158)), **pas** `Validate()`. Divergence délibérée avec `WriteConcept` : `verify` ne produit pas de contenu, il enregistre la relecture d'un contenu qu'il n'a pas écrit ; @@ -217,16 +241,22 @@ l'écriture. la chaîne `human:` nue (qui promeut pourtant le tier, `IsHuman` étant insensible à la bonne formation) est rejetée à l'écriture. Strict en entrée, permissif en lecture, comme partout. -- **`at` : toujours écrit.** Fourni ⇒ validé par - `BundleValidator.IsIso8601DateTime` (public, - [Validate.cs:618](../../../src/OKF4net/Validate.cs#L618) — le prédicat du - validateur lui-même, pour que `verify` ne puisse jamais écrire ce que - `validate` avertirait) ; absent ⇒ `OkfTimestamp.FormatUtc(UtcNow())` via le +- **`at` : toujours écrit, et strictement UTC.** Fourni ⇒ doit avoir exactement + la forme que la bibliothèque émet, `yyyy-MM-ddTHH:mm:ssZ`, contrôlée par un + `DateTime.TryParseExact` en `InvariantCulture`. **Surtout pas** + `BundleValidator.IsIso8601DateTime` : ce prédicat ne valide que la partie + date et ignore tout ce qui suit le `T` + ([Validate.cs:618](../../../src/OKF4net/Validate.cs#L618)), délibérément, + parce que *lire* un frontmatter est permissif. L'employer comme garde + d'écriture ferait accepter `2026-08-28` ou `2026-08-28T09:14:00+02:00` comme + estampilles que le champ documente pourtant en UTC — la règle « strict en + entrée, permissif en lecture » vaut ici comme pour l'acteur. Absent ⇒ + `OkfTimestamp.FormatUtc(UtcNow())` via le seam d'horloge existant du writer ([BundleConceptWriter.cs:81](../../../src/OKF4net/BundleConceptWriter.cs#L81)), donc épinglable en test. **Élargissement de contrat à acter** : la doc de ce seam dit aujourd'hui - « consulté uniquement quand `AutoStampGenerated` est vrai ». `RecordVerification` + « consulté uniquement quand `AutoStampGenerated` est vrai ». `RecordVerifications` le consultera indépendamment de ce flag — c'est voulu (une seule horloge dans le writer, épinglée une seule fois en test), mais le commentaire XML doit être corrigé dans le même changement, sinon il ment. @@ -257,7 +287,7 @@ okf verify - --by # ids lus sur stdin, un par ligne | `…` | Un ou plusieurs ids **explicites**. Aucune forme « tout le bundle ». | | `-` | Seul id positionnel : les ids arrivent de stdin, un par ligne, lignes vides ignorées, chaque ligne trimée. Pas de mélange `-` + ids explicites. | | `--by` | Requis, valué, acteur §7 bien formé. Aucun défaut, aucune variable d'environnement, aucune lecture de git config : l'outil n'invente jamais un auteur. | -| `--at` | Optionnel, valué, ISO-8601 ; défaut : UTC maintenant. Sert la transcription différée (CI future) et les goldens déterministes. | +| `--at` | Optionnel, valué, UTC strict `yyyy-MM-ddTHH:mm:ssZ` ; défaut : UTC maintenant. Sert la transcription différée (CI future) et les goldens déterministes. | | `--dry-run` | Affiche ce qui serait écrit, n'écrit rien, code 0. | Le parsing passe par `CliArgs.Scan(args, "--by", "--at")` — les flags valués @@ -308,7 +338,7 @@ silencieux. | `--by` absent | `error: verify requires --by ` | | `--by` sans valeur | `error: --by requires a value` (contrat `CliArgs`) | | `--by` mal formé | `error: --by is not a well-formed §7 actor: "human:"` | -| `--at` invalide | `error: --at is not ISO-8601: "hier"` | +| `--at` invalide | `error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: "hier"` | | id inconnu | `error: unknown concept "metrics/nope"` (et rien n'est écrit) | | `-` mélangé à des ids | `error: "-" (stdin) cannot be combined with explicit concept ids` | @@ -370,7 +400,7 @@ public string Verify( 6. `at` absent ⇒ `UtcNow` du writer, épinglé par le seam. 7. Le tier observé par `ConceptAudit` bascule : unverified → machine-confirmed (acteur `process:`) → human-reviewed (acteur `human:`) après estampille. -8. Concurrence : deux `RecordVerification` en parallèle sur le même concept, +8. Concurrence : deux `RecordVerifications` en parallèle sur le même concept, acteurs distincts ⇒ les deux estampilles présentes. 9. `generated` absent avant ⇒ toujours absent après ; présent avant ⇒ byte-identique après. @@ -426,7 +456,7 @@ Numérotés à la suite bien que la tâche vienne en premier : la numérotation ## 8. Documentation - README : le verbe (avec l'enchaînement `audit | verify` en exemple), la ligne - du tableau §5.2-§5.3 → `RecordVerification`, et l'encadré « déclaration, pas + du tableau §5.2-§5.3 → `RecordVerifications`, et l'encadré « déclaration, pas preuve » : ce que l'estampille garantit, ce qu'elle ne garantit pas, le fait qu'`okf_write_concept` peut aussi en écrire une, et le mécanisme recommandé (l'estampille dans le diff relu, jamais inférée d'une approbation). @@ -436,7 +466,7 @@ Numérotés à la suite bien que la tâche vienne en premier : la numérotation - Site (`web/`) : ligne dans les deux tables de verbes + chapitre docs/Cli, avec sortie réelle capturée ; tables de tools (12e tool) dans les README Agents et Mcp + pages du site. -- `CLAUDE.md` : une ligne — `RecordVerification` est l'écrivain gouverné unique +- `CLAUDE.md` : une ligne — `RecordVerifications` est l'écrivain gouverné unique de `verified` ; ne pas en forker un second. - ROADMAP : l'audit conscient du temps (le follow-up à plus forte valeur : il transforme les estampilles d'alibi permanent en signal qui décroît, et ne From 37d9b4f0d109232ef2be5708eaf956d48ac7cdca Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 21:27:38 +0200 Subject: [PATCH 08/27] docs(verify): report partial writes at every layer, not just in the core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teaching the writer to report what landed on a mid-batch failure left both consumers throwing that information away: the CLI raised its error without printing the lines for the concepts already stamped, and the tool returned the message alone. That reproduced, one layer up, exactly the defect the previous commit fixed — the bundle changed and the caller was told nothing had happened. The CLI now prints every recorded line first, then fails: stdout says what landed, stderr says why it stopped, exit code 1. The tool appends the failure after the lines. A rejected batch still has no records, so it still yields the message alone. Also cleared the last references to the singular RecordVerification in Task 5 and in two Consumes blocks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- .../plans/2026-08-28-okf-verify.md | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-okf-verify.md b/docs/superpowers/plans/2026-08-28-okf-verify.md index a3abfeb9..ddaa5225 100644 --- a/docs/superpowers/plans/2026-08-28-okf-verify.md +++ b/docs/superpowers/plans/2026-08-28-okf-verify.md @@ -23,7 +23,7 @@ ## Écart assumé par rapport à la spec -La spec §4.1 donne `RecordVerification` rendant `string?` (null = succès). Le plan +La spec §4.1 décrivait initialement un `RecordVerification` unitaire rendant `string?`. La spec a depuis été alignée sur l'API de lot ci-dessous ; cette section garde la trace du chemin parcouru. rend à la place un **`VerificationOutcome`** structuré. Raison : les deux consommateurs ont des besoins différents — le CLI doit formater sa propre ligne et connaître l'horodatage remplacé, le tool agent veut un message prêt à rendre. @@ -589,7 +589,7 @@ public class RecordVerificationTests - [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent** Run: `dotnet test OKF4net.sln --filter "FullyQualifiedName~RecordVerificationTests"` -Expected: échec de compilation — `RecordVerification` et `VerificationOutcome` n'existent pas. +Expected: échec de compilation — `RecordVerifications` et `VerificationOutcome` n'existent pas. - [ ] **Step 3: Implémenter** @@ -852,7 +852,7 @@ Enfin, corriger le commentaire XML du seam d'horloge, qui devient faux : ```csharp /// /// Clock seam for the generated auto-stamp and for - /// 's at; overridable in tests. + /// 's at; overridable in tests. /// internal Func UtcNow { get; set; } = () => DateTime.UtcNow; ``` @@ -866,7 +866,7 @@ Expected: PASS — 13 méthodes (14 cas, la `[Theory]` en comptant deux). ```bash git add src/OKF4net/BundleConceptWriter.cs tests/OKF4net.Tests/RecordVerificationTests.cs -git commit -m "feat(core): RecordVerification, the governed writer of verified" +git commit -m "feat(core): RecordVerifications, the governed writer of verified" ``` --- @@ -878,7 +878,7 @@ git commit -m "feat(core): RecordVerification, the governed writer of verified" - Test: `tests/OKF4net.Tests/CliTests.cs` **Interfaces:** -- Consumes: Task 0 (`CliArgs.Positionals`, `Run(…, TextReader stdin, …)`), Task 1 (`RecordVerification`, `VerificationOutcome`). +- Consumes: Task 0 (`CliArgs.Positionals`, `Run(…, TextReader stdin, …)`), Task 1 (`RecordVerifications`, `VerificationOutcome`). - Produces: le verbe et son format de sortie, que la Task 3 fige en golden. - [ ] **Step 1: Écrire les tests qui échouent** @@ -1212,11 +1212,10 @@ Puis la méthode : // One batch call: the writer prepares every concept before writing any, // so nothing is half-stamped if a later one turns out unwritable. var outcome = writer.RecordVerifications(ids, by, at); - if (!outcome.Recorded) - { - throw new CliOperationException(outcome.Message.Replace("Error: ", string.Empty, StringComparison.Ordinal)); - } - + // Printed BEFORE deciding the exit code: a batch can fail part-way + // through the write phase, and the concepts that did land must be + // reported. Staying silent about them would repeat, one layer up, the + // very thing the writer was fixed not to do. foreach (var record in outcome.Records) { // record.At is the timestamp the writer actually used — the CLI @@ -1225,6 +1224,11 @@ Puis la méthode : stdout.Write($"recorded {record.ConceptId} {by} {record.At}{replaces}\n"); } + if (!outcome.Recorded) + { + throw new CliOperationException(outcome.Message.Replace("Error: ", string.Empty, StringComparison.Ordinal)); + } + return 0; } @@ -1383,7 +1387,7 @@ git commit -m "test(verify): pin the verb's output with a golden" - Test: `tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs` (créé), `tests/OKF4net.Tests/Agents/OkfBundleToolsTests.cs`, `tests/OKF4net.Tests/Agents/AIFunctionExposureTests.cs`, `tests/OKF4net.Tests/Mcp/OkfMcpServerTests.cs` **Interfaces:** -- Consumes: Task 1 (`RecordVerification`, `VerificationOutcome`). +- Consumes: Task 1 (`RecordVerifications`, `VerificationOutcome`). - Produces: le tool `okf_verify`, **mutateur** (dans `WriteToolNames`). **Fallout attendu** : ajouter un 12e tool casse les tests qui figent le nombre et @@ -1644,10 +1648,6 @@ La méthode : // the clock seam and reports the timestamp it used, so the tool // never dates anything itself. var outcome = _writer.RecordVerifications(ids, by, at); - if (!outcome.Recorded) - { - return outcome.Message; - } // The same line shape as the CLI verb, deliberately re-implemented // rather than shared: the CLI's bytes are golden-locked and must not @@ -1660,6 +1660,15 @@ La méthode : lines.Append($"recorded {record.ConceptId} {by} {record.At}{replaces}").Append('\n'); } + // A rejected batch has no records and yields the message alone; a + // batch that failed part-way through writing has both, and the + // agent must see both — the lines for what landed, then why it + // stopped. + if (!outcome.Recorded) + { + lines.Append(outcome.Message).Append('\n'); + } + return lines.ToString(); }); } @@ -1705,7 +1714,7 @@ git commit -m "feat(agents): expose okf_verify as a write tool" Ajouter `verify` à la liste des verbes (après `audit`), une section montrant la boucle complète (`okf audit … | cut -d' ' -f1 | okf verify … -`), la ligne du -tableau §5.2 → `RecordVerification`, et **l'encadré d'honnêteté** : ce que +tableau §5.2 → `RecordVerifications`, et **l'encadré d'honnêteté** : ce que l'estampille garantit (bien formée, datée, sur les concepts nommés), ce qu'elle ne garantit pas (l'identité du signataire, qu'il ait lu), le fait qu'`okf_write_concept` peut aussi en écrire une, et le mécanisme recommandé — @@ -1726,7 +1735,7 @@ Mettre aussi à jour `CLAUDE.md`, qui documente encore - [ ] **Step 3: CLAUDE.md et ROADMAP.md** `CLAUDE.md` : ajouter `verify` à la liste des verbes, et une ligne disant que -`RecordVerification` est l'écrivain gouverné unique de `verified` — ne pas en +`RecordVerifications` est l'écrivain gouverné unique de `verified` — ne pas en forker un second. `ROADMAP.md` : `okf verify` livré ; et l'**audit conscient du temps** comme suite immédiate (exposer les estampilles dans `AuditFinding` pour demander « human-reviewed, mais depuis quand, et le contenu a-t-il bougé ? », @@ -1736,7 +1745,7 @@ la question se répondant par `git log -1 -- ` contre `max(verified[].at Verbe dans les deux tables (`Home.tsx`, `Cli.tsx`), chapitre dans `docs/Cli.tsx` avec **sortie réellement capturée** (lancer la commande, ne pas -l'inventer), ligne `RecordVerification` dans les deux pages bibliothèque, et +l'inventer), ligne `RecordVerifications` dans les deux pages bibliothèque, et tables de tools (12e tool) dans `src/OKF4net.Agents/README.md`, `src/OKF4net.Mcp/README.md` et les pages correspondantes. From eba4b12108b38ac600c44006025c7ac4280c02b3 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 21:32:53 +0200 Subject: [PATCH 09/27] refactor(cli): ordered positionals and a stdin seam --- src/OKF4net.Cli/OkfCli.cs | 39 ++++++++++++++++++-------- src/OKF4net.Cli/Program.cs | 2 +- tests/OKF4net.Tests/CliTests.cs | 48 ++++++++++++++++++++++++++++++-- tests/OKF4net.Tests/TestPaths.cs | 21 +++++++++++++- 4 files changed, 95 insertions(+), 15 deletions(-) diff --git a/src/OKF4net.Cli/OkfCli.cs b/src/OKF4net.Cli/OkfCli.cs index 33dc335e..2e58f420 100644 --- a/src/OKF4net.Cli/OkfCli.cs +++ b/src/OKF4net.Cli/OkfCli.cs @@ -60,7 +60,15 @@ private sealed class CliOperationException(string message) : Exception(message); /// Forces "\n"-only line endings on both writers regardless of platform: /// LF is the tool's canonical output. /// - public static int Run(string[] args, TextWriter stdout, TextWriter stderr) + /// The command-line arguments, excluding the program name. + /// + /// Standard input. Only a verb that documents reading it (today, verify) + /// ever touches this reader; every other verb never reads from it, so no + /// blocking read is introduced for the rest of the CLI. + /// + /// Standard output. + /// Standard error. + public static int Run(string[] args, TextReader stdin, TextWriter stdout, TextWriter stderr) { stdout.NewLine = "\n"; stderr.NewLine = "\n"; @@ -147,8 +155,12 @@ private sealed class CliArgs /// private readonly Dictionary _flags = new(StringComparer.Ordinal); - /// The first positional token — or the one after --, which takes the slot. - private string? _positional; + /// + /// The positional tokens, in order. `--` ends option parsing without + /// discarding what came before it, so a verb taking several positionals + /// (`verify …`) keeps them all. + /// + private readonly List _positionals = []; /// The flags this scan was told consume a value, kept so can tell a user's mistake from the caller's. private string[] _valuedFlags = []; @@ -168,12 +180,11 @@ internal static CliArgs Scan(string[] args, params string[] valuedFlags) if (token == "--") { - // Everything past the separator is positional, and the first - // of those takes the slot even if an earlier token was also - // positional. Nothing after it can be a flag. - if (i + 1 < args.Length) + // Everything past the separator is positional, never a flag. + // It APPENDS: the tokens before it are positionals too. + for (var j = i + 1; j < args.Length; j++) { - scanned._positional = args[i + 1]; + scanned._positionals.Add(args[j]); } break; @@ -201,13 +212,16 @@ internal static CliArgs Scan(string[] args, params string[] valuedFlags) continue; } - if (token.StartsWith('-')) + // A lone "-" is POSIX's "read from standard input" — an + // argument, not an option. Only a token with something after + // the dash is a flag. + if (token.Length > 1 && token.StartsWith('-')) { scanned._flags[token] = null; continue; } - scanned._positional ??= token; + scanned._positionals.Add(token); } return scanned; @@ -247,7 +261,10 @@ internal static CliArgs Scan(string[] args, params string[] valuedFlags) /// The first positional argument, or throws naming . internal string Positional(string what) => - _positional ?? throw new CliOperationException($"missing {what}"); + _positionals.Count > 0 ? _positionals[0] : throw new CliOperationException($"missing {what}"); + + /// Every positional argument, in order — the first is what returns. + internal IReadOnlyList Positionals => _positionals; } /// diff --git a/src/OKF4net.Cli/Program.cs b/src/OKF4net.Cli/Program.cs index 5a6ba08b..729d8405 100644 --- a/src/OKF4net.Cli/Program.cs +++ b/src/OKF4net.Cli/Program.cs @@ -14,6 +14,6 @@ public static int Main(string[] args) // pages otherwise mangle non-ASCII output). Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - return OkfCli.Run(args, Console.Out, Console.Error); + return OkfCli.Run(args, Console.In, Console.Out, Console.Error); } } diff --git a/tests/OKF4net.Tests/CliTests.cs b/tests/OKF4net.Tests/CliTests.cs index 6c76f044..6f5f1b4e 100644 --- a/tests/OKF4net.Tests/CliTests.cs +++ b/tests/OKF4net.Tests/CliTests.cs @@ -775,8 +775,6 @@ public void Fmt_a_write_flag_after_the_separator_is_not_a_flag() /// /// A `--` with nothing after it still ends the option scan, but it does not /// discard a positional that came before: `okf audit b --` resolves `b`. - /// This is the case that distinguishes "clear the positionals at the - /// separator" from "only override when the separator has a token after it". /// [Fact] public void Audit_a_trailing_separator_keeps_the_earlier_positional() @@ -919,4 +917,50 @@ public void Audit_json_keeps_a_malformed_stale_after_raw_and_not_stale() Assert.Equal("not-a-date", finding.GetProperty("staleAfter").GetString()); Assert.False(finding.GetProperty("stale").GetBoolean()); } + + /// + /// `--` ends option parsing; it does not discard the positionals that came + /// before it. With a single positional slot the old rule ("the token after + /// the separator wins") was indistinguishable from this one; with a verb + /// that takes several, it would silently drop the bundle. + /// + [Fact] + public void Separator_keeps_positionals_from_both_sides() + { + var r = Run("audit", V02BundlePath, "--", "--json"); + + // The bundle before `--` is still the positional; `--json` after it is + // an argument, not a flag, so the output is the text report. + Assert.Equal(0, r.Code); + Assert.StartsWith($"bundle: {V02BundlePath}", r.Out); + Assert.DoesNotContain("\"conceptCount\"", r.Out); + } + + /// + /// A verb that does not document reading standard input must never touch + /// it — otherwise `okf fmt file` inside a pipeline would block on a reader + /// nobody is feeding. A StringReader could not prove this (it records + /// nothing), so the reader here throws if anything reads it. + /// + [Fact] + public void A_verb_that_does_not_read_stdin_never_touches_it() + { + var r = TestPaths.RunWithReader( + new ThrowingReader(), + "fmt", + Path.Combine(BundlePath, "tables", "users.md")); + + Assert.Equal(0, r.Code); + Assert.Contains("title: Users", r.Out); + } + + /// A reader that fails the test if the CLI reads from it at all. + private sealed class ThrowingReader : TextReader + { + public override int Peek() => throw new InvalidOperationException("stdin was read"); + + public override int Read() => throw new InvalidOperationException("stdin was read"); + + public override string? ReadLine() => throw new InvalidOperationException("stdin was read"); + } } diff --git a/tests/OKF4net.Tests/TestPaths.cs b/tests/OKF4net.Tests/TestPaths.cs index 9f6e53e3..9b6594d8 100644 --- a/tests/OKF4net.Tests/TestPaths.cs +++ b/tests/OKF4net.Tests/TestPaths.cs @@ -41,6 +41,25 @@ internal static (int Code, string Out, string Err) Run(params string[] args) { var o = new StringWriter(); var e = new StringWriter(); - return (OkfCli.Run(args, o, e), o.ToString(), e.ToString()); + return (OkfCli.Run(args, TextReader.Null, o, e), o.ToString(), e.ToString()); + } + + /// + /// Runs the CLI in-process like , with + /// as its standard input — for the verbs that read ids from a pipe. + /// + internal static (int Code, string Out, string Err) RunWithStdin(string stdin, params string[] args) => + RunWithReader(new StringReader(stdin), args); + + /// + /// Runs the CLI in-process with an arbitrary reader — + /// lets a test prove a verb never touches standard input by handing it one + /// that throws. + /// + internal static (int Code, string Out, string Err) RunWithReader(TextReader stdin, params string[] args) + { + var o = new StringWriter(); + var e = new StringWriter(); + return (OkfCli.Run(args, stdin, o, e), o.ToString(), e.ToString()); } } From eea30f2c3b1ad4b8943deba324d554a04b8eca29 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 21:45:39 +0200 Subject: [PATCH 10/27] feat(core): RecordVerifications, the governed writer of verified --- src/OKF4net/BundleConceptWriter.cs | 243 ++++++++++++++- .../OKF4net.Tests/RecordVerificationTests.cs | 283 ++++++++++++++++++ 2 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 tests/OKF4net.Tests/RecordVerificationTests.cs diff --git a/src/OKF4net/BundleConceptWriter.cs b/src/OKF4net/BundleConceptWriter.cs index 6692bbef..345b28b4 100644 --- a/src/OKF4net/BundleConceptWriter.cs +++ b/src/OKF4net/BundleConceptWriter.cs @@ -1,10 +1,39 @@ // SPDX-License-Identifier: LGPL-3.0-or-later using System.Collections.Concurrent; +using System.Globalization; using OKF4net.Internal; using OKF4net.Yaml; namespace OKF4net; +/// One concept stamped by . +/// The concept that was stamped. +/// +/// The timestamp written. Callers could format their own — the CLI and the +/// Agents layer both see OkfTimestamp through InternalsVisibleTo — +/// but two clocks are one too many: only the writer holds the seam tests pin, +/// so it reports what it wrote. +/// +/// The superseded at, or null when the stamp is new. +public readonly record struct VerificationRecord(string ConceptId, string At, string? ReplacedAt); + +/// +/// The outcome of : +/// errors-as-data, never thrown. +/// +/// Read , not just . Every +/// concept is validated before the first byte is written, so a rejected batch +/// — unknown id, malformed actor, non-conformant document — writes nothing. +/// But writing several files cannot be atomic: if the third write fails on +/// I/O, the first two are already on disk. is then +/// false while lists what actually landed, and +/// names them. +/// +/// Whether the whole batch was written. +/// A confirmation, or what went wrong and how far it got. +/// One entry per concept actually stamped, in the order given. +public readonly record struct VerificationOutcome(bool Recorded, string Message, IReadOnlyList Records); + /// /// The core, thread-safe write primitive for OKF bundles: producer-validated, /// reparse-guarded, atomically-serialized create/update of a concept and an @@ -77,7 +106,10 @@ public sealed class BundleConceptWriter /// When true, stamps a generated block (§5.2) if the caller omitted one. Off by default so only opt-in producer paths (the Agents write tool) auto-stamp. internal bool AutoStampGenerated { get; set; } - /// Clock seam for the auto-stamp; overridable in tests. Only consulted when is true. + /// + /// Clock seam for the generated auto-stamp and for + /// 's at; overridable in tests. + /// internal Func UtcNow { get; set; } = () => DateTime.UtcNow; /// The §7 actor recorded as generated.by when auto-stamping. @@ -433,6 +465,215 @@ public string AppendToConceptAtomic( }); } + /// + /// Records a review of every concept in : + /// adds — or replaces, at its position — the { by, at } entry of + /// in each concept's §5.2 verified list, + /// preserving every other frontmatter key and the body. + /// + /// Fully validated before the first write: every concept is resolved, read, + /// edited and validated inside one hold of the bundle lock, before a single + /// byte is written. A batch is therefore REJECTED as a whole — an unknown + /// id, a malformed actor or a non-conformant document writes nothing. + /// + /// It is NOT a transaction. Writing several files cannot be atomic in + /// .NET, so a failure during the write phase (I/O, permissions, a reparse + /// point appearing after the late re-check) leaves the concepts already + /// written stamped. That case reports Recorded = false with + /// Records listing what did land — see . + /// The lock is also in-process, so an external actor mutating the bundle + /// mid-batch is not stopped: the same documented limit as this class's + /// reparse-point guard. + /// + /// A stamp is a dated declaration, not an authentication result: this + /// method cannot and does not check that the caller is who + /// names. What makes a stamp credible is where it + /// lands — a reviewed diff — not the tool that wrote it. + /// + /// Concept ids (paths without .md); each must already exist. + /// The §7 actor recording the review; must be well-formed. + /// + /// Timestamp in the library's own UTC shape (yyyy-MM-ddTHH:mm:ssZ); + /// null uses . + /// + public VerificationOutcome RecordVerifications(IReadOnlyList conceptIds, string by, string? at = null) + { + if (conceptIds is null || conceptIds.Count == 0) + { + return Failed("Error: no concept id given."); + } + + // Duplicates are refused, not silently collapsed. Preparing the same + // file twice would build both versions from the same original content + // and write it twice, reporting two `recorded` lines for the single + // stamp that survives — a result that reads like two reviews. Naming a + // concept twice is a mistake in the caller's list; say so. + var duplicate = conceptIds + .GroupBy(id => id, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + return Failed($"Error: concept '{duplicate.Key}' is named more than once."); + } + + // Strict on input, permissive on read: `human:` with no id promotes the + // tier (Actor.IsHuman ignores well-formedness), so it must never be + // written here even though a parser would accept it. + if (by is null || !Actor.Parse(by).IsWellFormed) + { + return Failed($"Error: '{by}' is not a well-formed §7 actor."); + } + + // NOT BundleValidator.IsIso8601DateTime: that predicate validates the + // date and ignores everything after the `T` (Validate.cs:618), because + // reading frontmatter is deliberately permissive. Writing is not: a + // stamp this library produces is UTC in one exact shape, and accepting + // "2026-08-28" or a +02:00 offset here would write a value the field's + // own documentation calls UTC. + var stampedAt = at ?? OkfTimestamp.FormatUtc(UtcNow()); + if (!DateTime.TryParseExact( + stampedAt, + "yyyy-MM-dd'T'HH:mm:ss'Z'", + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out _)) + { + return Failed($"Error: '{stampedAt}' is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ."); + } + + var records = new List(conceptIds.Count); + var message = RunTool(() => + { + // Resolved outside the lock, like AppendToConceptAtomic does. + var targets = new List(conceptIds.Count); + foreach (var conceptId in conceptIds) + { + var targetError = ValidateConceptTarget(conceptId, out var target); + if (targetError is not null) + { + return targetError; + } + + targets.Add(target); + } + + lock (_bundleLock) + { + // PREPARE every concept — read, parse, upsert, validate — before + // writing any of them — so a batch is REJECTED as a whole, even if + var prepared = new List<(ConceptTarget Target, string Content)>(targets.Count); + for (var i = 0; i < targets.Count; i++) + { + var target = targets[i]; + if (!File.Exists(target.TargetPath)) + { + return $"Error: concept '{conceptIds[i]}' does not exist."; + } + + var text = OkfEncodings.Strict.GetString(File.ReadAllBytes(target.TargetPath)); + var document = OkfDocument.Parse(text); + var map = document.Frontmatter.AsMapping(); + + map.Insert("verified", UpsertStamp(map.Get("verified"), by, stampedAt, out var replacedAt)); + + var (content, buildError) = BuildConformantContent(map, document.Body); + if (buildError is not null) + { + return buildError; + } + + prepared.Add((target, content!)); + records.Add(new VerificationRecord(conceptIds[i], stampedAt, replacedAt)); + } + + // Writing N files cannot be atomic, so a failure here — I/O, + // permissions, a reparse point appearing between the late + // re-check and the write — leaves the earlier concepts stamped. + // That is reported rather than hidden: `written` is trimmed to + // what actually landed, and the message names it. + for (var i = 0; i < prepared.Count; i++) + { + var (target, content) = prepared[i]; + var writeResult = WriteValidatedContentLocked(target.Id, target.TargetPath, content, existedBefore: true); + if (writeResult.StartsWith("Error:", StringComparison.Ordinal)) + { + var stamped = records.Take(i).Select(r => r.ConceptId).ToList(); + records.RemoveRange(i, records.Count - i); + return stamped.Count == 0 + ? writeResult + : $"{writeResult} — already written: {string.Join(", ", stamped)}"; + } + } + + return $"Recorded {prepared.Count} verification(s) by {by} at {stampedAt}."; + } + }); + + // On failure, Records is NOT emptied: it carries whatever reached disk + // before the failure, so a caller can tell "nothing happened" from + // "three of five were stamped and then it broke". + return message.StartsWith("Error:", StringComparison.Ordinal) + ? new VerificationOutcome(false, message, records) + : new VerificationOutcome(true, message, records); + + static VerificationOutcome Failed(string message) => new(false, message, []); + } + + /// + /// Returns the verified sequence with 's stamp + /// added, or replaced at its existing position. + /// is immutable, so the list is rebuilt; only the FIRST entry matching the + /// actor is replaced — a permissive reader accepts duplicates, and this + /// writer never deletes an entry it is not replacing. + /// + private static YamlSequence UpsertStamp(YamlValue? existing, string by, string at, out string? replacedAt) + { + replacedAt = null; + + var items = existing switch + { + YamlSequence sequence => new List(sequence.Items), + // `verified: { by, at }` — a bare mapping — is a shape ParseVerified + // accepts, so normalize it into the list rather than discarding it. + YamlMapping single => [single], + _ => [], + }; + + var stamp = new YamlMapping(); + stamp.Insert("by", new YamlString(by)); + stamp.Insert("at", new YamlString(at)); + + for (var i = 0; i < items.Count; i++) + { + if (items[i] is YamlMapping mapping + && string.Equals(mapping.Get("by")?.AsDisplayString(), by, StringComparison.Ordinal)) + { + replacedAt = mapping.Get("at")?.AsDisplayString(); + items[i] = stamp; + return new YamlSequence(items); + } + } + + items.Add(stamp); + return new YamlSequence(items); + } + + /// + /// Serializes after §11 conformance validation only (non-empty type), + /// unlike 's + /// producer-grade check. Deliberate: recording a review is not producing + /// content, and refusing a reviewer because a third party omitted a + /// description would make precisely the concepts an audit surfaces + /// unstampable. Throws , caught by + /// the caller's wrapper. + /// + private static (string? Content, string? Error) BuildConformantContent(YamlMapping frontmatter, string body) + { + var document = new OkfDocument(Frontmatter.FromMapping(frontmatter), body); + document.ValidateConformance(); + return (document.Serialize(), null); + } + /// A validated concept id and the absolute path it resolves to, produced by . private readonly record struct ConceptTarget(ConceptId Id, string TargetPath); diff --git a/tests/OKF4net.Tests/RecordVerificationTests.cs b/tests/OKF4net.Tests/RecordVerificationTests.cs new file mode 100644 index 00000000..0f113671 --- /dev/null +++ b/tests/OKF4net.Tests/RecordVerificationTests.cs @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +namespace OKF4net.Tests; + +/// +/// Tests for : the single +/// governed writer of the §5.2 verified field. Every test pins the +/// clock through the writer's own UtcNow seam so no assertion depends +/// on the day the suite runs. +/// +public class RecordVerificationTests +{ + private const string Fm = "---\ntype: Metric\ntitle: Daily Active Users\n"; + + private static BundleConceptWriter WriterOver(TempDir tmp) => + new(tmp.Path) { UtcNow = () => new DateTime(2026, 8, 28, 9, 14, 0, DateTimeKind.Utc) }; + + private static string Read(TempDir tmp, string rel) => File.ReadAllText(Path.Combine(tmp.Path, rel)); + + [Fact] + public void First_stamp_creates_the_list_and_leaves_everything_else_alone() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "custom_key: kept\n---\n\n# Body\n"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); + + Assert.True(outcome.Recorded); + Assert.Null(outcome.Records.Single().ReplacedAt); + + // Substring checks would miss a dropped key or a mangled body, so the + // whole document is compared: the frontmatter is exactly the original + // keys in order plus `verified`, and the body is untouched. + var after = OkfDocument.Parse(Read(tmp, "metrics/dau.md")); + Assert.Equal(["type", "title", "custom_key", "verified"], after.Frontmatter.AsMapping().Keys); + Assert.Equal("kept", after.Frontmatter.Get("custom_key")!.AsDisplayString()); + // Not "# Body\n": OkfDocument.Parse never returns a trailing newline + // for a single-trailing-line body (LfLines.Split drops the final + // empty segment, and Parse strips the leading '\n' left by the blank + // separator line) -- Serialize() re-adds exactly one on the way out, + // making this shape idempotent across a parse/serialize round trip. + // Confirmed against OkfDocument.Parse/Serialize directly, independent + // of RecordVerifications. + Assert.Equal("# Body", after.Body); + + var stamp = Assert.Single(after.Frontmatter.Verified); + Assert.Equal("human:ada", stamp.By!.Value.Raw); + Assert.Equal("2026-08-28T09:14:00Z", stamp.At); + } + + [Fact] + public void Same_actor_replaces_its_own_stamp_in_place() + { + using var tmp = new TempDir(); + tmp.Write( + "metrics/dau.md", + Fm + "verified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n" + + " - { by: process:nightly, at: 2026-02-02T00:00:00Z }\n---\n\nbody\n"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); + + Assert.True(outcome.Recorded); + Assert.Equal("2026-01-01T00:00:00Z", outcome.Records.Single().ReplacedAt); + + var doc = OkfDocument.Parse(Read(tmp, "metrics/dau.md")); + var stamps = doc.Frontmatter.Verified; + Assert.Equal(2, stamps.Count); + // Position preserved: ada stays first, nightly untouched. + Assert.Equal("human:ada", stamps[0].By!.Value.Raw); + Assert.Equal("2026-08-28T09:14:00Z", stamps[0].At); + Assert.Equal("process:nightly", stamps[1].By!.Value.Raw); + Assert.Equal("2026-02-02T00:00:00Z", stamps[1].At); + } + + [Fact] + public void A_different_actor_is_appended_and_never_touches_another_entry() + { + using var tmp = new TempDir(); + tmp.Write( + "metrics/dau.md", + Fm + "verified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n---\n\nbody\n"); + + WriterOver(tmp).RecordVerifications(["metrics/dau"], "process:nightly"); + + var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; + Assert.Equal(2, stamps.Count); + Assert.Equal("human:ada", stamps[0].By!.Value.Raw); + Assert.Equal("2026-01-01T00:00:00Z", stamps[0].At); + Assert.Equal("process:nightly", stamps[1].By!.Value.Raw); + } + + /// + /// A permissive reader accepts duplicate entries for one actor (§5.2 says + /// nothing about uniqueness), so the writer replaces the FIRST match only + /// and never deletes an entry it is not replacing. + /// + [Fact] + public void Only_the_first_duplicate_of_an_actor_is_replaced() + { + using var tmp = new TempDir(); + tmp.Write( + "metrics/dau.md", + Fm + "verified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n" + + " - { by: human:ada, at: 2026-02-02T00:00:00Z }\n---\n\nbody\n"); + + WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); + + var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; + Assert.Equal(2, stamps.Count); + Assert.Equal("2026-08-28T09:14:00Z", stamps[0].At); + Assert.Equal("2026-02-02T00:00:00Z", stamps[1].At); + } + + /// + /// `verified: { by, at }` — a single mapping rather than a list — is a + /// shape accepts (Trust.cs:32), so the + /// writer must normalize it instead of throwing or overwriting it. + /// + [Fact] + public void A_single_mapping_verified_is_normalized_to_a_list() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "verified: { by: process:nightly, at: 2026-01-01T00:00:00Z }\n---\n\nbody\n"); + + WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); + + var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; + Assert.Equal(2, stamps.Count); + Assert.Equal("process:nightly", stamps[0].By!.Value.Raw); + Assert.Equal("human:ada", stamps[1].By!.Value.Raw); + } + + /// + /// A concept named twice is refused rather than collapsed: preparing the + /// same file twice from the same original content would write it twice and + /// report two lines for one surviving stamp — a result that reads like two + /// reviews. Nothing is written. + /// + [Fact] + public void A_duplicate_concept_id_is_refused() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + var before = Read(tmp, "metrics/dau.md"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau", "metrics/dau"], "human:ada"); + + Assert.False(outcome.Recorded); + Assert.Contains("named more than once", outcome.Message); + Assert.Equal(before, Read(tmp, "metrics/dau.md")); + } + + [Theory] + [InlineData("human:", "not a well-formed")] + [InlineData("", "not a well-formed")] + public void A_malformed_actor_is_refused(string by, string expected) + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], by); + + Assert.False(outcome.Recorded); + Assert.Contains(expected, outcome.Message); + Assert.DoesNotContain("verified", Read(tmp, "metrics/dau.md")); + } + + [Fact] + public void A_non_iso_at_is_refused() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada", "hier"); + + Assert.False(outcome.Recorded); + Assert.Contains("yyyy-MM-ddTHH:mm:ssZ", outcome.Message); + } + + [Fact] + public void An_unknown_concept_is_refused_without_creating_it() + { + using var tmp = new TempDir(); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/nope"], "human:ada"); + + Assert.False(outcome.Recorded); + Assert.Contains("does not exist", outcome.Message); + Assert.False(File.Exists(Path.Combine(tmp.Path, "metrics", "nope.md"))); + } + + /// + /// Conformance-level validation (§11, non-empty type), NOT producer-grade: + /// refusing to record a human's review because a third party omitted a + /// `description` would make exactly the concepts the worklist surfaces + /// unstampable. See the design spec §4.2. + /// + [Fact] + public void A_concept_missing_description_is_still_stampable() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); + + Assert.True(outcome.Recorded); + Assert.Contains("by: human:ada", Read(tmp, "metrics/dau.md")); + } + + [Fact] + public void A_concept_without_type_is_refused() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntitle: No type\n---\n\nbody\n"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada"); + + Assert.False(outcome.Recorded); + Assert.Contains("type", outcome.Message); + } + + [Fact] + public void Generated_is_never_written_or_refreshed() + { + using var tmp = new TempDir(); + tmp.Write("a.md", Fm + "generated: { by: okf4net/0.3.0, at: 2020-01-01T00:00:00Z }\n---\n\nbody\n"); + tmp.Write("b.md", Fm + "---\n\nbody\n"); + + // AutoStampGenerated defaults to false, so a bare writer would pass this + // test even if RecordVerifications went through the auto-stamping path. + // OkfBundleTools turns it ON, which is the configuration that matters. + var stamping = new BundleConceptWriter(tmp.Path) + { + AutoStampGenerated = true, + UtcNow = () => new DateTime(2026, 8, 28, 9, 14, 0, DateTimeKind.Utc), + }; + stamping.RecordVerifications(["b"], "human:ada"); + Assert.DoesNotContain("generated", Read(tmp, "b.md")); + + var writer = WriterOver(tmp); + writer.RecordVerifications(["a"], "human:ada"); + writer.RecordVerifications(["b"], "human:ada"); + + Assert.Contains("at: 2020-01-01T00:00:00Z", Read(tmp, "a.md")); + Assert.DoesNotContain("generated", Read(tmp, "b.md")); + } + + /// The tier okf audit reads moves as a direct consequence. + [Fact] + public void The_trust_tier_moves_after_a_stamp() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + var writer = WriterOver(tmp); + + Assert.Equal(TrustTier.Unverified, Bundle.Load(tmp.Path).Concepts[0].Document.Frontmatter.TrustTier); + + writer.RecordVerifications(["metrics/dau"], "process:nightly"); + Assert.Equal(TrustTier.MachineConfirmed, Bundle.Load(tmp.Path).Concepts[0].Document.Frontmatter.TrustTier); + + writer.RecordVerifications(["metrics/dau"], "human:ada"); + Assert.Equal(TrustTier.HumanReviewed, Bundle.Load(tmp.Path).Concepts[0].Document.Frontmatter.TrustTier); + } + + /// + /// Two verifications of the same concept must not lose a stamp: the read, + /// the transform and the write all happen inside one hold of the writer's + /// bundle lock. + /// + [Fact] + public void Concurrent_verifications_of_one_concept_both_land() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + var writer = WriterOver(tmp); + + Parallel.Invoke( + () => writer.RecordVerifications(["metrics/dau"], "human:ada"), + () => writer.RecordVerifications(["metrics/dau"], "process:nightly")); + + var stamps = OkfDocument.Parse(Read(tmp, "metrics/dau.md")).Frontmatter.Verified; + Assert.Equal(2, stamps.Count); + } +} From b25553bc09c1f9f693098e0ddb957f03d589c4c6 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 22:01:40 +0200 Subject: [PATCH 11/27] fix(core): make RecordVerifications' Records mean written, not prepared Seven fixes from round-1 review of RecordVerifications: 1. CRITICAL: Records was populated in the PREPARE loop, so a batch rejected during prepare (unknown id, unparseable document, failed ValidateConformance) reported concepts as recorded that were never written to disk. Records is now built in the WRITE loop, one entry per successful write, with no separate trim/rollback step to keep in sync. 2. Pinned the deliberate divergence from BundleValidator.IsIso8601DateTime by testing a bare date and a non-UTC offset, not just a garbage string, as invalid `at` values. 3. Added a two-concept batch test where the second concept fails validation, asserting the first file is left untouched and Records is empty -- the actual reason this method is a batch. 4. A null element in conceptIds no longer throws NullReferenceException out of ConceptId.Parse; guarded up front like WriteConcept's own id checks. 5. The duplicate-id guard now compares resolved target paths with OrdinalIgnoreCase instead of raw id strings with Ordinal, so two case-variant spellings of the same concept collide too, matching the BundleLocks registry's own reasoning. 6. Finished the truncated PREPARE-loop comment. 7. BuildConformantContent now returns the serialized content directly instead of an (Content, Error) pair whose Error half was dead code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- src/OKF4net/BundleConceptWriter.cs | 101 ++++++++++++------ .../OKF4net.Tests/RecordVerificationTests.cs | 87 ++++++++++++++- 2 files changed, 153 insertions(+), 35 deletions(-) diff --git a/src/OKF4net/BundleConceptWriter.cs b/src/OKF4net/BundleConceptWriter.cs index 345b28b4..820698ef 100644 --- a/src/OKF4net/BundleConceptWriter.cs +++ b/src/OKF4net/BundleConceptWriter.cs @@ -503,17 +503,22 @@ public VerificationOutcome RecordVerifications(IReadOnlyList conceptIds, return Failed("Error: no concept id given."); } - // Duplicates are refused, not silently collapsed. Preparing the same - // file twice would build both versions from the same original content - // and write it twice, reporting two `recorded` lines for the single - // stamp that survives — a result that reads like two reviews. Naming a - // concept twice is a mistake in the caller's list; say so. - var duplicate = conceptIds - .GroupBy(id => id, StringComparer.Ordinal) - .FirstOrDefault(group => group.Count() > 1); - if (duplicate is not null) + // Guard every element before any of them reaches ValidateConceptTarget: + // ConceptId.TryParse's Parse -> s.Split('/') throws NullReferenceException + // for a null element (NRE is not in RunTool's catch filter), and a JSON + // binder handing this list to a string[] can put a null in it regardless + // of nullable annotations. Mirrors WriteConcept's own id guards verbatim. + foreach (var conceptId in conceptIds) { - return Failed($"Error: concept '{duplicate.Key}' is named more than once."); + if (string.IsNullOrWhiteSpace(conceptId)) + { + return Failed("Error: invalid concept id — it must not be empty."); + } + + if (conceptId.Contains('\0')) + { + return Failed("Error: invalid concept id — it must not contain a null character."); + } } // Strict on input, permissive on read: `human:` with no id promotes the @@ -557,11 +562,33 @@ public VerificationOutcome RecordVerifications(IReadOnlyList conceptIds, targets.Add(target); } + // Duplicates are refused, not silently collapsed: preparing the same + // file twice would build both versions from the same original + // content and write it twice, reporting two records for the single + // stamp that survives — a result that reads like two reviews. + // Checked on the RESOLVED target path, not the raw id string that + // was passed in: two case-variant spellings of the same concept + // ("metrics/dau" / "metrics/DAU") resolve to the same file on a + // case-insensitive filesystem (Windows/macOS) and must collide too + // — the same OrdinalIgnoreCase reasoning the BundleLocks registry + // above uses for exactly this class of bug. + var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < targets.Count; i++) + { + if (!seenPaths.Add(targets[i].TargetPath)) + { + return $"Error: concept '{conceptIds[i]}' is named more than once."; + } + } + lock (_bundleLock) { - // PREPARE every concept — read, parse, upsert, validate — before - // writing any of them — so a batch is REJECTED as a whole, even if - var prepared = new List<(ConceptTarget Target, string Content)>(targets.Count); + // PREPARE every concept — read, parse, upsert the stamp, and + // validate — before writing any of them, so an unknown, + // unreadable, or non-conformant concept later in the list + // rejects the WHOLE batch, even though earlier concepts in it + // already built successfully. + var prepared = new List<(ConceptTarget Target, string Content, string ConceptId, string? ReplacedAt)>(targets.Count); for (var i = 0; i < targets.Count; i++) { var target = targets[i]; @@ -576,33 +603,38 @@ public VerificationOutcome RecordVerifications(IReadOnlyList conceptIds, map.Insert("verified", UpsertStamp(map.Get("verified"), by, stampedAt, out var replacedAt)); - var (content, buildError) = BuildConformantContent(map, document.Body); - if (buildError is not null) - { - return buildError; - } + // Throws DocumentValidationException on a failed §11 check, + // caught by RunTool -- nothing in `prepared` so far has been + // written, so the whole batch is rejected cleanly. + var content = BuildConformantContent(map, document.Body); - prepared.Add((target, content!)); - records.Add(new VerificationRecord(conceptIds[i], stampedAt, replacedAt)); + prepared.Add((target, content, conceptIds[i], replacedAt)); } // Writing N files cannot be atomic, so a failure here — I/O, // permissions, a reparse point appearing between the late - // re-check and the write — leaves the earlier concepts stamped. - // That is reported rather than hidden: `written` is trimmed to - // what actually landed, and the message names it. + // re-check and the write — leaves the earlier concepts + // stamped. `records` is built HERE, one entry per successful + // write, deliberately NOT in the prepare loop above: that is + // what makes it mean "landed on disk", not "was validated". A + // batch rejected during PREPARE never reaches this loop, so + // `records` stays empty; a batch that fails partway through + // WRITE leaves `records` holding exactly the prefix that + // actually made it to disk — no separate trim/rollback step + // to keep in sync, and no way for a future early return in + // this loop to under- or over-report what landed. for (var i = 0; i < prepared.Count; i++) { - var (target, content) = prepared[i]; + var (target, content, conceptId, replacedAt) = prepared[i]; var writeResult = WriteValidatedContentLocked(target.Id, target.TargetPath, content, existedBefore: true); if (writeResult.StartsWith("Error:", StringComparison.Ordinal)) { - var stamped = records.Take(i).Select(r => r.ConceptId).ToList(); - records.RemoveRange(i, records.Count - i); - return stamped.Count == 0 + return records.Count == 0 ? writeResult - : $"{writeResult} — already written: {string.Join(", ", stamped)}"; + : $"{writeResult} — already written: {string.Join(", ", records.Select(r => r.ConceptId))}"; } + + records.Add(new VerificationRecord(conceptId, stampedAt, replacedAt)); } return $"Recorded {prepared.Count} verification(s) by {by} at {stampedAt}."; @@ -664,14 +696,19 @@ private static YamlSequence UpsertStamp(YamlValue? existing, string by, string a /// producer-grade check. Deliberate: recording a review is not producing /// content, and refusing a reviewer because a third party omitted a /// description would make precisely the concepts an audit surfaces - /// unstampable. Throws , caught by - /// the caller's wrapper. + /// unstampable. Unlike the -based overload above, + /// there is no "not a mapping" case to report here — the caller always + /// passes an already-typed — so this returns the + /// serialized content directly rather than an (Content, Error) pair + /// whose Error half could never be anything but . + /// Throws on a failed conformance + /// check, caught by the caller's wrapper. /// - private static (string? Content, string? Error) BuildConformantContent(YamlMapping frontmatter, string body) + private static string BuildConformantContent(YamlMapping frontmatter, string body) { var document = new OkfDocument(Frontmatter.FromMapping(frontmatter), body); document.ValidateConformance(); - return (document.Serialize(), null); + return document.Serialize(); } /// A validated concept id and the absolute path it resolves to, produced by . diff --git a/tests/OKF4net.Tests/RecordVerificationTests.cs b/tests/OKF4net.Tests/RecordVerificationTests.cs index 0f113671..f45a3313 100644 --- a/tests/OKF4net.Tests/RecordVerificationTests.cs +++ b/tests/OKF4net.Tests/RecordVerificationTests.cs @@ -149,6 +149,76 @@ public void A_duplicate_concept_id_is_refused() Assert.Equal(before, Read(tmp, "metrics/dau.md")); } + /// + /// The duplicate guard is checked on the RESOLVED target path, not the raw + /// id string, so two case-variant spellings of the same concept collide + /// too on a case-insensitive filesystem (Windows/macOS) — matching the + /// OrdinalIgnoreCase the BundleLocks registry uses for the + /// same reason. A raw-string, case-sensitive guard would let this pair + /// through and write two records for the one stamp that survives. + /// + [Fact] + public void A_case_variant_duplicate_concept_id_is_refused() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + var before = Read(tmp, "metrics/dau.md"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau", "metrics/DAU"], "human:ada"); + + Assert.False(outcome.Recorded); + Assert.Contains("named more than once", outcome.Message); + Assert.Equal(before, Read(tmp, "metrics/dau.md")); + } + + /// + /// A null element must be rejected as data, not thrown: ConceptId.Parse's + /// s.Split('/') throws NullReferenceException for a null id, which + /// is not in RunTool's catch filter — and a JSON binder can hand this + /// list a null element (e.g. ["a", null]) regardless of the + /// compile-time IReadOnlyList<string> annotation. + /// + [Fact] + public void A_null_concept_id_in_the_batch_is_refused_without_throwing() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + var before = Read(tmp, "metrics/dau.md"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau", null!], "human:ada"); + + Assert.False(outcome.Recorded); + Assert.Contains("must not be empty", outcome.Message); + Assert.Equal(before, Read(tmp, "metrics/dau.md")); + } + + /// + /// The whole point of a batch is that concept 2 failing rejects concept 1 + /// too, even though concept 1's content was already built successfully in + /// the prepare loop. A regression that moved validation/writing into a + /// single per-concept loop (writing as it goes, instead of preparing the + /// whole batch before writing any of it) would still pass every + /// single-concept test in this file but fail this one. Also covers the + /// contract: rejected during + /// PREPARE means nothing was ever written, so Records is empty — + /// not just Recorded == false. + /// + [Fact] + public void A_later_concept_failing_validation_leaves_an_earlier_one_unwritten() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + tmp.Write("metrics/no-type.md", "---\ntitle: No type\n---\n\nbody\n"); + var before = Read(tmp, "metrics/dau.md"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau", "metrics/no-type"], "human:ada"); + + Assert.False(outcome.Recorded); + Assert.Empty(outcome.Records); + Assert.Equal(before, Read(tmp, "metrics/dau.md")); + Assert.DoesNotContain("verified", Read(tmp, "metrics/dau.md")); + } + [Theory] [InlineData("human:", "not a well-formed")] [InlineData("", "not a well-formed")] @@ -164,13 +234,24 @@ public void A_malformed_actor_is_refused(string by, string expected) Assert.DoesNotContain("verified", Read(tmp, "metrics/dau.md")); } - [Fact] - public void A_non_iso_at_is_refused() + /// + /// Pins the deliberate divergence from BundleValidator.IsIso8601DateTime + /// (which validates only the date part and ignores everything after the + /// T, because reading frontmatter is permissive): a bare date and a + /// non-UTC offset both pass that permissive predicate, so testing only a + /// garbage string like "hier" would stay green even if the strict parse + /// were "simplified" back to it. + /// + [Theory] + [InlineData("hier")] + [InlineData("2026-08-28")] + [InlineData("2026-08-28T09:14:00+02:00")] + public void A_non_iso_at_is_refused(string at) { using var tmp = new TempDir(); tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); - var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada", "hier"); + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], "human:ada", at); Assert.False(outcome.Recorded); Assert.Contains("yyyy-MM-ddTHH:mm:ssZ", outcome.Message); From af4362458eddebd74147fbcf8d2757b7aced1bf9 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 22:09:51 +0200 Subject: [PATCH 12/27] feat(cli): add the okf verify verb --- src/OKF4net.Cli/OkfCli.cs | 154 +++++++++++++++++++++++++- tests/OKF4net.Tests/CliTests.cs | 186 ++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 4 deletions(-) diff --git a/src/OKF4net.Cli/OkfCli.cs b/src/OKF4net.Cli/OkfCli.cs index 2e58f420..64522a70 100644 --- a/src/OKF4net.Cli/OkfCli.cs +++ b/src/OKF4net.Cli/OkfCli.cs @@ -7,10 +7,10 @@ namespace OKF4net.Cli; /// -/// The okf command-line tool. Eight subcommands (validate, -/// audit, info, index, graph, parse, -/// fmt, render) over hand-rolled argument parsing -- no -/// third-party dependencies. +/// The okf command-line tool. Nine subcommands (validate, +/// audit, verify, info, index, graph, +/// parse, fmt, render) over hand-rolled argument +/// parsing -- no third-party dependencies. /// /// is the sole public entry point so tests can drive the /// CLI in-process (capturing stdout/stderr) without spawning a subprocess; @@ -31,6 +31,7 @@ public static class OkfCli "COMMANDS:\n" + " validate Check a bundle against OKF v0.2 conformance (§11)\n" + " audit Report trust, freshness and lifecycle across the bundle\n" + + " verify … Record a review of one or more concepts (--by )\n" + " info Summarize a bundle (concepts, types, links, version)\n" + " index (Re)generate every index.md in the bundle\n" + " graph Print the cross-link graph (--dot for Graphviz DOT)\n" + @@ -44,6 +45,8 @@ public static class OkfCli " --json Machine-readable output for validate/info/audit\n" + " --out Output directory for `render`\n" + " --as-of Pin today's date (YYYY-MM-DD) for validate/audit\n" + + " --by Who is recording the review, for `verify` (required)\n" + + " --dry-run Show what `verify` would record, write nothing\n" + " --stale, --trust , --status , --type \n" + " Filter `audit`'s worklist"; @@ -100,6 +103,7 @@ public static int Run(string[] args, TextReader stdin, TextWriter stdout, TextWr { "validate" => CmdValidate(rest, stdout), "audit" => CmdAudit(rest, stdout), + "verify" => CmdVerify(rest, stdin, stdout), "info" => CmdInfo(rest, stdout), "index" => CmdIndex(rest, stdout), "graph" => CmdGraph(rest, stdout), @@ -566,6 +570,148 @@ private static void WriteAuditReport(TextWriter stdout, string bundlePath, Audit } } + /// Implements the verify subcommand. + private static int CmdVerify(string[] args, TextReader stdin, TextWriter stdout) + { + var parsed = CliArgs.Scan(args, "--by", "--at"); + + // Both values are READ first, so a flag present without a value names + // itself ("--by requires a value") rather than surfacing later as a + // missing argument. They are VALIDATED after the ids, so that the most + // structural mistake — no concept named at all — is reported first. + var by = parsed.Value("--by"); + var at = parsed.Value("--at"); + + var positionals = parsed.Positionals; + var path = positionals.Count > 0 ? positionals[0] : throw new CliOperationException("missing "); + var ids = positionals.Skip(1).ToList(); + if (ids.Count == 0) + { + throw new CliOperationException("missing "); + } + + if (ids.Contains("-")) + { + if (ids.Count > 1) + { + throw new CliOperationException("\"-\" (stdin) cannot be combined with explicit concept ids"); + } + + ids = ReadIdsFrom(stdin); + if (ids.Count == 0) + { + throw new CliOperationException("no concept ids on standard input"); + } + } + + // Validated only now: an invocation naming no concept at all is the + // more structural mistake, and its message must come first. + if (by is null) + { + throw new CliOperationException("verify requires --by "); + } + + if (!Actor.Parse(by).IsWellFormed) + { + throw new CliOperationException($"--by is not a well-formed §7 actor: \"{by}\""); + } + + // The writer applies the same strict UTC rule; checking here too turns a + // generic write error into a message naming the flag. Deliberately NOT + // BundleValidator.IsIso8601DateTime, which only validates the date part. + if (at is not null && !DateTime.TryParseExact( + at, + "yyyy-MM-dd'T'HH:mm:ss'Z'", + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out _)) + { + throw new CliOperationException($"--at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"{at}\""); + } + + var bundle = Load(path); + + // Refused here as well as in the writer, so the message reads like its + // siblings (the writer's ends with a period; the CLI's do not). + var duplicate = ids.GroupBy(id => id, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1); + if (duplicate is not null) + { + throw new CliOperationException($"concept '{duplicate.Key}' is named more than once"); + } + + // Every id is resolved AND checked for §11 conformance before anything + // is written. Existence alone would not be enough: Bundle indexes any + // document that parses, including one with no `type`, which the writer + // then refuses at write time — so a mistyped id in third position would + // leave the first two stamped. Both checks here, so a rejected batch + // is true rather than nearly true. + foreach (var id in ids) + { + if (!ConceptId.TryParse(id, out var parsedId) || bundle.Get(parsedId!) is not { } concept) + { + throw new CliOperationException($"unknown concept \"{id}\""); + } + + if (concept.Document.Frontmatter.Get("type") is not { IsEmptyValue: false }) + { + throw new CliOperationException($"concept \"{id}\" has no `type` and is not §11-conformant"); + } + } + + var writer = new BundleConceptWriter(path); + + if (parsed.Has("--dry-run")) + { + // A dry run writes nothing, so there is no timestamp to report. It + // could format one (OkfTimestamp is reachable here), but printing a + // date the real run would not reproduce is worse than saying "now". + foreach (var id in ids) + { + stdout.Write($"would record {id} {by} {at ?? "(now)"}\n"); + } + + return 0; + } + + // One batch call: the writer prepares every concept before writing any, + // so nothing is half-stamped if a later one turns out unwritable. + var outcome = writer.RecordVerifications(ids, by, at); + // Printed BEFORE deciding the exit code: a batch can fail part-way + // through the write phase, and the concepts that did land must be + // reported. Staying silent about them would repeat, one layer up, the + // very thing the writer was fixed not to do. + foreach (var record in outcome.Records) + { + // record.At is the timestamp the writer actually used — the CLI + // reports it rather than recomputing one that could differ. + var replaces = record.ReplacedAt is { } previous ? $" (replaces {previous})" : string.Empty; + stdout.Write($"recorded {record.ConceptId} {by} {record.At}{replaces}\n"); + } + + if (!outcome.Recorded) + { + throw new CliOperationException(outcome.Message.Replace("Error: ", string.Empty, StringComparison.Ordinal)); + } + + return 0; + } + + /// Reads concept ids from , one per line, ignoring blank lines. + private static List ReadIdsFrom(TextReader stdin) + { + var ids = new List(); + while (stdin.ReadLine() is { } line) + { + var trimmed = line.Trim(); + if (trimmed.Length > 0) + { + ids.Add(trimmed); + } + } + + return ids; + } + /// Implements the info subcommand. private static int CmdInfo(string[] args, TextWriter stdout) { diff --git a/tests/OKF4net.Tests/CliTests.cs b/tests/OKF4net.Tests/CliTests.cs index 6f5f1b4e..232c9809 100644 --- a/tests/OKF4net.Tests/CliTests.cs +++ b/tests/OKF4net.Tests/CliTests.cs @@ -936,6 +936,192 @@ public void Separator_keeps_positionals_from_both_sides() Assert.DoesNotContain("\"conceptCount\"", r.Out); } + private static string NewBundleWithTwoConcepts(TempDir tmp) + { + tmp.Write("metrics/dau.md", "---\ntype: Metric\ntitle: DAU\n---\n\nbody\n"); + tmp.Write("metrics/rev.md", "---\ntype: Metric\ntitle: Revenue\n---\n\nbody\n"); + return tmp.Path; + } + + [Fact] + public void Verify_records_a_stamp_on_each_named_concept() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + + var r = Run("verify", bundle, "metrics/dau", "metrics/rev", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); + + Assert.Equal(0, r.Code); + Assert.Equal( + "recorded metrics/dau human:ada 2026-08-28T09:14:00Z\n" + + "recorded metrics/rev human:ada 2026-08-28T09:14:00Z\n", + r.Out); + Assert.Contains("by: human:ada", File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + + [Fact] + public void Verify_reports_the_timestamp_it_superseded() + { + using var tmp = new TempDir(); + tmp.Write( + "metrics/dau.md", + "---\ntype: Metric\nverified:\n - { by: human:ada, at: 2026-01-01T00:00:00Z }\n---\n\nbody\n"); + + var r = Run("verify", tmp.Path, "metrics/dau", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); + + Assert.Equal(0, r.Code); + Assert.Equal( + "recorded metrics/dau human:ada 2026-08-28T09:14:00Z (replaces 2026-01-01T00:00:00Z)\n", + r.Out); + } + + /// The line that closes the loop: audit's ids piped into verify. + [Fact] + public void Verify_reads_ids_from_stdin_when_the_id_is_a_dash() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + + var r = TestPaths.RunWithStdin( + "metrics/dau\n\nmetrics/rev\n", + "verify", bundle, "-", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); + + Assert.Equal(0, r.Code); + // The blank line is ignored, both concepts are stamped, order preserved. + Assert.Equal( + "recorded metrics/dau human:ada 2026-08-28T09:14:00Z\n" + + "recorded metrics/rev human:ada 2026-08-28T09:14:00Z\n", + r.Out); + } + + /// + /// Fully validated first: every id is resolved before anything is written, so one + /// unknown id leaves the whole bundle untouched. + /// + [Fact] + public void Verify_writes_nothing_when_one_id_is_unknown() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run("verify", bundle, "metrics/dau", "metrics/nope", "--by", "human:ada"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: unknown concept \"metrics/nope\"\n", r.Err); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + + /// + /// Existence is not enough for the pre-flight: a document with no `type` + /// loads into the bundle but is refused at write time, so without the + /// conformance check here the concepts named before it would already be + /// stamped. + /// + [Fact] + public void Verify_writes_nothing_when_one_concept_is_not_conformant() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + tmp.Write("metrics/broken.md", "---\ntitle: No type\n---\n\nbody\n"); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run("verify", bundle, "metrics/dau", "metrics/broken", "--by", "human:ada"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: concept \"metrics/broken\" has no `type` and is not §11-conformant\n", r.Err); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + + [Fact] + public void Verify_refuses_a_concept_named_twice() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run("verify", bundle, "metrics/dau", "metrics/dau", "--by", "human:ada"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: concept 'metrics/dau' is named more than once\n", r.Err); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + + [Fact] + public void Verify_dry_run_writes_nothing() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run("verify", bundle, "metrics/dau", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z", "--dry-run"); + + Assert.Equal(0, r.Code); + Assert.Equal("would record metrics/dau human:ada 2026-08-28T09:14:00Z\n", r.Out); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + + [Theory] + [InlineData(new[] { "verify", "BUNDLE" }, "error: missing \n")] + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau" }, "error: verify requires --by \n")] + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:" }, "error: --by is not a well-formed §7 actor: \"human:\"\n")] + // Three shapes a permissive reader accepts and a writer must not: garbage, + // a bare date, and a non-UTC offset. + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "hier" }, "error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"hier\"\n")] + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "2026-08-28" }, "error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"2026-08-28\"\n")] + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "2026-08-28T09:14:00+02:00" }, "error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"2026-08-28T09:14:00+02:00\"\n")] + public void Verify_rejects_bad_invocations(string[] args, string expected) + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var resolved = args.Select(a => a == "BUNDLE" ? bundle : a).ToArray(); + + var r = Run(resolved); + + Assert.Equal(1, r.Code); + Assert.Equal(expected, r.Err); + } + + [Fact] + public void Verify_refuses_to_mix_stdin_with_explicit_ids() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + + var r = Run("verify", bundle, "-", "metrics/dau", "--by", "human:ada"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: \"-\" (stdin) cannot be combined with explicit concept ids\n", r.Err); + } + + /// The loop, end to end: audit lists it, verify clears it. + [Fact] + public void Audit_then_verify_removes_the_concept_from_the_unverified_worklist() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + + var before = Run("audit", tmp.Path, "--trust", "unverified"); + Assert.Contains("metrics/dau", before.Out); + + Run("verify", tmp.Path, "metrics/dau", "--by", "human:ada"); + + var after = Run("audit", tmp.Path, "--trust", "unverified"); + Assert.Equal("", after.Out); + } + + [Fact] + public void Help_lists_verify_after_audit() + { + var r = Run("--help"); + + var lines = r.Out.Split('\n').Select(l => l.TrimStart()).ToList(); + var auditIndex = lines.FindIndex(l => l.StartsWith("audit ", StringComparison.Ordinal)); + var verifyIndex = lines.FindIndex(l => l.StartsWith("verify ", StringComparison.Ordinal)); + + Assert.True(auditIndex >= 0 && verifyIndex == auditIndex + 1); + } + /// /// A verb that does not document reading standard input must never touch /// it — otherwise `okf fmt file` inside a pipeline would block on a reader From c11174c709ce8edf548b816be4e9a40bdedfe21a Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 22:25:19 +0200 Subject: [PATCH 13/27] test(cli): pin verify's partial-write reporting, defer writer construction Round-1 review of the verify verb found two things: 1. IMPORTANT: no test drove a genuine write-phase partial failure through CmdVerify. Swapping the outcome.Records print loop and the !outcome.Recorded throw still passed every existing test -- the exact contract fixed once already in the core (b25553b) had no regression coverage at the verb layer. Added Verify_prints_the_records_that_landed_before_a_later_write_failure, which makes a batch's SECOND concept file genuinely unwritable (read-only) before invoking the verb, so the first concept's write really lands on disk while the second fails -- no internal test hook needed (CmdVerify builds its own private BundleConceptWriter that a test has no handle to, so BeforeLateReparseCheckForTest is reachable only from the core, not from here). Verified by mutation: applying the reviewer's exact swap made the new test fail (stdout empty instead of carrying the "recorded metrics/dau" line); reverting made it pass again. 2. Minor: the writer was constructed before the --dry-run check, so a dry run built one it never used. Moved past the branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- src/OKF4net.Cli/OkfCli.cs | 5 ++- tests/OKF4net.Tests/CliTests.cs | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/OKF4net.Cli/OkfCli.cs b/src/OKF4net.Cli/OkfCli.cs index 64522a70..ea8e21c5 100644 --- a/src/OKF4net.Cli/OkfCli.cs +++ b/src/OKF4net.Cli/OkfCli.cs @@ -658,8 +658,6 @@ private static int CmdVerify(string[] args, TextReader stdin, TextWriter stdout) } } - var writer = new BundleConceptWriter(path); - if (parsed.Has("--dry-run")) { // A dry run writes nothing, so there is no timestamp to report. It @@ -673,6 +671,9 @@ private static int CmdVerify(string[] args, TextReader stdin, TextWriter stdout) return 0; } + // Constructed only now: a dry run above never needs a writer at all. + var writer = new BundleConceptWriter(path); + // One batch call: the writer prepares every concept before writing any, // so nothing is half-stamped if a later one turns out unwritable. var outcome = writer.RecordVerifications(ids, by, at); diff --git a/tests/OKF4net.Tests/CliTests.cs b/tests/OKF4net.Tests/CliTests.cs index 232c9809..8d2036dd 100644 --- a/tests/OKF4net.Tests/CliTests.cs +++ b/tests/OKF4net.Tests/CliTests.cs @@ -1094,6 +1094,74 @@ public void Verify_refuses_to_mix_stdin_with_explicit_ids() Assert.Equal("error: \"-\" (stdin) cannot be combined with explicit concept ids\n", r.Err); } + /// + /// The write phase cannot be atomic across several files: RecordVerifications + /// writes "metrics/dau" first, THEN fails writing "metrics/rev" (made + /// unwritable below), so "dau" already landed on disk by the time the + /// batch fails. This pins the exact contract fixed twice already — once + /// in the core (b25553b, moving records.Add out of the prepare + /// loop so Records means "written", not "prepared") and once here, + /// in the verb itself, which must print every landed record BEFORE + /// throwing on !outcome.Recorded rather than swallow it. A version + /// of CmdVerify that swapped that print loop and the throw would + /// print nothing and still exit 1 -- indistinguishable from this test's + /// perspective if it only checked the exit code, which is why stdout is + /// asserted here, not just r.Code. + /// + /// Deliberately does NOT use the internal + /// hook + /// uses for the same kind of + /// injected write-time failure: constructs its + /// own private instance that a test has + /// no handle to, so that seam cannot be reached from here. Instead this + /// makes the SECOND file genuinely unwritable (read-only) before + /// invoking the verb at all -- a black-box failure any process, + /// including a real filesystem permission error, could produce. + /// + [Fact] + public void Verify_prints_the_records_that_landed_before_a_later_write_failure() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var revPath = Path.Combine(bundle, "metrics", "rev.md"); + var originalRev = File.ReadAllText(revPath); + File.SetAttributes(revPath, File.GetAttributes(revPath) | FileAttributes.ReadOnly); + + try + { + // Probe before asserting anything real depends on it: some + // environments (e.g. a CI job running as root on Linux) do not + // enforce the read-only bit at all, which would silently turn + // this into a false pass/fail rather than a skip. Restoring the + // original content afterward keeps the probe write itself inert. + try + { + File.WriteAllText(revPath, originalRev); + return; // read-only wasn't enforced on this platform/user -- skip. + } + catch (UnauthorizedAccessException) + { + // Expected: read-only is enforced here, continue. + } + + var r = Run("verify", bundle, "metrics/dau", "metrics/rev", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); + + Assert.Equal(1, r.Code); + // The concept written BEFORE the failure must be reported, not + // swallowed -- this is the assertion a swapped print/throw order + // would fail. + Assert.Equal("recorded metrics/dau human:ada 2026-08-28T09:14:00Z\n", r.Out); + Assert.StartsWith("error: ", r.Err); + Assert.DoesNotContain("recorded metrics/rev", r.Out); + // The write really landed on disk, not just in memory. + Assert.Contains("by: human:ada", File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + finally + { + File.SetAttributes(revPath, File.GetAttributes(revPath) & ~FileAttributes.ReadOnly); + } + } + /// The loop, end to end: audit lists it, verify clears it. [Fact] public void Audit_then_verify_removes_the_concept_from_the_unverified_worklist() From 55e3bbc81a37cece94f05072687bd0a473d555a4 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 22:32:11 +0200 Subject: [PATCH 14/27] test(verify): pin the verb's output with a golden --- tests/OKF4net.Tests/GoldenParityTests.cs | 25 +++++++++++++++++++ tests/fixtures/README.md | 24 ++++++++++++++++++ tests/fixtures/golden/verify-dau.md | 31 ++++++++++++++++++++++++ tests/fixtures/golden/verify.out | 2 ++ 4 files changed, 82 insertions(+) create mode 100644 tests/fixtures/golden/verify-dau.md create mode 100644 tests/fixtures/golden/verify.out diff --git a/tests/OKF4net.Tests/GoldenParityTests.cs b/tests/OKF4net.Tests/GoldenParityTests.cs index f5dd381f..f57182d6 100644 --- a/tests/OKF4net.Tests/GoldenParityTests.cs +++ b/tests/OKF4net.Tests/GoldenParityTests.cs @@ -208,6 +208,31 @@ public void Index_generation_matches_golden() Assert.Equal(8, allFiles.Length); } + /// + /// `verify` writes, so it runs against a throwaway copy of the v0.2 fixture + /// rather than the fixture itself. The golden is hand-authored and verified + /// against the design spec's output format -- there is no upstream `verify` + /// to capture. The date is pinned with --at so it cannot drift. + /// + [Fact] + public void Verify_output_matches_golden() + { + using var tmp = new TempDir(); + CopyDirectory(Path.Combine(TestPaths.RepoRoot(), "tests", "fixtures", "okf_v02"), tmp.Path); + + var r = Run("verify", tmp.Path, "metrics/dau", "metrics/legacy", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); + + Assert.Equal(0, r.Code); + // Concept ids only -- always '/'-normalized -- so no separator + // normalization is needed on any platform. + Assert.Equal(Golden("verify.out"), r.Out); + + // stdout alone would stay green if the verb printed the right line and + // wrote the wrong stamp, touched `generated`, or mangled the document. + // The written file is the artefact that matters, so it is pinned too. + Assert.Equal(Golden("verify-dau.md"), File.ReadAllText(Path.Combine(tmp.Path, "metrics", "dau.md"))); + } + private static void CopyDirectory(string sourceDir, string destDir) { Directory.CreateDirectory(destDir); diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 2591ba73..2b636095 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -201,3 +201,27 @@ them a re-capture from the (removed) Rust binary: §5.4 statuses, §5.5 staleness) rather than captured from the reference CLI: `audit` is an OKF4net verb with no upstream counterpart. The `--as-of` date is pinned so the output cannot drift with the calendar. + +## `okf verify` goldens (2026-08-28) + +- `golden/verify.out` — output of `okf verify metrics/dau + metrics/legacy --by human:ada --at 2026-08-28T09:14:00Z`. **Hand-authored**, + verified against the design spec's stated output format rather than captured + from a reference CLI: `verify` is an OKF4net verb with no upstream + counterpart. The bundle is a throwaway copy because the verb writes. The + first line carries a `(replaces …)` suffix because `okf_v02/metrics/dau.md` + already holds a `human:ada` stamp, so that run exercises the replace path + while the second line exercises the append path. +- `golden/verify-dau.md` — `metrics/dau.md` as it stands **after** that same + run. Pins what stdout cannot: that the stamp replaced the existing `human:ada` + entry **in place** (still the second entry, after `process:nightly`, which is + untouched), that `generated` was neither rewritten nor refreshed, and that no + key was added, dropped or reordered. Note that the frontmatter is re-emitted in + the YAML emitter's canonical block style, so the source fixture's flow mappings + and inline list (`tags: [engagement]`, `generated: { … }`, `usage_window: + { … }`, and the `verified`/`sources` entries) appear here expanded. That reflow + is pre-existing behaviour of every bundle write, not something `verify` does, + and pinning it is deliberate. Every scalar value other than the replaced `at` + is unchanged, as is the body. Produced by running the command once on a copy, + then **read line by line and justified by hand** before being frozen — the + inspection is the provenance, not the capture. diff --git a/tests/fixtures/golden/verify-dau.md b/tests/fixtures/golden/verify-dau.md new file mode 100644 index 00000000..62b31017 --- /dev/null +++ b/tests/fixtures/golden/verify-dau.md @@ -0,0 +1,31 @@ +--- +type: Metric +title: Daily Active Users +description: Count of distinct active users per day. +resource: https://example.com/metrics/dau +tags: + - engagement +generated: + by: okf4net/0.3.0 + at: 2026-07-01T00:00:00Z +verified: + - + by: process:nightly + at: 2026-07-02T00:00:00Z + - + by: human:ada + at: 2026-08-28T09:14:00Z +sources: + - + id: ga4 + resource: https://example.com/ga4 + usage_count: 5000 + last_modified: 2026-06-30 +usage_window: + from: 2026-06-01 + to: 2026-06-30 +status: stable +stale_after: 2099-01-01 +--- + +Daily active users. diff --git a/tests/fixtures/golden/verify.out b/tests/fixtures/golden/verify.out new file mode 100644 index 00000000..16efb448 --- /dev/null +++ b/tests/fixtures/golden/verify.out @@ -0,0 +1,2 @@ +recorded metrics/dau human:ada 2026-08-28T09:14:00Z (replaces 2026-07-03T00:00:00Z) +recorded metrics/legacy human:ada 2026-08-28T09:14:00Z From c62539a690ccba8b3d8b6e17fc6811f41a7a0d69 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 22:43:00 +0200 Subject: [PATCH 15/27] fix(verify): correct three inaccurate provenance/comment claims Review fix round 1 on the verify golden task, three Minor findings: - the test comment claimed concept ids are '/'-normalized on the stdout print path; they are echoed verbatim from the ids passed in, and the original wording named a guarantee that does not exist in the code - README's verify.out bullet said "Hand-authored" without noting the bytes were written before the run and then confirmed against it, unlike its verify-dau.md sibling bullet which was already explicit about this - README's reflow sentence labeled the `sources` entry a "flow mapping"; in the source fixture it is a compact block mapping, not a flow one, so the category label needed widening rather than the entry being dropped from the list Text-only; no golden bytes touched. --- tests/OKF4net.Tests/GoldenParityTests.cs | 4 ++-- tests/fixtures/README.md | 26 +++++++++++++----------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/OKF4net.Tests/GoldenParityTests.cs b/tests/OKF4net.Tests/GoldenParityTests.cs index f57182d6..cc7ee7be 100644 --- a/tests/OKF4net.Tests/GoldenParityTests.cs +++ b/tests/OKF4net.Tests/GoldenParityTests.cs @@ -223,8 +223,8 @@ public void Verify_output_matches_golden() var r = Run("verify", tmp.Path, "metrics/dau", "metrics/legacy", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); Assert.Equal(0, r.Code); - // Concept ids only -- always '/'-normalized -- so no separator - // normalization is needed on any platform. + // Concept ids are echoed verbatim from the '/'-form ids this test + // passes -- no separator normalization is needed on any platform. Assert.Equal(Golden("verify.out"), r.Out); // stdout alone would stay green if the verb printed the right line and diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 2b636095..a74a3db9 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -208,20 +208,22 @@ them a re-capture from the (removed) Rust binary: metrics/legacy --by human:ada --at 2026-08-28T09:14:00Z`. **Hand-authored**, verified against the design spec's stated output format rather than captured from a reference CLI: `verify` is an OKF4net verb with no upstream - counterpart. The bundle is a throwaway copy because the verb writes. The - first line carries a `(replaces …)` suffix because `okf_v02/metrics/dau.md` - already holds a `human:ada` stamp, so that run exercises the replace path - while the second line exercises the append path. + counterpart. The two lines were written into the plan before that run, then + confirmed byte-for-byte against its actual stdout — the same run that + produced `verify-dau.md` below. The bundle is a throwaway copy because the + verb writes. The first line carries a `(replaces …)` suffix because + `okf_v02/metrics/dau.md` already holds a `human:ada` stamp, so that run + exercises the replace path while the second line exercises the append path. - `golden/verify-dau.md` — `metrics/dau.md` as it stands **after** that same run. Pins what stdout cannot: that the stamp replaced the existing `human:ada` entry **in place** (still the second entry, after `process:nightly`, which is untouched), that `generated` was neither rewritten nor refreshed, and that no key was added, dropped or reordered. Note that the frontmatter is re-emitted in - the YAML emitter's canonical block style, so the source fixture's flow mappings - and inline list (`tags: [engagement]`, `generated: { … }`, `usage_window: - { … }`, and the `verified`/`sources` entries) appear here expanded. That reflow - is pre-existing behaviour of every bundle write, not something `verify` does, - and pinning it is deliberate. Every scalar value other than the replaced `at` - is unchanged, as is the body. Produced by running the command once on a copy, - then **read line by line and justified by hand** before being frozen — the - inspection is the provenance, not the capture. + the YAML emitter's canonical block style, so the source fixture's flow mappings, + inline list, and compact entries (`tags: [engagement]`, `generated: { … }`, + `usage_window: { … }`, and the `verified`/`sources` entries) appear here + expanded. That reflow is pre-existing behaviour of every bundle write, not + something `verify` does, and pinning it is deliberate. Every scalar value + other than the replaced `at` is unchanged, as is the body. Produced by running + the command once on a copy, then **read line by line and justified by hand** + before being frozen — the inspection is the provenance, not the capture. From c1706980aa15be82f3b09b8a9a01cd958a053202 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 22:50:29 +0200 Subject: [PATCH 16/27] feat(agents): expose okf_verify as a write tool --- src/OKF4net.Agents/OkfBundleTools.cs | 86 ++++++++- .../Agents/AIFunctionExposureTests.cs | 9 +- .../Agents/OkfBundleToolsTests.cs | 5 +- .../Agents/OkfVerifyToolTests.cs | 175 ++++++++++++++++++ tests/OKF4net.Tests/Mcp/OkfMcpServerTests.cs | 54 +++++- 5 files changed, 314 insertions(+), 15 deletions(-) create mode 100644 tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs diff --git a/src/OKF4net.Agents/OkfBundleTools.cs b/src/OKF4net.Agents/OkfBundleTools.cs index b70f842d..d73e32b1 100644 --- a/src/OKF4net.Agents/OkfBundleTools.cs +++ b/src/OKF4net.Agents/OkfBundleTools.cs @@ -30,6 +30,11 @@ public sealed class OkfBundleTools + "unverified, machine-confirmed, human-reviewed), status (draft, stable or deprecated) " + "and type (exact frontmatter type). Example: okf_audit(stale: true, trust: \"unverified\")."; + private const string VerifyUsageMessage = + "Usage: okf_verify records a review — comma-separated concept ids, plus a well-formed " + + "§7 actor (human:, agent:/, process:). Example: " + + "okf_verify(\"metrics/dau, metrics/revenue\", \"human:ada\")."; + /// /// The core write primitive this tool set delegates every write to: /// producer-validated create/update () and @@ -170,8 +175,8 @@ internal void InvalidateBundle() /// /// The tool names among 's output that write to the - /// bundle: okf_write_concept, okf_append_log, and - /// okf_regenerate_indexes. A host that wants a read-only tool set + /// bundle: okf_write_concept, okf_append_log, + /// okf_regenerate_indexes, and okf_verify. A host that wants a read-only tool set /// (e.g. a read-only MCP server, or a demo that must never mutate a /// pinned/shared bundle) can filter 's result /// against this set instead of hand-maintaining its own list of tool @@ -184,6 +189,7 @@ internal void InvalidateBundle() "okf_write_concept", "okf_append_log", "okf_regenerate_indexes", + "okf_verify", }; /// @@ -199,8 +205,9 @@ internal void InvalidateBundle() /// from each method's own /// — the single source of truth, so /// the two can never drift apart. The order is stable: read → browse → - /// graph → search → audit → write → append → regenerate → validate → - /// changes-since → get-computation → (conditionally) run-computation. + /// graph → search → audit → write → verify → append → regenerate → + /// validate → changes-since → get-computation → (conditionally) + /// run-computation. /// /// okf_get_computation is always included — it is read-only and /// needs no attestation runtime. okf_run_computation is included @@ -220,6 +227,7 @@ public IList GetTools() AIFunctionFactory.Create(Search, "okf_search"), AIFunctionFactory.Create(Audit, "okf_audit"), AIFunctionFactory.Create(WriteConcept, "okf_write_concept"), + AIFunctionFactory.Create(Verify, "okf_verify"), AIFunctionFactory.Create(AppendLog, "okf_append_log"), AIFunctionFactory.Create(RegenerateIndexes, "okf_regenerate_indexes"), AIFunctionFactory.Create(ValidateBundle, "okf_validate_bundle"), @@ -549,6 +557,76 @@ public string WriteConcept( [Description("The markdown body.")] string body) => _writer.WriteConcept(conceptId, frontmatterYaml, body); + /// + /// Records a review of one or more concepts: adds — or replaces — the + /// caller's { by, at } entry in each concept's verified list. + /// A stamp is a dated declaration, not a proof: this tool cannot check that + /// the caller is who names, exactly like the CLI verb. + /// + /// Comma-separated concept ids; each must already exist. + /// The §7 actor recording the review. + /// ISO-8601 timestamp; omit for now. + [Description("Record a review of one or more concepts: adds or replaces the caller's { by, at } entry in each concept's `verified` list. The stamp is a dated declaration, not a proof — the same rules as the okf verify CLI verb.")] + public string Verify( + [Description("Comma-separated concept ids (paths without .md). Explicit ids only — there is no whole-bundle form.")] string conceptIds, + [Description("The §7 actor recording the review, e.g. human:ada, agent:assistant/1.0, process:nightly.")] string by, + [Description("ISO-8601 UTC timestamp; omit for now.")] string? at = null) + { + var ids = (conceptIds ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + if (ids.Count == 0 || by is null || !Actor.Parse(by).IsWellFormed) + { + return VerifyUsageMessage; + } + + return RunTool(() => + { + // Pre-resolved like the CLI: every id is checked before the + // first write, so a typo in the third id cannot leave the first two + // stamped. Without this, `okf_verify("a, nope", …)` writes to `a` + // and then reports a failure — the worst of both. + var bundle = GetBundle(); + foreach (var id in ids) + { + if (!ConceptId.TryParse(id, out var parsedId) || bundle.Get(parsedId!) is null) + { + return $"Error: concept '{id}' does not exist."; + } + } + + // One batch call — the validation guarantee comes from the writer, so the + // pre-resolution above is only there to give a nicer message. + // `at` is passed through untouched, null included: the writer owns + // the clock seam and reports the timestamp it used, so the tool + // never dates anything itself. + var outcome = _writer.RecordVerifications(ids, by, at); + + // The same line shape as the CLI verb, deliberately re-implemented + // rather than shared: the CLI's bytes are golden-locked and must not + // move because an agent-facing string was tuned. The tool's tests + // assert this exact shape so the two cannot drift unnoticed. + var lines = new StringBuilder(); + foreach (var record in outcome.Records) + { + var replaces = record.ReplacedAt is { } previous ? $" (replaces {previous})" : string.Empty; + lines.Append($"recorded {record.ConceptId} {by} {record.At}{replaces}").Append('\n'); + } + + // A rejected batch has no records and yields the message alone; a + // batch that failed part-way through writing has both, and the + // agent must see both — the lines for what landed, then why it + // stopped. + if (!outcome.Recorded) + { + lines.Append(outcome.Message).Append('\n'); + } + + return lines.ToString(); + }); + } + /// /// Atomically reads, transforms, and rewrites one concept's body — the /// seam uses diff --git a/tests/OKF4net.Tests/Agents/AIFunctionExposureTests.cs b/tests/OKF4net.Tests/Agents/AIFunctionExposureTests.cs index 4eb1d72c..84c5d4ad 100644 --- a/tests/OKF4net.Tests/Agents/AIFunctionExposureTests.cs +++ b/tests/OKF4net.Tests/Agents/AIFunctionExposureTests.cs @@ -9,7 +9,7 @@ namespace OKF4net.Tests.Agents; /// -/// Tests : the eleven tool methods +/// Tests : the twelve tool methods /// exposed as Agent Framework s (via /// ) when no attestation orchestrator is wired (so /// okf_run_computation is omitted; see @@ -30,6 +30,7 @@ public class AIFunctionExposureTests "okf_search", "okf_audit", "okf_write_concept", + "okf_verify", "okf_append_log", "okf_regenerate_indexes", "okf_validate_bundle", @@ -38,14 +39,14 @@ public class AIFunctionExposureTests ]; [Fact] - public void GetTools_returns_exactly_eleven_tools() + public void GetTools_returns_exactly_twelve_tools() { var tools = new OkfBundleTools(BundlePath); - Assert.Equal(11, tools.GetTools().Count); + Assert.Equal(12, tools.GetTools().Count); } [Fact] - public void GetTools_names_are_the_eleven_snake_case_names_in_stable_order() + public void GetTools_names_are_the_twelve_snake_case_names_in_stable_order() { var tools = new OkfBundleTools(BundlePath); var names = tools.GetTools().Cast().Select(f => f.Name).ToList(); diff --git a/tests/OKF4net.Tests/Agents/OkfBundleToolsTests.cs b/tests/OKF4net.Tests/Agents/OkfBundleToolsTests.cs index 6fd5fb59..12f50bc4 100644 --- a/tests/OKF4net.Tests/Agents/OkfBundleToolsTests.cs +++ b/tests/OKF4net.Tests/Agents/OkfBundleToolsTests.cs @@ -40,10 +40,10 @@ public void GetBundle_loads_appendix_a_fixture() /// fail this test rather than leaking into a "read-only" consumer. /// [Fact] - public void WriteToolNames_matches_the_three_mutating_tools_and_filters_them_out() + public void WriteToolNames_matches_the_four_mutating_tools_and_filters_them_out() { Assert.Equal( - new HashSet { "okf_write_concept", "okf_append_log", "okf_regenerate_indexes" }, + new HashSet { "okf_write_concept", "okf_append_log", "okf_regenerate_indexes", "okf_verify" }, OkfBundleTools.WriteToolNames); var tools = new OkfBundleTools(BundlePath); @@ -57,6 +57,7 @@ public void WriteToolNames_matches_the_three_mutating_tools_and_filters_them_out Assert.DoesNotContain("okf_write_concept", readOnlyNames); Assert.DoesNotContain("okf_append_log", readOnlyNames); Assert.DoesNotContain("okf_regenerate_indexes", readOnlyNames); + Assert.DoesNotContain("okf_verify", readOnlyNames); Assert.Contains("okf_read_concept", readOnlyNames); Assert.Contains("okf_get_computation", readOnlyNames); Assert.Contains("okf_audit", readOnlyNames); diff --git a/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs new file mode 100644 index 00000000..bb0a1739 --- /dev/null +++ b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +using Microsoft.Extensions.AI; +using OKF4net.Agents; + +namespace OKF4net.Tests.Agents; + +/// +/// Tests for okf_verify. The tool is symmetric with the CLI verb — same +/// actors accepted, `human:` included — a deliberate decision: a stamp is a +/// declaration, and its credibility comes from landing in a reviewed diff, not +/// from the tool that wrote it. Being a mutator, it belongs to +/// and disappears from a read-only +/// deployment. +/// +public class OkfVerifyToolTests +{ + private static OkfBundleTools ToolsOver(TempDir tmp) => + new(tmp.Path) { UtcNow = () => new DateTime(2026, 8, 28, 9, 14, 0, DateTimeKind.Utc) }; + + [Fact] + public void Verify_records_a_stamp() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + + var text = ToolsOver(tmp).Verify("metrics/dau", "human:ada"); + + // Byte-identical to the CLI verb's line — the two renderers are + // separate on purpose, so only an exact assertion keeps them aligned. + Assert.Equal("recorded metrics/dau human:ada 2026-08-28T09:14:00Z\n", text); + Assert.Contains("by: human:ada", File.ReadAllText(Path.Combine(tmp.Path, "metrics", "dau.md"))); + } + + [Fact] + public void Verify_is_registered_and_is_a_write_tool() + { + using var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + + Assert.Contains("okf_verify", ToolsOver(tmp).GetTools().OfType().Select(t => t.Name)); + Assert.Contains("okf_verify", OkfBundleTools.WriteToolNames); + } + + [Fact] + public void Verify_returns_a_usage_message_for_a_malformed_actor() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + + Assert.Contains("Usage: okf_verify", ToolsOver(tmp).Verify("metrics/dau", "human:")); + } + + [Fact] + public void Verify_reports_an_unknown_concept_without_writing() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + + var text = ToolsOver(tmp).Verify("metrics/nope", "human:ada"); + + Assert.Contains("does not exist", text); + Assert.False(File.Exists(Path.Combine(tmp.Path, "metrics", "nope.md"))); + } + + /// + /// All-or-nothing across the whole list: one unknown id leaves every other + /// concept untouched. A single-id test cannot catch this. + /// + [Fact] + public void Verify_refuses_a_concept_named_twice() + { + using var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + var before = File.ReadAllText(Path.Combine(tmp.Path, "a.md")); + + var text = ToolsOver(tmp).Verify("a, a", "human:ada"); + + Assert.Contains("named more than once", text); + Assert.Equal(before, File.ReadAllText(Path.Combine(tmp.Path, "a.md"))); + } + + [Fact] + public void Verify_writes_nothing_when_one_id_of_several_is_unknown() + { + using var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + var before = File.ReadAllText(Path.Combine(tmp.Path, "a.md")); + + var text = ToolsOver(tmp).Verify("a, nope", "human:ada"); + + Assert.Contains("does not exist", text); + Assert.DoesNotContain("recorded a", text); + Assert.Equal(before, File.ReadAllText(Path.Combine(tmp.Path, "a.md"))); + } + + /// + /// The schema is what decides what a bare call means, like okf_audit's: + /// the two ids/actor parameters required, the timestamp optional. + /// + [Fact] + public void Verify_schema_requires_ids_and_actor_but_not_at() + { + var tools = new OkfBundleTools(Path.Combine(TestPaths.RepoRoot(), "tests", "fixtures", "okf_v02")); + var function = tools.GetTools().OfType().Single(t => t.Name == "okf_verify"); + var properties = function.JsonSchema.GetProperty("properties"); + + foreach (var name in new[] { "conceptIds", "by", "at" }) + { + Assert.True(properties.TryGetProperty(name, out _), $"schema should declare '{name}'."); + } + + var required = function.JsonSchema.GetProperty("required").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains("conceptIds", required); + Assert.Contains("by", required); + Assert.DoesNotContain("at", required); + } + + /// + /// Invoked through the framework's own binding, not by calling the C# + /// method: the arguments arrive as JSON and must reach the parameters for + /// the stamp to land. A tool can be registered, schema-correct and still + /// unusable from a host if that binding is wrong. + /// + [Fact] + public async Task Verify_stamps_when_invoked_through_the_AIFunction_binding() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + var tools = ToolsOver(tmp); + var function = tools.GetTools().OfType().Single(t => t.Name == "okf_verify"); + + // Same shape as okf_read_concept's invocation test + // (AIFunctionExposureTests.cs:223) — including the null-forgiving `!`, + // which that call needs too. + var arguments = new AIFunctionArguments(new Dictionary + { + ["conceptIds"] = "metrics/dau", + ["by"] = "human:ada", + ["at"] = "2026-08-28T09:14:00Z", + }!); + await function.InvokeAsync(arguments); + + // The emitter writes sequences in BLOCK style — a bare `-`, then the + // mapping indented under it (verified by running `okf fmt`) — so assert + // the two lines, never a flow-style `- { by: …, at: … }`. + var text = File.ReadAllText(Path.Combine(tmp.Path, "metrics", "dau.md")); + Assert.Contains("by: human:ada", text); + Assert.Contains("at: 2026-08-28T09:14:00Z", text); + } + + /// A bundle that vanishes after construction surfaces as an error string, never an exception. + [Fact] + public void Verify_returns_an_error_string_when_the_bundle_is_gone() + { + var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + var tools = ToolsOver(tmp); + tmp.Dispose(); + + Assert.StartsWith("Error: ", tools.Verify("a", "human:ada")); + } + + [Fact] + public void Verify_stamps_every_id_in_a_comma_separated_list() + { + using var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + tmp.Write("b.md", "---\ntype: Metric\n---\n\nbody\n"); + + var text = ToolsOver(tmp).Verify("a, b", "human:ada"); + + Assert.Contains("recorded a human:ada", text); + Assert.Contains("recorded b human:ada", text); + } +} diff --git a/tests/OKF4net.Tests/Mcp/OkfMcpServerTests.cs b/tests/OKF4net.Tests/Mcp/OkfMcpServerTests.cs index 9b0485d4..58861a3a 100644 --- a/tests/OKF4net.Tests/Mcp/OkfMcpServerTests.cs +++ b/tests/OKF4net.Tests/Mcp/OkfMcpServerTests.cs @@ -76,7 +76,7 @@ public async Task Write_then_read_round_trips_through_mcp() } [Fact] - public async Task Build_exposes_all_eleven_tools() + public async Task Build_exposes_all_twelve_tools() { var bundle = NewBundleDir(); try @@ -94,7 +94,7 @@ public async Task Build_exposes_all_eleven_tools() { "okf_append_log", "okf_audit", "okf_browse", "okf_changes_since", "okf_get_computation", "okf_graph", "okf_read_concept", "okf_regenerate_indexes", "okf_search", - "okf_validate_bundle", "okf_write_concept", + "okf_validate_bundle", "okf_verify", "okf_write_concept", }, names); @@ -110,7 +110,7 @@ public async Task Build_exposes_all_eleven_tools() } [Fact] - public async Task Build_readOnly_omits_the_three_write_tools() + public async Task Build_readOnly_omits_the_four_write_tools() { var bundle = NewBundleDir(); try @@ -126,6 +126,7 @@ public async Task Build_readOnly_omits_the_three_write_tools() Assert.DoesNotContain("okf_write_concept", names); Assert.DoesNotContain("okf_append_log", names); Assert.DoesNotContain("okf_regenerate_indexes", names); + Assert.DoesNotContain("okf_verify", names); Assert.Contains("okf_read_concept", names); // okf_get_computation is read-only and needs no attestation runtime, // so it surfaces in read-only mode too -- this is deliberate. @@ -140,7 +141,7 @@ public async Task Build_readOnly_omits_the_three_write_tools() } [Fact] - public void ConfigureServices_registers_all_eleven_tools() + public void ConfigureServices_registers_all_twelve_tools() { var bundle = NewBundleDir(); try @@ -149,7 +150,7 @@ public void ConfigureServices_registers_all_eleven_tools() OkfMcpHost.ConfigureServices(services, bundle, readOnly: false, version: "0.0.0"); using var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Value; - Assert.Equal(11, options.ToolCollection?.Count); + Assert.Equal(12, options.ToolCollection?.Count); } finally { @@ -228,4 +229,47 @@ await File.WriteAllTextAsync( Directory.Delete(bundle, recursive: true); } } + + /// + /// The other MCP tests only prove okf_verify appears in the tool + /// list (and, in read-only mode, does not). This one calls it, because the + /// MCP adapter does its own schema-driven argument conversion for the two + /// required string parameters plus the optional timestamp — a conversion + /// or binding regression could ship while the Agent-level test stayed + /// green. On the same model as . + /// + [Fact] + public async Task Verify_tool_invoked_over_mcp_stamps_the_concept() + { + var bundle = NewBundleDir(); + try + { + Directory.CreateDirectory(Path.Combine(bundle, "metrics")); + await File.WriteAllTextAsync( + Path.Combine(bundle, "metrics", "dau.md"), + "---\ntype: Metric\ntitle: DAU\n---\n"); + + var tools = OkfMcpToolset.Build(bundle, readOnly: false); + var (server, client) = await ConnectAsync(tools); + await using var _ = server; + await using var __ = client; + + var verify = await client.CallToolAsync( + "okf_verify", + new Dictionary + { + ["conceptIds"] = "metrics/dau", + ["by"] = "human:ada", + ["at"] = "2026-08-28T09:14:00Z", + }); + + var text = ResultText(verify); + Assert.Contains("recorded metrics/dau human:ada 2026-08-28T09:14:00Z", text); + Assert.Contains("by: human:ada", await File.ReadAllTextAsync(Path.Combine(bundle, "metrics", "dau.md"))); + } + finally + { + Directory.Delete(bundle, recursive: true); + } + } } From 196b8d2d7fd4eca989c3d616646d2d793351957a Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 23:08:40 +0200 Subject: [PATCH 17/27] fix(agents): close okf_verify's records-swallow gap, replaces coverage, and offender naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a genuine write-phase partial-failure test (read-only second file), discriminating both the print/append swap and the record-swallow mutation of Verify's rendering. - Add a full-line test for the '(replaces ...)' suffix, previously untested and claimed-but-unverified by a code comment. - Mirror the CLI's §11 conformance pre-check so a rejected batch names the offending concept instead of a bare writer error. - Document the writer's exact yyyy-MM-ddTHH:mm:ssZ timestamp format in the 'at' parameter's description. --- src/OKF4net.Agents/OkfBundleTools.cs | 30 ++++- .../Agents/OkfVerifyToolTests.cs | 121 ++++++++++++++++++ 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/src/OKF4net.Agents/OkfBundleTools.cs b/src/OKF4net.Agents/OkfBundleTools.cs index d73e32b1..fab0546b 100644 --- a/src/OKF4net.Agents/OkfBundleTools.cs +++ b/src/OKF4net.Agents/OkfBundleTools.cs @@ -565,12 +565,16 @@ public string WriteConcept( /// /// Comma-separated concept ids; each must already exist. /// The §7 actor recording the review. - /// ISO-8601 timestamp; omit for now. + /// + /// UTC timestamp in the exact form yyyy-MM-ddTHH:mm:ssZ (the writer's + /// rejects fractional + /// seconds, a numeric offset, and a bare date); omit for now. + /// [Description("Record a review of one or more concepts: adds or replaces the caller's { by, at } entry in each concept's `verified` list. The stamp is a dated declaration, not a proof — the same rules as the okf verify CLI verb.")] public string Verify( [Description("Comma-separated concept ids (paths without .md). Explicit ids only — there is no whole-bundle form.")] string conceptIds, [Description("The §7 actor recording the review, e.g. human:ada, agent:assistant/1.0, process:nightly.")] string by, - [Description("ISO-8601 UTC timestamp; omit for now.")] string? at = null) + [Description("UTC timestamp in the exact form yyyy-MM-ddTHH:mm:ssZ, e.g. 2026-08-28T09:14:00Z — no fractional seconds, no offset, no bare date. Omit for now.")] string? at = null) { var ids = (conceptIds ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) @@ -583,17 +587,29 @@ public string Verify( return RunTool(() => { - // Pre-resolved like the CLI: every id is checked before the - // first write, so a typo in the third id cannot leave the first two - // stamped. Without this, `okf_verify("a, nope", …)` writes to `a` - // and then reports a failure — the worst of both. + // Pre-resolved like the CLI (OkfCli.cs's CmdVerify): every id is + // checked for BOTH existence and §11 conformance before the first + // write, so a typo or a typeless draft in the third id cannot + // leave the first two stamped. Existence alone would not be + // enough: Bundle indexes any document that parses, including one + // with no `type`, which the writer then refuses at write time — + // naming the offender only through the writer's own error would + // leave an agent bisecting an eight-id batch by hand to find + // which one lacks `type`. Without either check here at all, + // `okf_verify("a, nope", …)` would write to `a` and then report a + // failure — the worst of both. var bundle = GetBundle(); foreach (var id in ids) { - if (!ConceptId.TryParse(id, out var parsedId) || bundle.Get(parsedId!) is null) + if (!ConceptId.TryParse(id, out var parsedId) || bundle.Get(parsedId!) is not { } concept) { return $"Error: concept '{id}' does not exist."; } + + if (concept.Document.Frontmatter.Get("type") is not { IsEmptyValue: false }) + { + return $"Error: concept '{id}' has no `type` and is not §11-conformant."; + } } // One batch call — the validation guarantee comes from the writer, so the diff --git a/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs index bb0a1739..ccd2041d 100644 --- a/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs +++ b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs @@ -172,4 +172,125 @@ public void Verify_stamps_every_id_in_a_comma_separated_list() Assert.Contains("recorded a human:ada", text); Assert.Contains("recorded b human:ada", text); } + + /// + /// The write phase cannot be atomic across several files: "a" lands on + /// disk first, THEN the write to "b" fails (made unwritable below), so + /// "a" is already stamped by the time the batch fails. This pins the + /// exact contract fixed twice already — once in the core (b25553b, moving + /// records.Add out of the prepare loop so Records means + /// "written", not "prepared") and once in the CLI verb + /// (CliTests.Verify_prints_the_records_that_landed_before_a_later_write_failure) + /// — and now here, in the tool, which must render every landed record + /// BEFORE appending outcome.Message on !outcome.Recorded, + /// never swallow it. A version of that + /// swapped the records loop and the message append, or that returned + /// outcome.Message alone on failure, would produce text that does + /// not start with the "recorded a" line — exactly what this test would + /// catch and the three all-during-PREPARE tests above cannot, since none + /// of them ever populates Records. + /// + /// Same black-box technique as CliTests's test: made genuinely + /// unwritable via the read-only attribute (not the internal + /// seam, + /// which is private to whatever instance + /// a test holds a reference to), with the same enforcement probe/skip + /// guard — some environments (e.g. a CI job running as root on Linux) do + /// not enforce the read-only bit at all. + /// + [Fact] + public void Verify_reports_the_records_that_landed_before_a_later_write_failure() + { + using var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\ntitle: A\n---\n\nbody\n"); + var bPath = tmp.Write("b.md", "---\ntype: Metric\ntitle: B\n---\n\nbody\n"); + var originalB = File.ReadAllText(bPath); + File.SetAttributes(bPath, File.GetAttributes(bPath) | FileAttributes.ReadOnly); + + try + { + try + { + File.WriteAllText(bPath, originalB); + return; // read-only wasn't enforced on this platform/user -- skip. + } + catch (UnauthorizedAccessException) + { + // Expected: read-only is enforced here, continue. + } + + var text = ToolsOver(tmp).Verify("a, b", "human:ada"); + + // The concept written BEFORE the failure must be reported FIRST, + // not swallowed by an early `if (!outcome.Recorded) return + // outcome.Message + "\n";`, and not reordered after the error + // line. Note: unlike the CLI's own equivalent black-box test + // (CliTests.Verify_prints_the_records_that_landed_before_a_later_write_failure), + // this deliberately does not assert on "already written: a" -- + // WriteValidatedContentLocked does not itself catch + // UnauthorizedAccessException (only BundleConceptWriter's OUTER + // RunTool does, generically, with no knowledge of `records`), so + // a genuine I/O failure here never reaches the per-record + // "{writeResult} — already written: ..." branch at all; that + // branch is only reachable via the late reparse-point guard's + // returned (not thrown) error string. Confirmed empirically by + // running this exact scenario before writing this assertion. + Assert.StartsWith("recorded a human:ada 2026-08-28T09:14:00Z\n", text); + Assert.DoesNotContain("recorded b", text); + Assert.Contains("Error:", text); + // The write really landed on disk, not just in memory. + Assert.Contains("by: human:ada", File.ReadAllText(Path.Combine(tmp.Path, "a.md"))); + } + finally + { + File.SetAttributes(bPath, File.GetAttributes(bPath) & ~FileAttributes.ReadOnly); + } + } + + /// + /// Re-verifying the same concept with a different at replaces the + /// prior stamp rather than appending a second one — and the rendered line + /// carries a (replaces ...) suffix naming the timestamp it + /// replaced, byte-identical to OkfCli.cs's CmdVerify + /// rendering. None of the other tests in this file ever re-verify the + /// same concept, so record.ReplacedAt is null in all of them and + /// this branch is otherwise untested. + /// + [Fact] + public void Verify_renders_a_replaces_suffix_when_reverifying_the_same_concept() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + var tools = ToolsOver(tmp); + + tools.Verify("metrics/dau", "human:ada", "2026-01-01T00:00:00Z"); + var text = tools.Verify("metrics/dau", "human:ada", "2026-02-02T00:00:00Z"); + + Assert.Equal( + "recorded metrics/dau human:ada 2026-02-02T00:00:00Z (replaces 2026-01-01T00:00:00Z)\n", + text); + } + + /// + /// Mirrors the CLI's own resolution loop (OkfCli.cs's + /// CmdVerify): every id is checked for existence AND §11 + /// conformance (non-empty type) before anything is written, and the + /// rejection names the offending id — not a bare, unattributed writer + /// error — so an agent clearing an okf_audit worklist in one call + /// does not have to bisect an eight-id batch by hand to find which one + /// lacks type. + /// + [Fact] + public void Verify_names_the_non_conformant_concept_when_rejecting_a_batch() + { + using var tmp = new TempDir(); + tmp.Write("a.md", "---\ntype: Metric\n---\n\nbody\n"); + tmp.Write("b.md", "---\ntitle: No Type\n---\n\nbody\n"); + var before = File.ReadAllText(Path.Combine(tmp.Path, "a.md")); + + var text = ToolsOver(tmp).Verify("a, b", "human:ada"); + + Assert.Contains("concept 'b' has no `type` and is not §11-conformant", text); + Assert.Equal(before, File.ReadAllText(Path.Combine(tmp.Path, "a.md"))); + } } From 6fdb01de98ef199f2eff668ea17cb5cfaf810568 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 23:38:55 +0200 Subject: [PATCH 18/27] docs(verify): document the verb, the tool and what a stamp does not prove Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- CHANGELOG.md | 39 +++++++++++++++++++ CLAUDE.md | 3 +- README.md | 52 +++++++++++++++++++++---- ROADMAP.md | 26 ++++++++++++- src/OKF4net.Agents/README.md | 16 +++++--- src/OKF4net.Mcp/OkfMcpToolset.cs | 2 +- src/OKF4net.Mcp/README.md | 27 +++++++------ web/src/pages/Cli.tsx | 11 +++++- web/src/pages/Home.tsx | 7 ++-- web/src/pages/Library.tsx | 2 +- web/src/pages/docs/Cli.tsx | 65 ++++++++++++++++++++++++++++++-- web/src/pages/docs/Library.tsx | 9 +++-- 12 files changed, 217 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3284df3..99209ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,24 @@ and this project adheres to `--stale`, `--trust`, `--status`, `--type`, `--as-of` and `--json`. Backed by the new `ConceptAudit` in the core library and exposed to agents as the read-only `okf_audit` tool. +- **`okf verify … --by `** — the verb that answers what + `okf audit` asks: it records a review (§5.2) by adding, or from the same + actor replacing, a `{by, at}` entry in each named concept's `verified` + list, so a reviewed concept leaves the audit worklist. `…` also accepts + a single `-`, reading concept ids from standard input, so + `okf audit … --trust unverified | cut -d' ' -f1 | okf verify … --by + human:ada -` closes the loop in one line. `--dry-run` shows what would be + recorded without writing; `--at` overrides the default of "now". A batch + is validated (existence, §11 conformance, no duplicate id) before the + first write, but writing several files cannot be atomic — a mid-batch I/O + failure still leaves the earlier concepts stamped, and is reported as + such. Backed by the new `BundleConceptWriter.RecordVerifications` in the + core library — the single governed writer of `verified` — and exposed to + agents as the `okf_verify` tool. **A `verified` stamp is a dated + declaration, not a proof**: it cannot and does not authenticate the + signer's identity, nor confirm anyone read the concept. Credibility comes + from where the stamp lands — a diff a human reviewed — never from + inferring one out of a PR approval. - **`okf render --out `** generates a self-contained, browsable HTML site from a bundle: one page per concept (frontmatter table + rendered body), a generated index, navigable cross-links with broken links flagged, @@ -67,6 +85,27 @@ and this project adheres to are unaffected. The same rewrite also fixes a token consumed as a flag's value still counting as a flag: `okf audit b --type --stale` no longer sets the stale filter. +- **`OkfCli.Run` gains a `TextReader stdin` parameter** (now + `Run(args, stdin, stdout, stderr)`), so `verify -` can read concept ids + from standard input without every other verb paying for a blocking read. + This is a breaking change to a public API signature, but it breaks no + external caller: `OKF4net.Cli` is the only project under `src/` with no + `PackageId`/`IsPackable` — it ships only as the `okf` binary, never + published as a library — and the sole call site outside `Program.cs` is + the test suite's `TestPaths.cs`, updated alongside it. +- **`--` now keeps the positionals given before it, instead of discarding + them.** The separator used to let the token right after it take the single + positional slot outright, so `okf a -- b` resolved to `b`; verbs now + keep every positional in order, `--` included, so the same invocation + resolves to `a`. This is what makes `verify …`'s multiple + positionals possible — a single "the positional" slot could never have + held more than one concept id. +- **A lone `-` is now a positional argument, not a flag.** The flag scan + previously matched any token starting with `-`, including the bare + character, so `-` was silently absorbed as a valueless, meaningless flag. + It now falls through to the positional list, which is what lets + `okf verify -` mean "read concept ids from standard input" — the + POSIX convention — instead of being swallowed before `verify` ever sees it. - **`okf validate` gains `--as-of `**, pinning the date its §5.5 staleness warning is evaluated against. `BundleValidator.Validate` already accepted a clock, but the verb exposed no way to set one, so its diff --git a/CLAUDE.md b/CLAUDE.md index 7dbba16e..d945bcd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,10 +35,11 @@ Requires .NET SDK 10.0+. CI (ci.yml) runs build+test on Linux/Windows/macOS, `do - **`src/OKF4net/`** — the library. One file per spec concern, following the OKF reference implementation's structure: `ConceptId` (§2), `Bundle` (§3, permissive loading — parse failures go into `Bundle.ParseErrors`, never abort), `OkfDocument`/`Frontmatter` (§4), `Links.cs`/`LinkScanner` (§6, legacy citations §13.1), `IndexGenerator` (§8), `ChangeLog` (§9), `Validate.cs`/`BundleValidator` (§11). The README has the full spec-section → type mapping table. - `ConceptSearch` — the single shared full-text scorer (title x3, tags/description x2, body x1) used by both `OKF4net.Agents` (`okf_search`/context provider) and `OKF4net.Catalog` (`OkfBundleKnowledgeSource`, `FileMemoryStore`); do not fork a second scorer in either consumer. - `Audit.cs` — `ConceptAudit`, the single shared corpus-level query behind both `okf audit` and the `okf_audit` tool; the two renderers are deliberately separate (the CLI's bytes are golden-locked), but the computation and the `AuditVocabulary` labels must not be forked. + - `BundleConceptWriter.RecordVerifications` — the single governed writer of the §5.2 `verified` field, behind both `okf verify` and the `okf_verify` tool; do not fork a second write path. A stamp it writes is a dated declaration, not a proof (it cannot authenticate `--by`) — see the README's `okf verify` section for the full caveat. - `Yaml/` — the documented YAML *subset* (scalars, lists, shallow maps, block/flow, `|`/`>`); it deliberately rejects anchors/tags/multi-docs with clear errors. `Frontmatter` wraps an order-preserving `YamlMapping` with typed getters rather than a fixed DTO, so unknown producer keys survive round-trips. - `Internal/LfLines.cs` — the single shared line splitter (splits on `\n` only, stripping a preceding `\r`). Use it anywhere `\n`-based line splitting matters; do not reintroduce private copies. - `Internal/ReparsePoints.cs` — internal symlink/junction detection; `OKF4net.Catalog` is granted `InternalsVisibleTo` so it can reuse this seam rather than duplicating a platform-specific implementation. -- **`src/OKF4net.Cli/`** — the `okf` binary (`validate`/`audit`/`info`/`index`/`graph`/`parse`/`fmt`/`render`), published Native AOT (`PublishAot`, `InvariantGlobalization`). All logic lives in `OkfCli.Run(args, out, err)` so tests invoke it in-process without spawning a process. +- **`src/OKF4net.Cli/`** — the `okf` binary (`validate`/`audit`/`verify`/`info`/`index`/`graph`/`parse`/`fmt`/`render`), published Native AOT (`PublishAot`, `InvariantGlobalization`). All logic lives in `OkfCli.Run(args, stdin, out, err)` so tests invoke it in-process without spawning a process; `stdin` is read only by `verify -` (concept ids, one per line), no other verb touches it. - **`src/OKF4net.Attestation/`** — zero-dep §10 attested-computation orchestration, referencing only `OKF4net`. Defines the host-plugged contracts (`IParameterBinder`, `IComputationExecutor`, `IAttester`, resolved per concept's `runtime` field through `IAttestationRuntimeRegistry`) and the value types that flow between them (`BoundComputation`, `Receipt`, `AttestationVerdict`, `AttestationContext`, `AttestationOutcome`); `AttestationOrchestrator.RunAsync` drives one run end to end (resolve → bind → execute → receipt-shape check → attest → gate on verdict + `stale_after`), errors-as-data, never writing a verdict back to the bundle (§10.6). Referenced by `OKF4net.Agents` to back `okf_run_computation`. - **`src/OKF4net.Agents/`** — Microsoft Agent Framework layer exposing OKF bundle operations as function tools (e.g. `OkfBundleTools`) plus `OkfContextProvider`, an `AIContextProvider` that auto-injects budget-bounded bundle context and captures deterministic per-day memory concepts; the only project depending on `Microsoft.Agents.AI`. - **`src/OKF4net.Catalog/`** — knowledge-catalog model and logic, referencing only `OKF4net` (BCL otherwise; zero `PackageReference`). Depended on by `OKF4net.Catalog.Hosting`. Each manifest source carries a `role` (`SourceRole`): `Knowledge` (read-only, searched by `IKnowledgeResolver`) or `Memory` (writable, scoped by a required `tier` — `session`/`user`/`tenant`, all three backed by `FileMemoryStore`, fed by `IMemoryStore`, never searched by the resolver); any other `role` string in `catalog.json` is rejected (`CatalogDiagnosticCode.IllegalRole`). diff --git a/README.md b/README.md index 5f9afe68..7154a12b 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ other project layers a specific integration on top and points back to it. | Project | NuGet package | Responsibility | Deep dive | |--------------------------|---------------------------|----------------------------------------------------------------------------|--------------------------------------------------------------| | `OKF4net` | `OKF4net` | Zero-dependency core library: parse, validate, index, graph OKF bundles. | [Library overview](#library-overview) | -| `OKF4net.Cli` | — (Native AOT `okf` binary, no PackageId) | The `okf` command-line tool (`validate`/`info`/`index`/`graph`/`parse`/`fmt`/`render`). | [As a CLI](#as-a-cli) | +| `OKF4net.Cli` | — (Native AOT `okf` binary, no PackageId) | The `okf` command-line tool (`validate`/`audit`/`verify`/`info`/`index`/`graph`/`parse`/`fmt`/`render`). | [As a CLI](#as-a-cli) | | `OKF4net.Viewer` | — (ships inside the `okf` binary, not packed by `release.yml`) | Static HTML site generation for a bundle; backs the `okf render` verb. | [As a CLI](#as-a-cli) | | `OKF4net.Agents` | `OKF4net.Agents` | Microsoft Agent Framework tools + `OkfContextProvider` (context & memory). | [Microsoft Agent Framework](#using-okf4net-with-microsoft-agent-framework) | | `OKF4net.Catalog` | `OKF4net.Catalog` | Local catalog of OKF bundles: `catalog.json` manifest + source resolver. | [Local catalog](#local-catalog-okf4netcatalog) · [README](src/OKF4net.Catalog/README.md) | @@ -177,6 +177,7 @@ On any OS, build from source — see [Building & testing](#building--testing). ``` okf validate Check a bundle against OKF v0.2 conformance (§11) okf audit Report trust, freshness and lifecycle across the bundle +okf verify … Record a review of one or more concepts (--by ) okf info Summarize a bundle (concepts, types, links, version) okf index (Re)generate every index.md in the bundle okf graph Print the cross-link graph (--dot for Graphviz DOT) @@ -214,6 +215,41 @@ verdict should pin the date rather than let the calendar move under it. Note the always cover the whole bundle while `findings` covers the selection: `audit` is a worklist, not an inventory (use `okf info --json` for that). +`okf verify … --by ` records a review (§5.2): it adds — or, +for a repeat review from the same actor, replaces — a `{ by, at }` entry in +each named concept's `verified` list. It is the verb that answers what +`okf audit` asks: audit finds what needs a look, verify records that the look +happened, and the reviewed concept leaves the worklist. `…` also accepts a +single `-`, reading one concept id per line from standard input, so the two +verbs compose into one line: + +```sh +okf audit bundles/acme_retail --trust unverified | cut -d' ' -f1 | okf verify bundles/acme_retail --by human:ada - +``` + +Every named concept is checked for existence and §11 conformance before +anything is written, so a batch is rejected as a whole at that stage; a +mid-batch I/O failure can still leave the concepts already written stamped +(`okf verify`'s output lists exactly what landed). `--dry-run` prints what +would be recorded without writing anything; `--at ` overrides the +default of "now" for reproducible scripting. + +> **What a `verified` stamp does and doesn't prove.** It guarantees the +> stamp is well-formed, dated, and attached to the concepts named — nothing +> more. It does **not** guarantee the signer's identity, nor that anyone +> actually read the concept: no zero-dependency tool can authenticate `--by`, +> and `okf_write_concept` can write the exact same field with no ceremony at +> all — deliberately unguarded, since a full frontmatter rewrite (importing a +> bundle, correcting a concept) has to be able to touch `verified` too. +> Credibility comes from *where the stamp lands*: in a diff a human reviewed, +> under branch protection, where the reviewer sees the assertion and can +> reject it. **Never infer a stamp from a PR approval** — that turns "a human +> approved this diff" into "a human vouches for this knowledge," which are +> different every time a PR touches a file for a reason other than reviewing +> it (which is most of the time). Doing so would mass-promote every concept +> the diff happens to touch and silently empty the very worklist this feature +> exists to populate. + Generate a browsable HTML site from a bundle: ```sh @@ -235,8 +271,8 @@ machine. Full command reference with real output samples: `src/OKF4net.Agents/` exposes bundle operations as function tools for the [Microsoft Agent Framework](https://github.com/microsoft/agent-framework): `OkfBundleTools` wraps one bundle root and its `GetTools()` method returns -eleven ready-to-use `AITool`s unconditionally, which `AsAIAgent` turns into an -agent's tool list, plus a twelfth — `okf_run_computation` — only when the +twelve ready-to-use `AITool`s unconditionally, which `AsAIAgent` turns into an +agent's tool list, plus a thirteenth — `okf_run_computation` — only when the tool set is constructed with an `OKF4net.Attestation` orchestrator wired in (see [Attested computation](#attested-computation-okf4netattestation)). @@ -253,9 +289,9 @@ var response = await agent.RunAsync("Search the bundle for concepts about refund Console.WriteLine(response.Text); ``` -The eleven unconditional tools, plus the twelfth conditional on an attestation +The twelve unconditional tools, plus the thirteenth conditional on an attestation orchestrator being wired (read → browse → graph → search → audit → write → -append → regenerate → validate → changes-since → get-computation → run-computation): +verify → append → regenerate → validate → changes-since → get-computation → run-computation): | Tool | Description | |--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -265,6 +301,7 @@ append → regenerate → validate → changes-since → get-computation → run | `okf_search` | Full-text search across concept titles, descriptions, tags and bodies. Returns matching concept ids ranked by relevance. | | `okf_audit` | Audit the bundle's trust, freshness and lifecycle signals (§5.3–§5.5): counts by trust tier and status, plus the concepts needing attention. Read-only. | | `okf_write_concept` | Create or update a concept document. The frontmatter must contain non-empty type, title and description (producer-grade validation is enforced before writing). | +| `okf_verify` | Record a review of one or more concepts (§5.2): adds or replaces the caller's `{by, at}` entry in each concept's `verified` list. A stamp is a dated declaration, not a proof — never infer one from a PR approval. | | `okf_append_log` | Append an entry to the bundle root log.md under today's date (ISO). Note: log.md is re-rendered through the strict §9 model, so non-conforming prose or comments in a hand-authored log.md are not preserved. | | `okf_regenerate_indexes` | Regenerate every index.md in the bundle (progressive-disclosure listings). Run after adding or changing concepts. | | `okf_validate_bundle` | Validate the bundle against OKF v0.2 conformance (§11). Returns the diagnostics report. | @@ -276,8 +313,8 @@ append → regenerate → validate → changes-since → get-computation → run is untrusted — it comes from files on disk that may have been written by another agent or a human contributor — and is never injected into the conversation with a `system` role; it only ever reaches the model as tool -output. The three write-capable tools (`okf_write_concept`, `okf_append_log` -and `okf_regenerate_indexes`) +output. The four write-capable tools (`okf_write_concept`, `okf_verify`, +`okf_append_log` and `okf_regenerate_indexes`) rely entirely on the Agent Framework's own tool-approval mechanism to gate execution — `OkfBundleTools` performs no additional confirmation step of its own. @@ -530,6 +567,7 @@ This table is also published as the | §4 Concept documents | `OKF4net.OkfDocument`, `OKF4net.Frontmatter` | | §4.2 Body headings | `OkfDocument.Computation()` (fenced `# Computation` heading) | | §5 Provenance, trust, and lifecycle | `Frontmatter.Sources`/`Generated`/`Verified`/`TrustTier`/`Status`/`StaleAfter`, `Actor`/`Trust`/`Provenance`/`Lifecycle` | +| §5.2 Generation and verification stamps | `Frontmatter.Generated`/`Verified`, `BundleConceptWriter.RecordVerifications` — the governed writer behind `okf verify` and `okf_verify` | | §5.3–§5.5 trust, lifecycle, staleness | `ConceptAudit`, `AuditQuery`, `AuditReport` — the corpus-level query behind `okf audit` and `okf_audit` | | §6 Cross-linking and paths | `OKF4net.LinkScanner`, `Bundle.LinksFrom` / `Bundle.Backlinks` | | §6.2 Path-valued fields | `OkfDocument.FrontmatterResources()`, `Bundle.TryResolveResource` / `Bundle.ReadResourceText` | diff --git a/ROADMAP.md b/ROADMAP.md index 46a7589b..4044cfa4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,12 +20,36 @@ are the concrete entry points. backed by the shared `ConceptAudit`/`AuditVocabulary` model in `OKF4net`. Motivated by ["OKF v0.2 Quietly Admits the Folder Has a Ceiling"](https://medium.com/@davidroliver/okf-v0-2-quietly-admits-the-folder-has-a-ceiling-the-way-up-is-a-library-25fa54e872f9) — see [its design spec](docs/superpowers/specs/2026-08-21-okf-audit-design.md). +- **`okf verify` shipped** — the verb that answers what `okf audit` asks: it + records a review by adding, or from the same actor replacing, a + `{by, at}` entry in a named concept's `verified` list (§5.2), so the + concept leaves the audit worklist at the next pass. `…` accepts `-` to + read ids from standard input, so `okf audit … --trust unverified | cut + -d' ' -f1 | okf verify … --by human:ada -` closes the loop in one line. + Backed by the new `BundleConceptWriter.RecordVerifications` — the single + governed writer of `verified` — and exposed to agents as `okf_verify`. See + [its design spec](docs/superpowers/specs/2026-08-28-okf-verify-design.md). + - **Next, highest-value follow-up: a time-aware audit.** A `verified` + stamp today attests a moment, not a version — `Trust.DeriveTier` derives + `human-reviewed` from an actor's presence alone, so a five-year-old human + stamp counts the same as one from this morning, and nothing currently + flags that the concept's content moved after the review. Exposing the + stamps' timestamps on `AuditFinding` would let `okf audit` ask "reviewed, + but as of when, and has the file changed since?" — answered outside the + library, by comparing `max(verified[].at)` against + `git log -1 --format=%cI -- ` (the folder is canonical; its + history is git's, not the frontmatter's). Deliberately out of `okf + verify`'s scope: it needs no new write path, only turns an existing + field from a permanent alibi into a signal that decays. No schema + extension (`digest`, `scope`, `note` on the stamp) is planned to + recreate this information inside the bundle instead — that question is + answered by git, on purpose. - **Per-verb `--help` for the CLI.** `okf audit --help` today prints `error: missing `, and so do `okf validate --help` and every other verb: the CLI has one global usage block and no per-verb help, so a verb's own flags are only discoverable by reading OPTIONS or this repo. `audit` makes it visible (six optional flags, none of which fit on its COMMANDS - line), but the gap is CLI-wide and should be closed for all eight verbs at + line), but the gap is CLI-wide and should be closed for all nine verbs at once — intercepting `--help` inside each command before its positional is resolved, which also changes those invocations from exit 1 to exit 0. - More `OKF4net.Agents` samples with Microsoft Agent Framework — the first, diff --git a/src/OKF4net.Agents/README.md b/src/OKF4net.Agents/README.md index f65e2c35..125d4494 100644 --- a/src/OKF4net.Agents/README.md +++ b/src/OKF4net.Agents/README.md @@ -26,20 +26,24 @@ AIAgent agent = chatClient.AsAIAgent( var response = await agent.RunAsync("Summarize the concepts in this bundle."); ``` -## The eleven tools +## The twelve tools `okf_read_concept`, `okf_browse`, `okf_graph`, `okf_search`, `okf_audit`, -`okf_write_concept`, `okf_append_log`, `okf_regenerate_indexes`, +`okf_write_concept`, `okf_verify`, `okf_append_log`, `okf_regenerate_indexes`, `okf_validate_bundle`, `okf_changes_since`, `okf_get_computation` — plus a -twelfth, `okf_run_computation`, only when the tool set is constructed with an +thirteenth, `okf_run_computation`, only when the tool set is constructed with an `OKF4net.Attestation` orchestrator wired in. All tools return agent-friendly markdown/plain text and never throw for expected errors (unknown ids, invalid paths, malformed input) — the agent -receives an explanatory message instead. Write tools validate documents +receives an explanatory message instead. Write tools (`okf_write_concept`, +`okf_verify`, `okf_append_log`, `okf_regenerate_indexes`) validate documents (producer-grade OKF rules) before touching disk, serialize their writes, and -rely on the Agent Framework's tool-approval mechanism for gating. Bundle -content is treated as untrusted and is never injected as a system message. +rely on the Agent Framework's tool-approval mechanism for gating. `okf_verify` +records a `{by, at}` review stamp (§5.2) — a dated declaration, not a proof; +see the project README's `okf verify` section for what it does and doesn't +guarantee. Bundle content is treated as untrusted and is never injected as a +system message. `OkfContextProvider` (an `AIContextProvider`, registered via `ChatClientAgentOptions.AIContextProviders`) layers on top of the same diff --git a/src/OKF4net.Mcp/OkfMcpToolset.cs b/src/OKF4net.Mcp/OkfMcpToolset.cs index 34195219..47c93060 100644 --- a/src/OKF4net.Mcp/OkfMcpToolset.cs +++ b/src/OKF4net.Mcp/OkfMcpToolset.cs @@ -15,7 +15,7 @@ public static class OkfMcpToolset { /// /// Creates the MCP tools rooted at . When - /// is , the three write + /// is , the four write /// tools () are omitted so the /// bundle is served for consultation only. /// diff --git a/src/OKF4net.Mcp/README.md b/src/OKF4net.Mcp/README.md index bbc7e6a8..4808d835 100644 --- a/src/OKF4net.Mcp/README.md +++ b/src/OKF4net.Mcp/README.md @@ -41,8 +41,8 @@ The bundle root may instead be supplied via the environment: ``` Set `OKF_MCP_READONLY=1` to serve the bundle for consultation only (the write -tools `okf_write_concept`, `okf_append_log`, `okf_regenerate_indexes` are not -registered). +tools `okf_write_concept`, `okf_verify`, `okf_append_log`, +`okf_regenerate_indexes` are not registered). ## Bundle resolution order @@ -69,20 +69,23 @@ or `OKF_BUNDLE_ROOT` in `claude_desktop_config.json`. ## Tools `okf_read_concept`, `okf_browse`, `okf_graph`, `okf_search`, `okf_audit`, -`okf_write_concept`, `okf_append_log`, `okf_regenerate_indexes`, `okf_validate_bundle`, -`okf_changes_since`, `okf_get_computation`. +`okf_write_concept`, `okf_verify`, `okf_append_log`, `okf_regenerate_indexes`, +`okf_validate_bundle`, `okf_changes_since`, `okf_get_computation`. Each is the corresponding `OkfBundleTools` operation, so all OKF v0.2 behaviour, producer-grade validation, path-safety, and locking apply unchanged. -That's eleven tools full (eight read-only tools above plus the three write -tools), or eight when `OKF_MCP_READONLY=1` drops the three write tools. +That's twelve tools full (eight read-only tools above plus the four write +tools), or eight when `OKF_MCP_READONLY=1` drops the four write tools. `okf_get_computation` reads a §10 attested-computation concept's contract and sanctioned computation source — read-only, no attestation runtime needed. `okf_audit` reads the bundle's trust/freshness/lifecycle signals — also -read-only. The twelfth `OkfBundleTools` tool, `okf_run_computation`, is -**not** exposed by this server: it only appears in `GetTools()` when the tool -set is constructed with an `OKF4net.Attestation` `AttestationOrchestrator` -wired in, and this server starts `OkfBundleTools` with no orchestrator (it -wires no host-specific binder/executor/attester runtime). Embed -`OKF4net.Agents` directly if you need `okf_run_computation`. +read-only. `okf_verify` records a `{by, at}` review stamp (§5.2) in a named +concept's `verified` list — a dated declaration, not a proof; see the project +README's `okf verify` section for what it does and doesn't guarantee. The +thirteenth `OkfBundleTools` tool, `okf_run_computation`, is **not** exposed +by this server: it only appears in `GetTools()` when the tool set is +constructed with an `OKF4net.Attestation` `AttestationOrchestrator` wired in, +and this server starts `OkfBundleTools` with no orchestrator (it wires no +host-specific binder/executor/attester runtime). Embed `OKF4net.Agents` +directly if you need `okf_run_computation`. diff --git a/web/src/pages/Cli.tsx b/web/src/pages/Cli.tsx index 4425f746..fb478123 100644 --- a/web/src/pages/Cli.tsx +++ b/web/src/pages/Cli.tsx @@ -40,7 +40,7 @@ export default function Cli() { return ( - Eight commands, one binary. + Nine commands, one binary. } lede={ @@ -76,6 +76,13 @@ export default function Cli() { --stale, --trust, --status, --type , ], + [ + 'okf verify …', + <> + Record a review (§5.2) — adds or replaces a {'{by, at}'} stamp; closes the audit + worklist + , + ], ['okf info ', 'Summarize a bundle — concepts, types, links, version'], ['okf index ', '(Re)generate every index.md in the bundle (§8)'], [ diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 2f220f7b..e9e09710 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -98,6 +98,7 @@ export default function Home() { CommandDoes okf validate <bundle>Conformance check (§11), non-zero exit on failure okf audit <bundle>Trust, freshness and lifecycle across the bundle (§5.3–§5.5) + okf verify <bundle> <id>…Record a review (§5.2), closing the audit worklist okf info <bundle>Concepts, types, links, version okf index <bundle>(Re)generate every index.md (§8) okf graph <bundle>Cross-link graph, --dot for Graphviz @@ -114,9 +115,9 @@ export default function Home() {
##

The Agent tools

- Microsoft Agent Framework — ten tools + bounded context + Microsoft Agent Framework — twelve tools + bounded context
-

OKF4net.Agents turns a bundle into ten AIFunction tools (read, search, write, validate, log, §10 attested computation, …) — an eleventh, okf_run_computation, when an attestation orchestrator is wired in — plus OkfContextProvider, which injects budget-bounded reference data automatically — never as instructions — and, opt-in, captures exchanges as deterministic memory, single-bundle or scoped across tenants, users, and sessions.

+

OKF4net.Agents turns a bundle into twelve AIFunction tools (read, search, write, verify, validate, log, §10 attested computation, …) — a thirteenth, okf_run_computation, when an attestation orchestrator is wired in — plus OkfContextProvider, which injects budget-bounded reference data automatically — never as instructions — and, opt-in, captures exchanges as deterministic memory, single-bundle or scoped across tenants, users, and sessions.

docs/agents.md — the tools, the context provider, and scoped memory capture

@@ -136,7 +137,7 @@ export default function Home() {

MCP — In Claude & your editor

MCP — the bundle as tools -

Run okf-mcp, point it at a bundle, and its ten operations become tools inside Claude Desktop, Claude Code, and Cursor — read, search, and write concepts from a conversation, over the Model Context Protocol. Same engine as the library and the CLI, exposed to any MCP client.

+

Run okf-mcp, point it at a bundle, and its twelve operations become tools inside Claude Desktop, Claude Code, and Cursor — read, search, and write concepts from a conversation, over the Model Context Protocol. Same engine as the library and the CLI, exposed to any MCP client.

On Claude Code, skip the manual config: the OKF plugin installs okf-mcp, an okf skill, and guided /okf-init / /okf-validate slash commands in one step — /plugin marketplace add jchable/okf4net-claude-plugin, then /plugin install okf@okf4net.

docs/mcp.md — install okf-mcp and connect each client, step by step

diff --git a/web/src/pages/Library.tsx b/web/src/pages/Library.tsx index f43a421f..cc3d9f58 100644 --- a/web/src/pages/Library.tsx +++ b/web/src/pages/Library.tsx @@ -142,7 +142,7 @@ export default function Library() { [StalePolicy, 'Consumer-side policy for stale concepts (§5.5)'], [ BundleConceptWriter, - 'Atomic, per-path-locked, reparse-guarded concept writes — shared by Agents & Catalog', + 'Atomic, per-path-locked, reparse-guarded concept writes, incl. RecordVerifications (§5.2) — shared by Agents & Catalog', ], [ConceptSearch, 'The full-text scorer shared by okf_search and the local catalog'], [ diff --git a/web/src/pages/docs/Cli.tsx b/web/src/pages/docs/Cli.tsx index 2e53c8b1..af83f3d0 100644 --- a/web/src/pages/docs/Cli.tsx +++ b/web/src/pages/docs/Cli.tsx @@ -48,6 +48,20 @@ const auditQueryHtml = `# any filter flag switches to one line p $ okf audit bundles/acme_retail --trust unverified skills/run-on-bq no-stale-after unverified stable` +const verifyHtml = `$ okf verify bundles/acme_retail metrics/revenue --by human:ada --at 2026-08-28T09:14:00Z +recorded metrics/revenue human:ada 2026-08-28T09:14:00Z + +# a repeat review from the same actor replaces its own stamp +$ okf verify bundles/acme_retail metrics/revenue --by human:ada --at 2026-09-15T09:00:00Z +recorded metrics/revenue human:ada 2026-09-15T09:00:00Z (replaces 2026-08-28T09:14:00Z)` + +const verifyLoopHtml = `# "-" reads concept ids from standard input, one per line -- audit's worklist becomes verify's input +$ okf audit bundles/acme_retail --trust unverified | cut -d' ' -f1 | okf verify bundles/acme_retail --by human:ada - +recorded skills/run-on-bq human:ada 2026-08-28T21:22:23Z` + +const verifyDryRunHtml = `$ okf verify bundles/acme_retail metrics/gross-margin --by human:ada --dry-run +would record metrics/gross-margin human:ada (now)` + const infoHtml = `$ okf info tests/fixtures/appendix_a bundle: tests/fixtures/appendix_a concepts: 4 @@ -141,14 +155,14 @@ const buildHtml = `$ git clone https://github.com/jchable/okf4net $ dotnet publish src/OKF4net.Cli -c Release # self-contained okf binary` /** - * Port of `website/docs/cli.html` — the eight `okf` subcommands: synopsis, + * Port of `website/docs/cli.html` — the nine `okf` subcommands: synopsis, * per-command reference with real captured output, exit codes, build. */ export default function Cli() { return ( - Eight subcommands over a bundle or a file, a self-contained Native AOT binary with no + Nine subcommands over a bundle or a file, a self-contained Native AOT binary with no runtime to install. validate exits non-zero on a non-conformant bundle, so the whole tool drops into CI as one line. @@ -197,6 +211,15 @@ export default function Cli() { <bundle> Report trust, freshness and lifecycle across the bundle (§5.3–§5.5) + + + verify + + <bundle> <id>… + + Record a review (§5.2) with --by <actor>; closes the audit worklist + + info @@ -258,7 +281,9 @@ export default function Cli() { okf fmt -- notes.md -w treats -w as a second filename rather than as the write-in-place flag; write it as okf fmt -w -- notes.md if that is what you meant. A value belonging to an option is likewise only ever a value: in okf audit b --type --stale,{' '} - --stale is the type being searched for, not a filter. + --stale is the type being searched for, not a filter. A lone - is never a + flag — it is POSIX's "read standard input" argument, which is what lets{' '} + verify's <id>… read concept ids from a pipe.

         
@@ -306,6 +331,38 @@ export default function Cli() {
           

+ +

+ Records a review: adds — or, for a repeat review from the same actor, replaces — a{' '} + {'{by, at}'} entry in each named concept's verified list. Every id is checked + for existence and §11 conformance before anything is written, so a batch with one bad id + is rejected as a whole at that stage; a failure partway through the write phase (I/O, permissions) can + still leave the concepts already written stamped, and the output says exactly what landed. Exits{' '} + 0 on success, 1 otherwise. +

+
+          

+ <id>… also accepts a single -, reading one concept id per line from + standard input — which is what lets okf audit's worklist feed okf verify + directly, closing the loop in one line: +

+
+          

+ Re-running okf audit … --trust unverified afterward prints nothing — the concept it just + stamped left the worklist. --dry-run shows what would be recorded without writing;{' '} + --at <yyyy-MM-ddTHH:mm:ssZ> overrides the default of "now" for reproducible scripting. +

+
+          

+ A stamp is a dated declaration, not a proof. It guarantees the entry is well-formed, + dated, and attached to the concepts named — it does not, and cannot, guarantee the signer's identity or + that anyone read the concept: no zero-dependency tool can authenticate --by, and{' '} + okf_write_concept can write the same field with no ceremony at all. What makes a stamp + credible is where it lands — in a diff a human reviewed — never a stamp inferred from a PR approval; see + the project README for the full reasoning. +

+ +

Reports the bundle root, declared OKF version (if any), concept count, reserved-file counts, a breakdown diff --git a/web/src/pages/docs/Library.tsx b/web/src/pages/docs/Library.tsx index 02a739fd..17897b73 100644 --- a/web/src/pages/docs/Library.tsx +++ b/web/src/pages/docs/Library.tsx @@ -303,10 +303,11 @@ export default function Library() { 'BundleConceptWriter', <> Atomic, per-path-locked, reparse-guarded concept writes —{' '} - WriteConcept/AppendToConceptAtomic, plus a Frontmatter-typed{' '} - WriteConcept overload for a caller building a document programmatically (e.g. with{' '} - OkfDocumentBuilder), no YAML text round trip. The primitive behind{' '} - okf_write_concept and the scoped memory store; see{' '} + WriteConcept/AppendToConceptAtomic/RecordVerifications, + plus a Frontmatter-typed WriteConcept overload for a caller building a + document programmatically (e.g. with OkfDocumentBuilder), no YAML text round trip. + The primitive behind okf_write_concept, okf_verify and the scoped memory + store — the single governed writer of the §5.2 verified field; see{' '} docs/agents.md. , ], From 847073bd671b499e80c8f78a2b9904be3a24cc13 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Fri, 28 Aug 2026 23:44:01 +0200 Subject: [PATCH 19/27] docs(verify): correct stale tool counts on the Agents and MCP doc pages web/src/pages/docs/Agents.tsx and Mcp.tsx still said "ten tools (eleven when wired)" -- stale even before this branch, since neither page picked up last week's okf_audit addition. Bump to twelve/thirteen, add the missing okf_audit and okf_verify rows to both tool tables (previously absent entirely), and fix the MCP read-only counts (eight read tools, four writers). Also corrects two counts on the docs index page (nine okf commands, twelve agent tools) found by the same sweep. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- web/src/pages/docs/Agents.tsx | 33 ++++++++++++++++++++++++--------- web/src/pages/docs/Index.tsx | 4 ++-- web/src/pages/docs/Mcp.tsx | 24 +++++++++++++++++++----- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/web/src/pages/docs/Agents.tsx b/web/src/pages/docs/Agents.tsx index 19fb37b3..ebe3c444 100644 --- a/web/src/pages/docs/Agents.tsx +++ b/web/src/pages/docs/Agents.tsx @@ -21,8 +21,8 @@ const wireUpHtml = `using OKF4net.Agents; });` /** - * `/docs/agents` — reference for `OKF4net.Agents`: the ten `OkfBundleTools` - * (eleven when an attestation orchestrator is wired in), `OkfContextProvider` + * `/docs/agents` — reference for `OKF4net.Agents`: the twelve `OkfBundleTools` + * (thirteen when an attestation orchestrator is wired in), `OkfContextProvider` * (budget-bounded injection, V1 single-bundle and V2 scoped-memory modes). * Every claim here traces to a direct read of `src/OKF4net.Agents/*.cs`, not * to the README (see the audit in @@ -32,7 +32,7 @@ export default function Agents() { return ( - OKF4net.Agents exposes a bundle two ways: ten tools an agent calls - directly (OkfBundleTools), an eleventh — okf_run_computation — when an{' '} + OKF4net.Agents exposes a bundle two ways: twelve tools an agent calls + directly (OkfBundleTools), a thirteenth — okf_run_computation — when an{' '} OKF4net.Attestation orchestrator is wired in, and a context provider{' '} that injects bounded reference data automatically (OkfContextProvider). Neither ever throws — every failure comes back as data, not an exception the invocation pipeline has to handle. @@ -67,7 +67,7 @@ export default function Agents() {

- +

Each tool is a plain string in, string out AIFunction. On any failure — the tool returns a plain-text failure message (Error: ..., Concept '...' not found, @@ -101,6 +101,13 @@ export default function Agents() { [deprecated]/[stale] when relevant. , ], + [ + 'okf_audit', + <> + Audit the bundle's trust (§5.3), lifecycle (§5.4) and staleness (§5.5) signals — counts by tier + and status, plus the concepts needing attention. Read-only. + , + ], [ 'okf_write_concept', <> @@ -108,6 +115,14 @@ export default function Agents() { generated (§5.2) when the caller didn't supply one. , ], + [ + 'okf_verify', + <> + Record a review (§5.2): adds — or, from the same actor, replaces — a{' '} + {'{by, at}'} entry in each named concept's verified list. A dated + declaration, not a proof — never inferred from a PR approval. + , + ], [ 'okf_append_log', <>Append a dated entry to log.md (§9) — re-renders the whole file through the strict log model., @@ -139,9 +154,9 @@ export default function Agents() { ]} />

- okf_write_concept and the scoped memory store both funnel through the same core - primitive, OKF4net.BundleConceptWriter — one atomic, per-path-locked, reparse-guarded - write path, not two. + okf_write_concept, okf_verify, and the scoped memory store all funnel + through the same core primitive, OKF4net.BundleConceptWriter — one atomic, + per-path-locked, reparse-guarded write path, not two or three.

diff --git a/web/src/pages/docs/Index.tsx b/web/src/pages/docs/Index.tsx index 3e5f044b..92675c6a 100644 --- a/web/src/pages/docs/Index.tsx +++ b/web/src/pages/docs/Index.tsx @@ -73,14 +73,14 @@ export default function DocsIndex() { concept: cli, desc: ( <> - The seven okf commands — flags, exit codes, and copy-paste transcripts. + The nine okf commands — flags, exit codes, and copy-paste transcripts. ), }, { type: 'Reference', concept: agents, - desc: 'The Microsoft Agent Framework layer — ten bundle tools and a budget-bounded context provider.', + desc: 'The Microsoft Agent Framework layer — twelve bundle tools and a budget-bounded context provider.', }, { type: 'Reference', diff --git a/web/src/pages/docs/Mcp.tsx b/web/src/pages/docs/Mcp.tsx index 7d77bfe8..02160fc1 100644 --- a/web/src/pages/docs/Mcp.tsx +++ b/web/src/pages/docs/Mcp.tsx @@ -69,7 +69,7 @@ export default function Mcp() { } lede={ <> - okf-mcp is a small MCP server. Point it at a bundle and its ten + okf-mcp is a small MCP server. Point it at a bundle and its twelve operations become tools inside Claude — and any MCP client — so you read, search, and write concepts from a conversation. It's the same tools as the Agent Framework layer, spoken over the Model Context Protocol. @@ -82,7 +82,7 @@ export default function Mcp() {

MCP is the open protocol Claude Desktop, Claude Code, and editors like Cursor use to talk to local tools.{' '} okf-mcp is a thin façade over the same OkfBundleTools the CLI - and the Agent Framework layer use — one bundle per server, ten tools, read and write. Everything runs + and the Agent Framework layer use — one bundle per server, twelve tools, read and write. Everything runs through the library, so path-safety, producer validation, and permissive loading come for free.

+ Trust (§5.3), lifecycle (§5.4) and staleness (§5.5) across the bundle — counts plus the + concepts needing attention + , + ], ['okf_write_concept', 'Create or update a concept — producer validation first (§11)'], + [ + 'okf_verify', + <> + Record a review (§5.2) — adds or replaces a {'{by, at}'} entry in a concept's{' '} + verified list + , + ], [ 'okf_append_log', <> @@ -117,7 +131,7 @@ export default function Mcp() { ]} />

- okf-mcp doesn't wire an attestation runtime, so the eleventh, execution-capable{' '} + okf-mcp doesn't wire an attestation runtime, so the thirteenth, execution-capable{' '} okf_run_computation tool (see docs/agents.md) isn't exposed here — only the read-only okf_get_computation above.

@@ -264,8 +278,8 @@ export default function Mcp() {

- Set OKF_MCP_READONLY=1 and okf-mcp registers only the seven read tools — the - three writers (okf_write_concept, okf_append_log,{' '} + Set OKF_MCP_READONLY=1 and okf-mcp registers only the eight read tools — the + four writers (okf_write_concept, okf_verify, okf_append_log,{' '} okf_regenerate_indexes) are left out entirely. Use it for a shared reference bundle you want the model to consult but never edit.

From f10f2a9081628b5c5d45160be820177dd9ac90f6 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Sat, 29 Aug 2026 00:09:35 +0200 Subject: [PATCH 20/27] docs(verify): fix producer-grade overclaim, worklist overclaim, and add reserialization caveat Two important findings from review, both proven by running the binary: - okf_verify does NOT apply producer-grade validation (only S11 conformance, deliberately -- RecordVerifications' own doc comment says why: refusing a reviewer over a missing description would make exactly the concepts an audit surfaces unstampable). Split the "write tools validate producer-grade rules" claim in both OKF4net.Agents/README.md and OKF4net.Mcp/README.md so okf_verify is described accurately. - "closes / leaves the audit worklist" overclaimed: verification only moves the trust dimension: staleness is untouched, so a just-reviewed concept can still appear in audit's default (stale-only) worklist. Rescoped to "clears the unverified worklist, not staleness" (or the prose equivalent) in README.md, CHANGELOG.md, ROADMAP.md, and three web pages; docs/Cli.tsx's chapter body already had this right, so its synopsis row was made to agree with the body. Also: added a reserialization clause to the README honesty box (stamping reserializes the whole frontmatter canonically, so a flow-style bundle's diff can bury the assertion -- run `okf fmt` first if you want the diff to be just the stamp); spelled out --at's exact yyyy-MM-ddTHH:mm:ssZ shape wherever it was under-specified; added the missing --at line to the CLI's own --help OPTIONS block; added RecordVerifications to the site's spec mapping page's S5 row; fixed the okf_verify row's broken column padding in README's tool table; and corrected stale tool/verb counts found just outside the prior sweep's perimeter (a test comment, and three spots in the outreach launch-kit docs meant for external publication). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- CHANGELOG.md | 15 ++++++---- README.md | 28 +++++++++++++------ ROADMAP.md | 11 +++++--- docs/outreach/ecosystem-blurbs.md | 12 ++++---- .../issues/add-agents-quickstart-sample.md | 2 +- src/OKF4net.Agents/README.md | 21 ++++++++------ src/OKF4net.Cli/OkfCli.cs | 1 + src/OKF4net.Mcp/README.md | 6 +++- .../Agents/AgentIntegrationTests.cs | 2 +- web/src/pages/Cli.tsx | 4 +-- web/src/pages/Home.tsx | 2 +- web/src/pages/docs/Cli.tsx | 3 +- web/src/pages/docs/Spec.tsx | 2 ++ 13 files changed, 71 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99209ea5..03f9bc4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,14 +21,19 @@ and this project adheres to the new `ConceptAudit` in the core library and exposed to agents as the read-only `okf_audit` tool. - **`okf verify … --by `** — the verb that answers what - `okf audit` asks: it records a review (§5.2) by adding, or from the same - actor replacing, a `{by, at}` entry in each named concept's `verified` - list, so a reviewed concept leaves the audit worklist. `…` also accepts + `okf audit` asks about trust: it records a review (§5.2) by adding, or + from the same actor replacing, a `{by, at}` entry in each named concept's + `verified` list, so the concept clears audit's trust-filtered + (`--trust unverified`/`unverified,machine-confirmed`) selection. + Verification only moves the trust dimension (§5.3) — it never touches + `stale_after`, so a just-reviewed concept can still appear in `okf audit`'s + *default* worklist, which selects on staleness alone. `…` also accepts a single `-`, reading concept ids from standard input, so `okf audit … --trust unverified | cut -d' ' -f1 | okf verify … --by human:ada -` closes the loop in one line. `--dry-run` shows what would be - recorded without writing; `--at` overrides the default of "now". A batch - is validated (existence, §11 conformance, no duplicate id) before the + recorded without writing; `--at ` overrides the + default of "now" (a bare date, an offset, or fractional seconds are + rejected). A batch is validated (existence, §11 conformance, no duplicate id) before the first write, but writing several files cannot be atomic — a mid-batch I/O failure still leaves the earlier concepts stamped, and is reported as such. Backed by the new `BundleConceptWriter.RecordVerifications` in the diff --git a/README.md b/README.md index 7154a12b..eb304119 100644 --- a/README.md +++ b/README.md @@ -218,10 +218,14 @@ a worklist, not an inventory (use `okf info --json` for that). `okf verify … --by ` records a review (§5.2): it adds — or, for a repeat review from the same actor, replaces — a `{ by, at }` entry in each named concept's `verified` list. It is the verb that answers what -`okf audit` asks: audit finds what needs a look, verify records that the look -happened, and the reviewed concept leaves the worklist. `…` also accepts a -single `-`, reading one concept id per line from standard input, so the two -verbs compose into one line: +`okf audit` asks about trust: audit finds concepts a human has never reviewed +(`--trust unverified` / `unverified,machine-confirmed`), verify records that +the review happened, and the reviewed concept clears that trust-filtered +selection. Verification only moves the trust dimension (§5.3) — it never +touches `stale_after`, so a concept just reviewed can still show up in +`okf audit`'s *default* worklist, which selects on staleness alone (see +above). `…` also accepts a single `-`, reading one concept id per line +from standard input, so the two verbs compose into one line: ```sh okf audit bundles/acme_retail --trust unverified | cut -d' ' -f1 | okf verify bundles/acme_retail --by human:ada - @@ -231,8 +235,9 @@ Every named concept is checked for existence and §11 conformance before anything is written, so a batch is rejected as a whole at that stage; a mid-batch I/O failure can still leave the concepts already written stamped (`okf verify`'s output lists exactly what landed). `--dry-run` prints what -would be recorded without writing anything; `--at ` overrides the -default of "now" for reproducible scripting. +would be recorded without writing anything; `--at ` +overrides the default of "now" for reproducible scripting — a bare date, a +numeric offset, or fractional seconds are all rejected, not silently rounded. > **What a `verified` stamp does and doesn't prove.** It guarantees the > stamp is well-formed, dated, and attached to the concepts named — nothing @@ -243,7 +248,14 @@ default of "now" for reproducible scripting. > bundle, correcting a concept) has to be able to touch `verified` too. > Credibility comes from *where the stamp lands*: in a diff a human reviewed, > under branch protection, where the reviewer sees the assertion and can -> reject it. **Never infer a stamp from a PR approval** — that turns "a human +> reject it. That argument only works if the diff is legible: like every +> write path in this library, `verify` re-serializes the whole document in +> canonical form (the same shape `okf fmt` produces) — a flow-style mapping +> or an inline list expands to one entry per line, so a three-line stamp can +> land as a much larger diff with the new `verified` entry buried inside a +> reformat. Run `okf fmt -w` on the bundle first, as its own reviewed commit, +> if you want a review's diff to be the stamp and nothing else. **Never infer +> a stamp from a PR approval** — that turns "a human > approved this diff" into "a human vouches for this knowledge," which are > different every time a PR touches a file for a reason other than reviewing > it (which is most of the time). Doing so would mass-promote every concept @@ -301,7 +313,7 @@ verify → append → regenerate → validate → changes-since → get-computat | `okf_search` | Full-text search across concept titles, descriptions, tags and bodies. Returns matching concept ids ranked by relevance. | | `okf_audit` | Audit the bundle's trust, freshness and lifecycle signals (§5.3–§5.5): counts by trust tier and status, plus the concepts needing attention. Read-only. | | `okf_write_concept` | Create or update a concept document. The frontmatter must contain non-empty type, title and description (producer-grade validation is enforced before writing). | -| `okf_verify` | Record a review of one or more concepts (§5.2): adds or replaces the caller's `{by, at}` entry in each concept's `verified` list. A stamp is a dated declaration, not a proof — never infer one from a PR approval. | +| `okf_verify` | Record a review (§5.2): adds or replaces the caller's `{by, at}` entry in each concept's `verified` list — a dated declaration, not a proof; never infer one from a PR approval. | | `okf_append_log` | Append an entry to the bundle root log.md under today's date (ISO). Note: log.md is re-rendered through the strict §9 model, so non-conforming prose or comments in a hand-authored log.md are not preserved. | | `okf_regenerate_indexes` | Regenerate every index.md in the bundle (progressive-disclosure listings). Run after adding or changing concepts. | | `okf_validate_bundle` | Validate the bundle against OKF v0.2 conformance (§11). Returns the diagnostics report. | diff --git a/ROADMAP.md b/ROADMAP.md index 4044cfa4..a2ec73a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,10 +20,13 @@ are the concrete entry points. backed by the shared `ConceptAudit`/`AuditVocabulary` model in `OKF4net`. Motivated by ["OKF v0.2 Quietly Admits the Folder Has a Ceiling"](https://medium.com/@davidroliver/okf-v0-2-quietly-admits-the-folder-has-a-ceiling-the-way-up-is-a-library-25fa54e872f9) — see [its design spec](docs/superpowers/specs/2026-08-21-okf-audit-design.md). -- **`okf verify` shipped** — the verb that answers what `okf audit` asks: it - records a review by adding, or from the same actor replacing, a - `{by, at}` entry in a named concept's `verified` list (§5.2), so the - concept leaves the audit worklist at the next pass. `…` accepts `-` to +- **`okf verify` shipped** — the verb that answers what `okf audit` asks + about trust: it records a review by adding, or from the same actor + replacing, a `{by, at}` entry in a named concept's `verified` list (§5.2), + so the concept clears audit's trust-filtered selection at the next pass. + Verification only moves the trust dimension (§5.3) — `stale_after` is + untouched, so a just-reviewed concept can still appear in `okf audit`'s + *default* (staleness-only) worklist. `…` accepts `-` to read ids from standard input, so `okf audit … --trust unverified | cut -d' ' -f1 | okf verify … --by human:ada -` closes the loop in one line. Backed by the new `BundleConceptWriter.RecordVerifications` — the single diff --git a/docs/outreach/ecosystem-blurbs.md b/docs/outreach/ecosystem-blurbs.md index 4f6b4294..e90421e9 100644 --- a/docs/outreach/ecosystem-blurbs.md +++ b/docs/outreach/ecosystem-blurbs.md @@ -6,10 +6,10 @@ below are checked against `README.md` as of this writing: - Zero third-party runtime dependencies in `src/OKF4net/` and `src/OKF4net.Cli/` (BCL only — own YAML-subset parser, link scanner, CLI arg parsing). -- Library + a `okf` CLI (`validate`/`info`/`index`/`graph`/`parse`/`fmt`), - published Native AOT, self-contained, single-file. +- Library + a `okf` CLI (`validate`/`audit`/`verify`/`info`/`index`/`graph`/ + `parse`/`fmt`/`render`), published Native AOT, self-contained, single-file. - `src/OKF4net.Agents/` is a separate package exposing bundle operations as - Microsoft Agent Framework tools (`OkfBundleTools`, nine `AITool`s) plus + Microsoft Agent Framework tools (`OkfBundleTools`, twelve `AITool`s) plus `OkfContextProvider`; it is the only project depending on `Microsoft.Agents.AI`. - Implements Google's Open Knowledge Format (OKF) v0.1: a bundle is a @@ -116,9 +116,9 @@ in-site submission flow is the only mechanism observed. > OKF4net is a from-scratch, zero-dependency .NET port of Google's Open > Knowledge Format (OKF v0.1) — treat a directory of markdown + YAML files > as a queryable, cross-linked knowledge bundle. It ships a Native AOT -> `okf` CLI (`validate`/`info`/`index`/`graph`/`parse`/`fmt`) and a -> Microsoft Agent Framework tools layer for agent-native read/write access -> to the bundle. https://github.com/jchable/okf4net +> `okf` CLI (`validate`/`audit`/`verify`/`info`/`index`/`graph`/`parse`/`fmt`/ +> `render`) and a Microsoft Agent Framework tools layer for agent-native +> read/write access to the bundle. https://github.com/jchable/okf4net --- diff --git a/docs/outreach/issues/add-agents-quickstart-sample.md b/docs/outreach/issues/add-agents-quickstart-sample.md index 1be92e2d..c8a0c27c 100644 --- a/docs/outreach/issues/add-agents-quickstart-sample.md +++ b/docs/outreach/issues/add-agents-quickstart-sample.md @@ -6,7 +6,7 @@ **Files to touch:** `samples/AgentsQuickstart/AgentsQuickstart.csproj` (new), `samples/AgentsQuickstart/Program.cs` (new), `samples/AgentsQuickstart/bundle/*.md` (new, a tiny demo bundle), `README.md` **What to do:** 1. Create `samples/AgentsQuickstart/AgentsQuickstart.csproj` as a `net10.0` console app with a `ProjectReference` to `src/OKF4net.Agents/OKF4net.Agents.csproj` only — no other package references. This transitively pulls in `Microsoft.Agents.AI`/`Microsoft.Extensions.AI`, satisfying `OKF4net.Agents`'s "only `Microsoft.Agents.AI`" dependency rule without adding anything new. -2. In `Program.cs`, construct an `OkfBundleTools` over a small bundled sample directory (e.g. `samples/AgentsQuickstart/bundle/`, containing 2-3 concept files), call `tools.GetTools()`, and demonstrate calling one or two of the nine tools directly in code (e.g. `okf_search`/`okf_browse` equivalents — check the exact public method names on `OkfBundleTools` in `src/OKF4net.Agents/OkfBundleTools.cs`) without needing a real `IChatClient`. If you want to show the full `AsAIAgent` wiring from the README, write a minimal in-sample fake `IChatClient` (the test suite's `tests/OKF4net.Tests/Agents/ScriptedChatClient.cs` is a good reference for the shape, but don't reference the test assembly from the sample — copy the pattern, not the file) so the sample runs with zero network calls and zero API keys. +2. In `Program.cs`, construct an `OkfBundleTools` over a small bundled sample directory (e.g. `samples/AgentsQuickstart/bundle/`, containing 2-3 concept files), call `tools.GetTools()`, and demonstrate calling one or two of the twelve tools directly in code (e.g. `okf_search`/`okf_browse` equivalents — check the exact public method names on `OkfBundleTools` in `src/OKF4net.Agents/OkfBundleTools.cs`) without needing a real `IChatClient`. If you want to show the full `AsAIAgent` wiring from the README, write a minimal in-sample fake `IChatClient` (the test suite's `tests/OKF4net.Tests/Agents/ScriptedChatClient.cs` is a good reference for the shape, but don't reference the test assembly from the sample — copy the pattern, not the file) so the sample runs with zero network calls and zero API keys. 3. Print clear, narrated console output explaining what's happening at each step (this is a teaching sample, not a benchmark). 4. Add a short pointer to the new sample from the README's "Using OKF4net with Microsoft Agent Framework" section. **How to verify:** `dotnet run --project samples/AgentsQuickstart` — expect it to run to completion with no exceptions and readable output. Also confirm `dotnet build OKF4net.sln` still succeeds (add the new project to `OKF4net.sln` via `dotnet sln OKF4net.sln add samples/AgentsQuickstart/AgentsQuickstart.csproj` if you want it built by the main solution; optional but recommended for CI visibility). diff --git a/src/OKF4net.Agents/README.md b/src/OKF4net.Agents/README.md index 125d4494..83a133ba 100644 --- a/src/OKF4net.Agents/README.md +++ b/src/OKF4net.Agents/README.md @@ -36,14 +36,19 @@ thirteenth, `okf_run_computation`, only when the tool set is constructed with an All tools return agent-friendly markdown/plain text and never throw for expected errors (unknown ids, invalid paths, malformed input) — the agent -receives an explanatory message instead. Write tools (`okf_write_concept`, -`okf_verify`, `okf_append_log`, `okf_regenerate_indexes`) validate documents -(producer-grade OKF rules) before touching disk, serialize their writes, and -rely on the Agent Framework's tool-approval mechanism for gating. `okf_verify` -records a `{by, at}` review stamp (§5.2) — a dated declaration, not a proof; -see the project README's `okf verify` section for what it does and doesn't -guarantee. Bundle content is treated as untrusted and is never injected as a -system message. +receives an explanatory message instead. All four write tools +(`okf_write_concept`, `okf_verify`, `okf_append_log`, `okf_regenerate_indexes`) +serialize their writes and rely on the Agent Framework's tool-approval +mechanism for gating. `okf_write_concept`, `okf_append_log` and +`okf_regenerate_indexes` validate documents against the stricter +producer-grade OKF rules before touching disk; `okf_verify` deliberately +enforces only §11 conformance (a non-empty `type`) instead — recording a +review is not producing content, and refusing a reviewer because a concept +is missing a `description` would make precisely the concepts an audit +surfaces unstampable. It records a `{by, at}` review stamp (§5.2) — a dated +declaration, not a proof; see the project README's `okf verify` section for +what it does and doesn't guarantee. Bundle content is treated as untrusted +and is never injected as a system message. `OkfContextProvider` (an `AIContextProvider`, registered via `ChatClientAgentOptions.AIContextProviders`) layers on top of the same diff --git a/src/OKF4net.Cli/OkfCli.cs b/src/OKF4net.Cli/OkfCli.cs index ea8e21c5..00c8316b 100644 --- a/src/OKF4net.Cli/OkfCli.cs +++ b/src/OKF4net.Cli/OkfCli.cs @@ -46,6 +46,7 @@ public static class OkfCli " --out Output directory for `render`\n" + " --as-of Pin today's date (YYYY-MM-DD) for validate/audit\n" + " --by Who is recording the review, for `verify` (required)\n" + + " --at UTC timestamp yyyy-MM-ddTHH:mm:ssZ for `verify` (default: now)\n" + " --dry-run Show what `verify` would record, write nothing\n" + " --stale, --trust , --status , --type \n" + " Filter `audit`'s worklist"; diff --git a/src/OKF4net.Mcp/README.md b/src/OKF4net.Mcp/README.md index 4808d835..af0fc136 100644 --- a/src/OKF4net.Mcp/README.md +++ b/src/OKF4net.Mcp/README.md @@ -73,7 +73,11 @@ or `OKF_BUNDLE_ROOT` in `claude_desktop_config.json`. `okf_validate_bundle`, `okf_changes_since`, `okf_get_computation`. Each is the corresponding `OkfBundleTools` operation, so all OKF v0.2 behaviour, -producer-grade validation, path-safety, and locking apply unchanged. +path-safety, and locking apply unchanged — including each write tool's exact +validation level: producer-grade for `okf_write_concept`, `okf_append_log` +and `okf_regenerate_indexes`, but only §11 conformance (a non-empty `type`) +for `okf_verify`, deliberately, so a concept missing a `description` can +still be reviewed. That's twelve tools full (eight read-only tools above plus the four write tools), or eight when `OKF_MCP_READONLY=1` drops the four write tools. diff --git a/tests/OKF4net.Tests/Agents/AgentIntegrationTests.cs b/tests/OKF4net.Tests/Agents/AgentIntegrationTests.cs index ea7a613f..e8119e1f 100644 --- a/tests/OKF4net.Tests/Agents/AgentIntegrationTests.cs +++ b/tests/OKF4net.Tests/Agents/AgentIntegrationTests.cs @@ -72,7 +72,7 @@ public async Task Agent_writes_concept_regenerates_indexes_and_validates_end_to_ // THE key API decision: build a real ChatClientAgent over the raw // scripted IChatClient via the Microsoft.Agents.AI 1.14.0 AsAIAgent // extension (Microsoft.Extensions.AI.ChatClientExtensions.AsAIAgent), - // handing it the nine OkfBundleTools AIFunctions as its tool list. + // handing it the twelve OkfBundleTools AIFunctions as its tool list. // ChatClientAgent inserts its own FunctionInvokingChatClient in front // of the chat client when one isn't already present, so the real // function-invocation pipeline runs here -- no manual diff --git a/web/src/pages/Cli.tsx b/web/src/pages/Cli.tsx index fb478123..f4caa66f 100644 --- a/web/src/pages/Cli.tsx +++ b/web/src/pages/Cli.tsx @@ -79,8 +79,8 @@ export default function Cli() { [ 'okf verify …', <> - Record a review (§5.2) — adds or replaces a {'{by, at}'} stamp; closes the audit - worklist + Record a review (§5.2) — adds or replaces a {'{by, at}'} stamp; clears the + unverified worklist, not staleness , ], ['okf info ', 'Summarize a bundle — concepts, types, links, version'], diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index e9e09710..48b8b908 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -98,7 +98,7 @@ export default function Home() { CommandDoes okf validate <bundle>Conformance check (§11), non-zero exit on failure okf audit <bundle>Trust, freshness and lifecycle across the bundle (§5.3–§5.5) - okf verify <bundle> <id>…Record a review (§5.2), closing the audit worklist + okf verify <bundle> <id>…Record a review (§5.2) — clears the unverified worklist, not staleness okf info <bundle>Concepts, types, links, version okf index <bundle>(Re)generate every index.md (§8) okf graph <bundle>Cross-link graph, --dot for Graphviz diff --git a/web/src/pages/docs/Cli.tsx b/web/src/pages/docs/Cli.tsx index af83f3d0..04e99c2f 100644 --- a/web/src/pages/docs/Cli.tsx +++ b/web/src/pages/docs/Cli.tsx @@ -217,7 +217,8 @@ export default function Cli() { <bundle> <id>… - Record a review (§5.2) with --by <actor>; closes the audit worklist + Record a review (§5.2) with --by <actor>; clears the unverified worklist, + not staleness diff --git a/web/src/pages/docs/Spec.tsx b/web/src/pages/docs/Spec.tsx index ec02b2d9..2ee4d6d3 100644 --- a/web/src/pages/docs/Spec.tsx +++ b/web/src/pages/docs/Spec.tsx @@ -113,6 +113,8 @@ export default function Spec() { Frontmatter.Sources/Generated/Verified/ TrustTier/Status/StaleAfter, and the{' '} Actor/Trust/Provenance/Lifecycle value types. + §5.2's verified stamps are written by BundleConceptWriter.RecordVerifications, + the single governed writer behind okf verify and okf_verify. , ], [ From 37d08078adf950dfe3df327b6ec8edd502cf720a Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Sat, 29 Aug 2026 00:39:38 +0200 Subject: [PATCH 21/27] docs(verify): fix seven false/imprecise claims in prose, comments and test docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final fix round on the okf verify branch: no behavioural change anywhere, every fix is a correction to a comment, XML doc, README passage, or test doc-comment that made a claim not backed by the code path it described. - Three comments (CLI, agent tool, CLI test) wrongly justified the pre-flight existence/§11 check by claiming it prevents a half-stamped batch. RecordVerifications already resolves, reads, parses and validates every concept before writing any, so the pre-check's real value is message quality (naming the offending id) — rewrote all three. - Agents/Mcp READMEs claimed okf_append_log and okf_regenerate_indexes validate documents against producer-grade rules before writing. Neither does: AppendLog validates only its kind/text arguments, and IndexGenerator performs no document validation at all. Only okf_write_concept does. - README/CHANGELOG/ROADMAP said verify clears audit's --trust unverified,machine-confirmed selection unconditionally; that only holds for a human: actor — a process:/agent: actor moves a concept to machine-confirmed, which that filter still selects. - BundleConceptWriter.RecordVerifications' XML doc said every concept is resolved inside the bundle lock; resolution happens before the lock is taken, matching AppendToConceptAtomic's shape and the method's own body comment. - UpsertStamp's doc claimed the writer never deletes an entry it isn't replacing; true only for the sequence/mapping shapes — a malformed scalar verified value is discarded whole, the same shape BundleValidator already flags as VerifiedMalformed. Scoped the claim; behaviour unchanged. - Moved a misplaced doc-comment in OkfVerifyToolTests.cs onto the test it actually describes, and gave the test it had been sitting on its own. - Added the two CliTests.cs invocation cases (--by with no value, empty stdin) that the design spec's §5.3 message table specifies but the theory omitted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- CHANGELOG.md | 9 +++++--- README.md | 7 +++++-- ROADMAP.md | 7 +++++-- src/OKF4net.Agents/OkfBundleTools.cs | 21 +++++++++---------- src/OKF4net.Agents/README.md | 19 ++++++++++------- src/OKF4net.Cli/OkfCli.cs | 14 +++++++------ src/OKF4net.Mcp/README.md | 11 ++++++---- src/OKF4net/BundleConceptWriter.cs | 21 +++++++++++++------ .../Agents/OkfVerifyToolTests.cs | 8 +++++-- tests/OKF4net.Tests/CliTests.cs | 14 +++++++++---- 10 files changed, 84 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f9bc4d..53a6076d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,12 @@ and this project adheres to - **`okf verify … --by `** — the verb that answers what `okf audit` asks about trust: it records a review (§5.2) by adding, or from the same actor replacing, a `{by, at}` entry in each named concept's - `verified` list, so the concept clears audit's trust-filtered - (`--trust unverified`/`unverified,machine-confirmed`) selection. - Verification only moves the trust dimension (§5.3) — it never touches + `verified` list, so — for a `human:` actor — the concept clears audit's + trust-filtered (`--trust unverified`/`unverified,machine-confirmed`) + selection. A `process:`/`agent:` actor is accepted symmetrically (§7) but + only moves the concept from `unverified` to `machine-confirmed`, which + that same filter still selects. Verification only moves the trust + dimension (§5.3) — it never touches `stale_after`, so a just-reviewed concept can still appear in `okf audit`'s *default* worklist, which selects on staleness alone. `…` also accepts a single `-`, reading concept ids from standard input, so diff --git a/README.md b/README.md index eb304119..c8c61b68 100644 --- a/README.md +++ b/README.md @@ -220,8 +220,11 @@ for a repeat review from the same actor, replaces — a `{ by, at }` entry in each named concept's `verified` list. It is the verb that answers what `okf audit` asks about trust: audit finds concepts a human has never reviewed (`--trust unverified` / `unverified,machine-confirmed`), verify records that -the review happened, and the reviewed concept clears that trust-filtered -selection. Verification only moves the trust dimension (§5.3) — it never +the review happened, and — for a `human:` actor — the reviewed concept +clears that trust-filtered selection. A `process:`/`agent:` actor is accepted +symmetrically (§7), but only moves the concept from `unverified` to +`machine-confirmed` (§5.3), which `--trust unverified,machine-confirmed` +still selects. Verification only moves the trust dimension (§5.3) — it never touches `stale_after`, so a concept just reviewed can still show up in `okf audit`'s *default* worklist, which selects on staleness alone (see above). `…` also accepts a single `-`, reading one concept id per line diff --git a/ROADMAP.md b/ROADMAP.md index a2ec73a6..286f4331 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,8 +23,11 @@ are the concrete entry points. - **`okf verify` shipped** — the verb that answers what `okf audit` asks about trust: it records a review by adding, or from the same actor replacing, a `{by, at}` entry in a named concept's `verified` list (§5.2), - so the concept clears audit's trust-filtered selection at the next pass. - Verification only moves the trust dimension (§5.3) — `stale_after` is + so — for a `human:` actor — the concept clears audit's trust-filtered + selection at the next pass. A `process:`/`agent:` actor is accepted + symmetrically (§7) but only moves the concept from `unverified` to + `machine-confirmed`, which `--trust unverified,machine-confirmed` still + selects. Verification only moves the trust dimension (§5.3) — `stale_after` is untouched, so a just-reviewed concept can still appear in `okf audit`'s *default* (staleness-only) worklist. `…` accepts `-` to read ids from standard input, so `okf audit … --trust unverified | cut diff --git a/src/OKF4net.Agents/OkfBundleTools.cs b/src/OKF4net.Agents/OkfBundleTools.cs index fab0546b..234b8da9 100644 --- a/src/OKF4net.Agents/OkfBundleTools.cs +++ b/src/OKF4net.Agents/OkfBundleTools.cs @@ -587,17 +587,16 @@ public string Verify( return RunTool(() => { - // Pre-resolved like the CLI (OkfCli.cs's CmdVerify): every id is - // checked for BOTH existence and §11 conformance before the first - // write, so a typo or a typeless draft in the third id cannot - // leave the first two stamped. Existence alone would not be - // enough: Bundle indexes any document that parses, including one - // with no `type`, which the writer then refuses at write time — - // naming the offender only through the writer's own error would - // leave an agent bisecting an eight-id batch by hand to find - // which one lacks `type`. Without either check here at all, - // `okf_verify("a, nope", …)` would write to `a` and then report a - // failure — the worst of both. + // Pre-resolved like the CLI (OkfCli.cs's CmdVerify). The writer + // already refuses the whole batch atomically on its own if any id + // is unknown or non-conformant — RecordVerifications resolves, + // reads, parses and validates every concept before writing any — + // so this loop is not what stops a half-stamped batch. What it + // buys is message quality: naming the offender directly ("concept + // 'x' does not exist" / "concept 'x' has no `type`...") instead of + // the writer's unattributed "Missing required frontmatter keys: + // type", which would leave an agent bisecting an eight-id batch by + // hand to find which one lacks `type`. var bundle = GetBundle(); foreach (var id in ids) { diff --git a/src/OKF4net.Agents/README.md b/src/OKF4net.Agents/README.md index 83a133ba..a1277f30 100644 --- a/src/OKF4net.Agents/README.md +++ b/src/OKF4net.Agents/README.md @@ -39,13 +39,18 @@ expected errors (unknown ids, invalid paths, malformed input) — the agent receives an explanatory message instead. All four write tools (`okf_write_concept`, `okf_verify`, `okf_append_log`, `okf_regenerate_indexes`) serialize their writes and rely on the Agent Framework's tool-approval -mechanism for gating. `okf_write_concept`, `okf_append_log` and -`okf_regenerate_indexes` validate documents against the stricter -producer-grade OKF rules before touching disk; `okf_verify` deliberately -enforces only §11 conformance (a non-empty `type`) instead — recording a -review is not producing content, and refusing a reviewer because a concept -is missing a `description` would make precisely the concepts an audit -surfaces unstampable. It records a `{by, at}` review stamp (§5.2) — a dated +mechanism for gating. Their validation levels differ, though: only +`okf_write_concept` checks a document against the stricter producer-grade +OKF rules (non-empty `type`, `title`, `description`) before touching disk; +`okf_verify` deliberately enforces only §11 conformance (a non-empty `type`) +instead — recording a review is not producing content, and refusing a +reviewer because a concept is missing a `description` would make precisely +the concepts an audit surfaces unstampable. `okf_append_log` validates only +its own `kind`/`text` arguments (non-empty, no embedded newline or null +byte) and re-renders `log.md` through the §9 model — it does not touch +concept documents at all. `okf_regenerate_indexes` performs no document +validation whatsoever; it only rebuilds `index.md` listings from whatever is +already on disk. It records a `{by, at}` review stamp (§5.2) — a dated declaration, not a proof; see the project README's `okf verify` section for what it does and doesn't guarantee. Bundle content is treated as untrusted and is never injected as a system message. diff --git a/src/OKF4net.Cli/OkfCli.cs b/src/OKF4net.Cli/OkfCli.cs index 00c8316b..e704b42e 100644 --- a/src/OKF4net.Cli/OkfCli.cs +++ b/src/OKF4net.Cli/OkfCli.cs @@ -640,12 +640,14 @@ private static int CmdVerify(string[] args, TextReader stdin, TextWriter stdout) throw new CliOperationException($"concept '{duplicate.Key}' is named more than once"); } - // Every id is resolved AND checked for §11 conformance before anything - // is written. Existence alone would not be enough: Bundle indexes any - // document that parses, including one with no `type`, which the writer - // then refuses at write time — so a mistyped id in third position would - // leave the first two stamped. Both checks here, so a rejected batch - // is true rather than nearly true. + // The writer itself already refuses the whole batch atomically if any + // id is unknown or non-conformant (BundleConceptWriter.RecordVerifications + // resolves, reads, parses and validates every concept before writing + // any) — this loop does not exist to prevent a half-stamped batch. + // What it buys is message quality: naming the offending id directly + // ("unknown concept \"x\"" / "concept \"x\" has no `type`...") instead + // of the writer's unattributed "Missing required frontmatter keys: + // type", which does not say which of several ids was at fault. foreach (var id in ids) { if (!ConceptId.TryParse(id, out var parsedId) || bundle.Get(parsedId!) is not { } concept) diff --git a/src/OKF4net.Mcp/README.md b/src/OKF4net.Mcp/README.md index af0fc136..e4940872 100644 --- a/src/OKF4net.Mcp/README.md +++ b/src/OKF4net.Mcp/README.md @@ -74,10 +74,13 @@ or `OKF_BUNDLE_ROOT` in `claude_desktop_config.json`. Each is the corresponding `OkfBundleTools` operation, so all OKF v0.2 behaviour, path-safety, and locking apply unchanged — including each write tool's exact -validation level: producer-grade for `okf_write_concept`, `okf_append_log` -and `okf_regenerate_indexes`, but only §11 conformance (a non-empty `type`) -for `okf_verify`, deliberately, so a concept missing a `description` can -still be reviewed. +validation level, which differs per tool: producer-grade (non-empty `type`, +`title`, `description`) for `okf_write_concept`; only §11 conformance (a +non-empty `type`) for `okf_verify`, deliberately, so a concept missing a +`description` can still be reviewed; `okf_append_log` validates only its own +`kind`/`text` arguments, not any concept document; and `okf_regenerate_indexes` +performs no document validation at all — it only rebuilds `index.md` listings +from what is already on disk. That's twelve tools full (eight read-only tools above plus the four write tools), or eight when `OKF_MCP_READONLY=1` drops the four write tools. diff --git a/src/OKF4net/BundleConceptWriter.cs b/src/OKF4net/BundleConceptWriter.cs index 820698ef..546f21c0 100644 --- a/src/OKF4net/BundleConceptWriter.cs +++ b/src/OKF4net/BundleConceptWriter.cs @@ -471,10 +471,13 @@ public string AppendToConceptAtomic( /// in each concept's §5.2 verified list, /// preserving every other frontmatter key and the body. /// - /// Fully validated before the first write: every concept is resolved, read, - /// edited and validated inside one hold of the bundle lock, before a single - /// byte is written. A batch is therefore REJECTED as a whole — an unknown - /// id, a malformed actor or a non-conformant document writes nothing. + /// Fully validated before the first write: every concept id is resolved + /// to a target path before the bundle lock is taken (like + /// does); then, inside one hold of + /// that lock, each target is read, edited and validated, all before a + /// single byte is written. A batch is therefore REJECTED as a whole — an + /// unknown id, a malformed actor or a non-conformant document writes + /// nothing. /// /// It is NOT a transaction. Writing several files cannot be atomic in /// .NET, so a failure during the write phase (I/O, permissions, a reparse @@ -655,8 +658,14 @@ public VerificationOutcome RecordVerifications(IReadOnlyList conceptIds, /// Returns the verified sequence with 's stamp /// added, or replaced at its existing position. /// is immutable, so the list is rebuilt; only the FIRST entry matching the - /// actor is replaced — a permissive reader accepts duplicates, and this - /// writer never deletes an entry it is not replacing. + /// actor is replaced — a permissive reader accepts duplicates, and when + /// is already a sequence (or the single-entry + /// mapping shape it is normalized into) this writer never deletes an + /// entry it is not replacing. A malformed verified value that is + /// neither — a bare scalar such as verified: 2026-01-01 — is not + /// preserved at all: it is discarded whole and replaced by a new + /// single-entry sequence, the same input shape + /// already reports. ///
private static YamlSequence UpsertStamp(YamlValue? existing, string by, string at, out string? replacedAt) { diff --git a/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs index ccd2041d..b65a03f1 100644 --- a/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs +++ b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs @@ -63,8 +63,8 @@ public void Verify_reports_an_unknown_concept_without_writing() } /// - /// All-or-nothing across the whole list: one unknown id leaves every other - /// concept untouched. A single-id test cannot catch this. + /// A repeated id is refused outright, rather than silently collapsed to + /// one stamp or double-recorded as two. /// [Fact] public void Verify_refuses_a_concept_named_twice() @@ -79,6 +79,10 @@ public void Verify_refuses_a_concept_named_twice() Assert.Equal(before, File.ReadAllText(Path.Combine(tmp.Path, "a.md"))); } + /// + /// All-or-nothing across the whole list: one unknown id leaves every other + /// concept untouched. A single-id test cannot catch this. + /// [Fact] public void Verify_writes_nothing_when_one_id_of_several_is_unknown() { diff --git a/tests/OKF4net.Tests/CliTests.cs b/tests/OKF4net.Tests/CliTests.cs index 8d2036dd..6fdf73a7 100644 --- a/tests/OKF4net.Tests/CliTests.cs +++ b/tests/OKF4net.Tests/CliTests.cs @@ -1013,10 +1013,11 @@ public void Verify_writes_nothing_when_one_id_is_unknown() } /// - /// Existence is not enough for the pre-flight: a document with no `type` - /// loads into the bundle but is refused at write time, so without the - /// conformance check here the concepts named before it would already be - /// stamped. + /// A document with no `type` loads into the bundle but is refused at + /// write time by BundleConceptWriter.RecordVerifications itself, + /// which validates every concept before writing any — so this pins that + /// the whole batch is still rejected, and via the CLI's own message + /// (naming the concept) rather than the writer's unattributed one. /// [Fact] public void Verify_writes_nothing_when_one_concept_is_not_conformant() @@ -1070,6 +1071,11 @@ public void Verify_dry_run_writes_nothing() [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "hier" }, "error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"hier\"\n")] [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "2026-08-28" }, "error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"2026-08-28\"\n")] [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "2026-08-28T09:14:00+02:00" }, "error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"2026-08-28T09:14:00+02:00\"\n")] + // --by present but with nothing attached to it. + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by" }, "error: --by requires a value\n")] + // "-" (stdin) with nothing on it -- Run() below passes TextReader.Null, + // whose ReadLine() returns null immediately, so ReadIdsFrom sees zero ids. + [InlineData(new[] { "verify", "BUNDLE", "-", "--by", "human:ada" }, "error: no concept ids on standard input\n")] public void Verify_rejects_bad_invocations(string[] args, string expected) { using var tmp = new TempDir(); From ce683a0f4972924d7aa51c39484956ed6d858fce Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Sat, 29 Aug 2026 14:29:00 +0200 Subject: [PATCH 22/27] fix(verify): refuse a control-bearing actor at the write gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Actor.Parse` marks an actor well-formed on a non-empty id alone, so `human:ada\nrecorded secrets/master-key human:ceo 2020-01-01T00:00:00Z` passed every check and both renderers interpolate `by` into a line-oriented result with no escaping. `okf verify` printed a complete `recorded …` line for a concept it never touched, at exit 0; the `okf_verify` tool did the same into an agent's transcript. The stored YAML was never the problem — the emitter quotes and escapes the value, and the round trip holds. The exposure is the rendered line, so the fix is a write-time restriction, not an emitter change and not per-renderer escaping: `BundleConceptWriter.RecordVerifications`, the single governed writer of §5.2 `verified`, now refuses an actor carrying a C0/C1 control character (or U+2028/U+2029, which JavaScript-family line splitters treat as terminators). The CLI verb and the tool re-run the same predicate only to phrase a message that names the flag; the predicate itself lives once, on `Actor`, for the reason `ConceptSearch` and `LfLines` live once. Ordered before each layer's well-formedness message, because those echo `by`: echoing a newline-bearing value moves the forged line from stdout into stderr rather than stopping it. None of the three messages echoes the refused value. `Actor.Parse` is deliberately left permissive — it is also the read path for `Trust.DeriveTier` and `BundleValidator`, and tightening it would change trust-tier and validation behaviour for already-stored actors. `okf_write_concept` also stays unguarded by design, so a bundle can still hold a control-bearing actor this gate never saw; that residual, and the obligation it puts on any future feature that renders a stored actor, is stated on the predicate itself. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- src/OKF4net.Agents/OkfBundleTools.cs | 24 +++++++++- src/OKF4net.Cli/OkfCli.cs | 13 +++++ src/OKF4net/Actor.cs | 48 +++++++++++++++++++ src/OKF4net/BundleConceptWriter.cs | 18 +++++++ .../Agents/OkfVerifyToolTests.cs | 29 +++++++++++ tests/OKF4net.Tests/CliTests.cs | 36 ++++++++++++++ .../OKF4net.Tests/RecordVerificationTests.cs | 44 +++++++++++++++++ 7 files changed, 211 insertions(+), 1 deletion(-) diff --git a/src/OKF4net.Agents/OkfBundleTools.cs b/src/OKF4net.Agents/OkfBundleTools.cs index 234b8da9..7eb6df0c 100644 --- a/src/OKF4net.Agents/OkfBundleTools.cs +++ b/src/OKF4net.Agents/OkfBundleTools.cs @@ -35,6 +35,17 @@ public sealed class OkfBundleTools + "§7 actor (human:, agent:/, process:). Example: " + "okf_verify(\"metrics/dau, metrics/revenue\", \"human:ada\")."; + /// + /// Refusal message for an actor carrying a character that would break the + /// rendered line. The predicate is shared + /// (Actor.ContainsControlCharacter); only the phrasing is local, so + /// it reads like this class's other Error: … results rather than + /// like the CLI's. Deliberately does NOT echo the offending value: doing so + /// would put the newline it is refusing into this very message. + /// + private const string VerifyControlCharacterMessage = + "Error: a §7 actor must not contain control characters."; + /// /// The core write primitive this tool set delegates every write to: /// producer-validated create/update () and @@ -573,13 +584,24 @@ public string WriteConcept( [Description("Record a review of one or more concepts: adds or replaces the caller's { by, at } entry in each concept's `verified` list. The stamp is a dated declaration, not a proof — the same rules as the okf verify CLI verb.")] public string Verify( [Description("Comma-separated concept ids (paths without .md). Explicit ids only — there is no whole-bundle form.")] string conceptIds, - [Description("The §7 actor recording the review, e.g. human:ada, agent:assistant/1.0, process:nightly.")] string by, + [Description("The §7 actor recording the review, e.g. human:ada, agent:assistant/1.0, process:nightly. Must not contain control characters.")] string by, [Description("UTC timestamp in the exact form yyyy-MM-ddTHH:mm:ssZ, e.g. 2026-08-28T09:14:00Z — no fractional seconds, no offset, no bare date. Omit for now.")] string? at = null) { var ids = (conceptIds ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList(); + // Refused ahead of the usage message so a control-bearing actor gets a + // message that names the actual problem — an agent handed the generic + // usage text would most likely retry the same value. The write gate + // (BundleConceptWriter.RecordVerifications) is what stops the value + // being stored; this shares its one predicate rather than testing + // characters itself. See Actor.ContainsControlCharacter. + if (by is not null && Actor.ContainsControlCharacter(by)) + { + return VerifyControlCharacterMessage; + } + if (ids.Count == 0 || by is null || !Actor.Parse(by).IsWellFormed) { return VerifyUsageMessage; diff --git a/src/OKF4net.Cli/OkfCli.cs b/src/OKF4net.Cli/OkfCli.cs index e704b42e..84fd59ed 100644 --- a/src/OKF4net.Cli/OkfCli.cs +++ b/src/OKF4net.Cli/OkfCli.cs @@ -612,6 +612,19 @@ private static int CmdVerify(string[] args, TextReader stdin, TextWriter stdout) throw new CliOperationException("verify requires --by "); } + // Checked BEFORE the well-formedness message below, which echoes `by`: + // a newline in an echoed value forges a line in the caller's error + // output. The write gate (BundleConceptWriter.RecordVerifications) is + // what actually stops the value from being stored — see + // Actor.ContainsControlCharacter; this call site exists only so the + // message names the flag instead of arriving unattributed from the + // writer, which is why it shares that one predicate rather than + // spelling out its own character test. + if (Actor.ContainsControlCharacter(by)) + { + throw new CliOperationException("--by must not contain control characters"); + } + if (!Actor.Parse(by).IsWellFormed) { throw new CliOperationException($"--by is not a well-formed §7 actor: \"{by}\""); diff --git a/src/OKF4net/Actor.cs b/src/OKF4net/Actor.cs index e5f306f1..0efe5703 100644 --- a/src/OKF4net/Actor.cs +++ b/src/OKF4net/Actor.cs @@ -48,4 +48,52 @@ public static Actor Parse(string raw) return new Actor(raw, ActorKind.Producer, null, null, null, false); } + + /// + /// True when carries a character that would break a + /// line-oriented rendering of it: any C0/C1 control character + /// (\n and \r among + /// them, plus ESC, which forges appearance in a terminal), plus + /// U+2028/U+2029, which does not + /// classify as control but which JavaScript-family line splitters treat as + /// terminators. + /// + /// This predicate is the whole defense, and it belongs on the WRITE + /// path. — the + /// single governed writer of the §5.2 verified field — refuses an + /// actor it rejects, so no such value can be stored by okf verify + /// or okf_verify; the CLI verb and the okf_verify tool call + /// it again only to phrase a better message, never as a second line of + /// defense. That is what lets both renderers stay simple: they interpolate + /// by into a line with no escaping at all, which is safe precisely + /// because a control-bearing actor never reaches them. One predicate, three + /// call sites — a forked character test would let the three drift, exactly + /// the failure mode ConceptSearch and Internal/LfLines exist + /// to prevent. + /// + /// Two limits, stated plainly. is deliberately NOT + /// tightened: it is also the READ path (Trust.DeriveTier, + /// BundleValidator), and an already-stored actor must keep parsing + /// as it did. And okf_write_concept can still write a whole + /// frontmatter, verified included, with no such check — deliberately + /// unguarded (see the README). So a bundle can hold a control-bearing actor + /// this gate never saw: any FUTURE feature that renders a stored actor owes + /// its output its own escaping, and must not assume this predicate ran. + /// + /// The raw actor string. + internal static bool ContainsControlCharacter(string raw) + { + foreach (var c in raw) + { + // The two separators are written as numeric constants on purpose: + // a literal U+2028 in source is invisible in every editor and diff + // that would have to review this line. + if (char.IsControl(c) || c is (char)0x2028 or (char)0x2029) + { + return true; + } + } + + return false; + } } diff --git a/src/OKF4net/BundleConceptWriter.cs b/src/OKF4net/BundleConceptWriter.cs index 546f21c0..881950ae 100644 --- a/src/OKF4net/BundleConceptWriter.cs +++ b/src/OKF4net/BundleConceptWriter.cs @@ -524,6 +524,24 @@ public VerificationOutcome RecordVerifications(IReadOnlyList conceptIds, } } + // THE governed gate for a control-bearing actor — see + // Actor.ContainsControlCharacter for why it lives on the write path and + // what it does not cover. Every layer above inherits it from here: the + // CLI verb and okf_verify re-run the same predicate only to phrase a + // better message, and both renderers interpolate `by` into a line with + // no escaping precisely because nothing this method wrote can carry a + // newline. Note this refuses the value rather than sanitizing it: an + // actor is an identity, and silently rewriting one would store a + // different identity than the caller asked for. + // + // Checked BEFORE the well-formedness arm below, whose message echoes + // `by`: echoing a newline-bearing value would forge a line in the + // caller's error output — the very thing being closed here. + if (by is not null && Actor.ContainsControlCharacter(by)) + { + return Failed("Error: a §7 actor must not contain control characters."); + } + // Strict on input, permissive on read: `human:` with no id promotes the // tier (Actor.IsHuman ignores well-formedness), so it must never be // written here even though a parser would accept it. diff --git a/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs index b65a03f1..358bc4a8 100644 --- a/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs +++ b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs @@ -50,6 +50,35 @@ public void Verify_returns_a_usage_message_for_a_malformed_actor() Assert.Contains("Usage: okf_verify", ToolsOver(tmp).Verify("metrics/dau", "human:")); } + /// + /// The tool's result is line-oriented and interpolates by with no + /// escaping, so an actor carrying a newline forged a complete + /// recorded <concept> … line for a concept never touched — + /// and human:ada\n… is well-formed by , so + /// the usage check above never saw it. The refusal comes from the write + /// gate (BundleConceptWriter.RecordVerifications, via the shared + /// Actor.ContainsControlCharacter); the tool re-runs that one + /// predicate only so the message names the problem instead of handing an + /// agent generic usage text it would retry unchanged. The message must not + /// echo the value — that would move the forged line from the success + /// output into the error output. + /// + [Fact] + public void Verify_refuses_an_actor_carrying_a_control_character() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", "---\ntype: Metric\n---\n\nbody\n"); + var before = File.ReadAllText(Path.Combine(tmp.Path, "metrics", "dau.md")); + + var text = ToolsOver(tmp).Verify( + "metrics/dau", + "human:ada\nrecorded secrets/master-key human:ceo 2020-01-01T00:00:00Z"); + + Assert.Equal("Error: a §7 actor must not contain control characters.", text); + Assert.DoesNotContain("recorded", text); + Assert.Equal(before, File.ReadAllText(Path.Combine(tmp.Path, "metrics", "dau.md"))); + } + [Fact] public void Verify_reports_an_unknown_concept_without_writing() { diff --git a/tests/OKF4net.Tests/CliTests.cs b/tests/OKF4net.Tests/CliTests.cs index 6fdf73a7..fa84c850 100644 --- a/tests/OKF4net.Tests/CliTests.cs +++ b/tests/OKF4net.Tests/CliTests.cs @@ -1062,6 +1062,38 @@ public void Verify_dry_run_writes_nothing() Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); } + /// + /// An actor carrying a newline could otherwise forge a whole recorded + /// … line — the renderer interpolates by into a line-oriented + /// result with no escaping — naming a concept the command never touched, + /// at exit 0. The refusal is the write gate's + /// (BundleConceptWriter.RecordVerifications, via the shared + /// Actor.ContainsControlCharacter); this pins that the CLI reports + /// it as a flag error and, crucially, that the message does NOT echo the + /// value — echoing it would put the refused newline into stderr instead. + /// + [Fact] + public void Verify_refuses_an_actor_carrying_a_control_character() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run( + "verify", + bundle, + "metrics/dau", + "--by", + "human:ada\nrecorded secrets/master-key human:ceo 2020-01-01T00:00:00Z", + "--at", + "2026-08-28T09:14:00Z"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: --by must not contain control characters\n", r.Err); + Assert.Equal(string.Empty, r.Out); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + [Theory] [InlineData(new[] { "verify", "BUNDLE" }, "error: missing \n")] [InlineData(new[] { "verify", "BUNDLE", "metrics/dau" }, "error: verify requires --by \n")] @@ -1073,6 +1105,10 @@ public void Verify_dry_run_writes_nothing() [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "human:ada", "--at", "2026-08-28T09:14:00+02:00" }, "error: --at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"2026-08-28T09:14:00+02:00\"\n")] // --by present but with nothing attached to it. [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by" }, "error: --by requires a value\n")] + // An actor that is BOTH control-bearing and malformed: the control-character + // arm must win, because the well-formedness message echoes the value and + // would put the refused newline straight into stderr. + [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "\nrecorded x human:ceo" }, "error: --by must not contain control characters\n")] // "-" (stdin) with nothing on it -- Run() below passes TextReader.Null, // whose ReadLine() returns null immediately, so ReadIdsFrom sees zero ids. [InlineData(new[] { "verify", "BUNDLE", "-", "--by", "human:ada" }, "error: no concept ids on standard input\n")] diff --git a/tests/OKF4net.Tests/RecordVerificationTests.cs b/tests/OKF4net.Tests/RecordVerificationTests.cs index f45a3313..263004f1 100644 --- a/tests/OKF4net.Tests/RecordVerificationTests.cs +++ b/tests/OKF4net.Tests/RecordVerificationTests.cs @@ -234,6 +234,50 @@ public void A_malformed_actor_is_refused(string by, string expected) Assert.DoesNotContain("verified", Read(tmp, "metrics/dau.md")); } + /// + /// The governed gate for a control-bearing actor, and the reason both + /// renderers above it can stay escaping-free. human:ada\nrecorded … + /// is WELL-FORMED by (a human: prefix and + /// a non-empty id), so nothing before this check would have stopped it; + /// interpolated into the CLI's or the tool's line-oriented output it forged + /// a complete recorded <concept> … line for a concept the + /// command never touched, at exit 0. + /// + /// Refused here rather than escaped at the renderers: this is the single + /// governed writer of verified, so one check covers the CLI verb, + /// the okf_verify tool and every future caller — and the value is + /// rejected, not sanitized, because an actor is an identity. + /// itself stays permissive on purpose (it is also + /// the read path for Trust.DeriveTier and BundleValidator), + /// and okf_write_concept remains an unguarded path by design — so + /// this is a WRITE-time restriction, not a promise about what a bundle can + /// hold. + /// + [Theory] + [InlineData("human:ada\nrecorded secrets/master-key human:ceo 2020-01-01T00:00:00Z")] + [InlineData("human:ada\rrecorded x")] + // ESC: forges appearance in a terminal rather than a new line. + [InlineData("human:\u001b[2Kada")] + // U+2028: not char.IsControl, but a line terminator to JavaScript-family + // splitters, so the predicate names it explicitly. + [InlineData("human:ada\u2028recorded x")] + public void An_actor_carrying_a_control_character_is_refused(string by) + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + var before = Read(tmp, "metrics/dau.md"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau"], by); + + Assert.False(outcome.Recorded); + Assert.Equal("Error: a §7 actor must not contain control characters.", outcome.Message); + // The message must not carry the refused value: echoing it would forge + // a line in the caller's error output instead of the success output. + Assert.DoesNotContain("recorded", outcome.Message); + Assert.Empty(outcome.Records); + Assert.Equal(before, Read(tmp, "metrics/dau.md")); + } + /// /// Pins the deliberate divergence from BundleValidator.IsIso8601DateTime /// (which validates only the date part and ignores everything after the From b95664b853ba7f1a9a3eae283466964894efabcf Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Sat, 29 Aug 2026 14:33:32 +0200 Subject: [PATCH 23/27] fix(yaml): raise an OkfException when the emitter's depth guard trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `YamlParser` enforces its 1000-level cap with two independent counters (one for block nesting, one for flow); `YamlEmitter` has a single counter covering both. A frontmatter mixing ~450 block levels with ~600 flow levels therefore parses cleanly and then trips the emitter — a hostile-but-loadable concept file, reachable through any ordinary read-modify-write. The guard threw a bare `InvalidOperationException`, which is in neither `RunTool` catch filter (`OkfException or ArgumentException or IOException or UnauthorizedAccessException or DecoderFallbackException`) nor `OkfCli.Run`'s (`CliOperationException` only). So `okf_verify` threw out of the `AIFunction` into the MCP host, and the CLI died with a stack trace — while `VerificationOutcome` documents errors-as-data, never thrown. Nothing was ever written: the throw lands in the prepare loop, so batch atomicity held. Fixed at the exception type, not the counters: the parser already signals the same condition as a `YamlParseException : OkfException`, so the emitter now has an equivalent `YamlEmitException : OkfException` and every existing filter covers it. Reconciling one counter with two is a change to what the library accepts on the READ path; it is left alone and documented on the guard. `OkfCli.Run` also gains an `OkfException` arm, so any library failure a verb did not anticipate prints `error: ` and exits 1 instead of a stack trace. Strict improvement for all nine verbs; no golden pinned a crash. Deliberately narrow — an unexpected BCL exception still crashes loudly rather than being reported as a routine failure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- src/OKF4net.Cli/OkfCli.cs | 15 +++++ src/OKF4net/Yaml/YamlEmitException.cs | 27 +++++++++ src/OKF4net/Yaml/YamlEmitter.cs | 18 ++++-- .../Agents/OkfVerifyToolTests.cs | 21 +++++++ tests/OKF4net.Tests/CliTests.cs | 24 ++++++++ tests/OKF4net.Tests/DeepYamlDocument.cs | 60 +++++++++++++++++++ .../OKF4net.Tests/RecordVerificationTests.cs | 33 ++++++++++ .../OKF4net.Tests/Yaml/YamlRoundtripTests.cs | 23 ++++++- 8 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 src/OKF4net/Yaml/YamlEmitException.cs create mode 100644 tests/OKF4net.Tests/DeepYamlDocument.cs diff --git a/src/OKF4net.Cli/OkfCli.cs b/src/OKF4net.Cli/OkfCli.cs index 84fd59ed..859e9787 100644 --- a/src/OKF4net.Cli/OkfCli.cs +++ b/src/OKF4net.Cli/OkfCli.cs @@ -119,6 +119,21 @@ public static int Run(string[] args, TextReader stdin, TextWriter stdout, TextWr stderr.Write($"error: {e.Message}\n"); return 1; } + catch (OkfException e) + { + // The safety net for every verb, not a substitute for the targeted + // catches below: those exist to phrase a better message (naming the + // file, the flag, the concept) and still run first. This one only + // catches a library failure no verb anticipated — a YAML emit + // failure on a document that parsed, say — and turns it into the + // same `error: …`/exit 1 shape as everything else, instead of a + // stack trace and exit 127. Deliberately narrow: `OkfException` is + // this library's own expected-error base, so an unexpected BCL + // exception still crashes loudly rather than being reported as a + // routine failure. + stderr.Write($"error: {e.Message}\n"); + return 1; + } } /// Handles an unknown subcommand: writes the message and usage directly, bypassing the error: prefix. diff --git a/src/OKF4net/Yaml/YamlEmitException.cs b/src/OKF4net/Yaml/YamlEmitException.cs new file mode 100644 index 00000000..f1655fcf --- /dev/null +++ b/src/OKF4net/Yaml/YamlEmitException.cs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +namespace OKF4net.Yaml; + +/// +/// An error produced while EMITTING YAML — today, only a +/// tree deeper than the emitter's own nesting limit. +/// +/// It derives from for one reason: the parser +/// signals the same condition as a , which is +/// an , and every layer that turns library failures +/// into data already catches that base type +/// (BundleConceptWriter's RunTool, the CLI's top-level handler). +/// A bare here escaped both: it threw +/// out of okf_verify into the MCP host, and killed the CLI with a stack +/// trace — while VerificationOutcome promises errors-as-data, never +/// thrown. The type is what makes that promise true on every path, so an +/// emitter failure must never be signalled with anything else. +/// +public sealed class YamlEmitException : OkfException +{ + /// Creates the exception with a descriptive message. + /// What could not be emitted. + public YamlEmitException(string message) + : base($"YAML emit error: {message}") + { + } +} diff --git a/src/OKF4net/Yaml/YamlEmitter.cs b/src/OKF4net/Yaml/YamlEmitter.cs index 5c44993a..9d9a94b1 100644 --- a/src/OKF4net/Yaml/YamlEmitter.cs +++ b/src/OKF4net/Yaml/YamlEmitter.cs @@ -24,8 +24,18 @@ public static class YamlEmitter /// YamlParser.MaxNestingDepth. A safety guard: a pathologically /// deep tree — however it was constructed, since /// this guards the emitter independently of the parser's own limit — - /// throws a catchable here - /// instead of overflowing the stack. + /// throws a catchable here instead of + /// overflowing the stack. + /// + /// The numbers match; the counting does not. YamlParser enforces + /// its limit with TWO independent counters (one for block nesting, one for + /// flow), while this emitter has a single counter covering both, so a + /// frontmatter mixing, say, 450 block levels with 900 flow levels parses + /// happily and then exceeds the limit here. That asymmetry is a real + /// problem and is deliberately NOT fixed by this guard: it is a change to + /// what the library ACCEPTS, on the read path, and belongs in its own pass + /// with its own tests. What matters here is that reaching this line is + /// errors-as-data on every caller's path — see . /// private const int MaxNestingDepth = 1000; @@ -57,7 +67,7 @@ private static void EmitMapping(YamlMapping map, int indent, int depth, StringBu { if (depth > MaxNestingDepth) { - throw new InvalidOperationException("nesting depth limit exceeded"); + throw new YamlEmitException("nesting depth limit exceeded"); } var pad = new string(' ', indent); @@ -86,7 +96,7 @@ private static void EmitSequence(IReadOnlyList seq, int indent, int d { if (depth > MaxNestingDepth) { - throw new InvalidOperationException("nesting depth limit exceeded"); + throw new YamlEmitException("nesting depth limit exceeded"); } var pad = new string(' ', indent); diff --git a/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs index 358bc4a8..cddafe85 100644 --- a/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs +++ b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs @@ -79,6 +79,27 @@ public void Verify_refuses_an_actor_carrying_a_control_character() Assert.Equal(before, File.ReadAllText(Path.Combine(tmp.Path, "metrics", "dau.md"))); } + /// + /// The vector that made this a host problem rather than a library one: a + /// concept whose frontmatter parses and cannot be re-emitted (see + /// ) reached YamlEmitter's nesting + /// guard, which threw a bare InvalidOperationException — outside + /// both RunTool filters — so the exception left the + /// AIFunction and landed in the MCP host. A tool must always answer + /// with a string. + /// + [Fact] + public void Verify_returns_an_error_string_for_a_document_that_cannot_be_re_emitted() + { + using var tmp = new TempDir(); + tmp.Write("metrics/deep.md", DeepYamlDocument.Text()); + + var text = ToolsOver(tmp).Verify("metrics/deep", "human:ada"); + + Assert.StartsWith("Error: ", text); + Assert.Contains("nesting depth limit exceeded", text); + } + [Fact] public void Verify_reports_an_unknown_concept_without_writing() { diff --git a/tests/OKF4net.Tests/CliTests.cs b/tests/OKF4net.Tests/CliTests.cs index fa84c850..7ad29096 100644 --- a/tests/OKF4net.Tests/CliTests.cs +++ b/tests/OKF4net.Tests/CliTests.cs @@ -1204,6 +1204,30 @@ public void Verify_prints_the_records_that_landed_before_a_later_write_failure() } } + /// + /// A library failure no verb anticipated must still exit like every other + /// failure. A concept whose frontmatter parses and cannot be re-emitted + /// (see ) reached YamlEmitter's + /// nesting guard, which threw a bare InvalidOperationException: + /// OkfCli.Run caught only CliOperationException, so the + /// process died with a stack trace. The emitter now raises an + /// OkfException and Run catches that base type, which is a + /// strict improvement for all nine verbs — no golden pinned a crash. + /// + [Fact] + public void A_document_that_cannot_be_re_emitted_exits_cleanly_rather_than_crashing() + { + using var tmp = new TempDir(); + tmp.Write("metrics/deep.md", DeepYamlDocument.Text()); + + var r = Run("verify", tmp.Path, "metrics/deep", "--by", "human:ada"); + + Assert.Equal(1, r.Code); + Assert.StartsWith("error: ", r.Err); + Assert.Contains("nesting depth limit exceeded", r.Err); + Assert.DoesNotContain(" at ", r.Err); + } + /// The loop, end to end: audit lists it, verify clears it. [Fact] public void Audit_then_verify_removes_the_concept_from_the_unverified_worklist() diff --git a/tests/OKF4net.Tests/DeepYamlDocument.cs b/tests/OKF4net.Tests/DeepYamlDocument.cs new file mode 100644 index 00000000..c63e2bdf --- /dev/null +++ b/tests/OKF4net.Tests/DeepYamlDocument.cs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +using System.Text; + +namespace OKF4net.Tests; + +/// +/// Builds a concept document whose frontmatter parses and then +/// cannot be emitted — the one input shape that reaches +/// YamlEmitter's nesting guard through a normal read-modify-write. +/// +/// It exists because the two limits are counted differently. +/// YamlParser enforces its 1000-level cap with two independent +/// counters, one for block nesting and one for flow; YamlEmitter has a +/// single counter covering both. So a frontmatter mixing 450 block levels +/// with 600 flow levels is under the cap twice on the way in and over it once +/// on the way out. Building it by hand (rather than assembling a +/// YamlValue tree in memory) is the point: this is a file a bundle can +/// actually contain, so every layer that loads and rewrites a concept meets +/// it the way a caller would. +/// +/// The defaults are not symmetric because the counters are not: the block +/// parser charges its counter roughly twice per nesting level (a node and its +/// mapping/nested arm both increment it), so 450 block levels sit near 900 of +/// its 1000 — 600 there fails to PARSE, which would test nothing. The flow +/// counter charges about once per level. Their sum, 1050, is what clears the +/// emitter's single 1000. +/// +/// Shared by the emitter, writer and CLI tests so all three describe the same +/// artifact — a second hand-rolled copy would drift the moment either limit +/// moved. +/// +internal static class DeepYamlDocument +{ + /// + /// A §11-conformant document (non-empty type, so it is stampable) + /// whose deep key nests block + /// mappings and then flow mappings. + /// + internal static string Text(int blockLevels = 450, int flowLevels = 600) + { + var sb = new StringBuilder("---\ntype: Metric\ntitle: Deep\ndeep:\n"); + + // blockLevels - 1 "a:" lines, each one indent step deeper, then a + // final line carrying the flow value on the same line as its key -- + // the shape our parser accepts without a more-indented continuation. + for (var i = 0; i < blockLevels - 1; i++) + { + sb.Append(' ', (i + 1) * 2).Append("a:\n"); + } + + sb.Append(' ', blockLevels * 2).Append("a: "); + for (var i = 0; i < flowLevels; i++) + { + sb.Append("{a: "); + } + + sb.Append('1').Append('}', flowLevels).Append('\n'); + return sb.Append("---\n\nbody\n").ToString(); + } +} diff --git a/tests/OKF4net.Tests/RecordVerificationTests.cs b/tests/OKF4net.Tests/RecordVerificationTests.cs index 263004f1..46d48711 100644 --- a/tests/OKF4net.Tests/RecordVerificationTests.cs +++ b/tests/OKF4net.Tests/RecordVerificationTests.cs @@ -278,6 +278,39 @@ public void An_actor_carrying_a_control_character_is_refused(string by) Assert.Equal(before, Read(tmp, "metrics/dau.md")); } + /// + /// promises errors-as-data, never thrown + /// — and a hostile-but-loadable concept used to break that promise. A + /// frontmatter that parses and cannot be re-emitted (see + /// ) made YamlEmitter throw a bare + /// InvalidOperationException, which is not in RunTool's catch + /// filter, so it escaped this method entirely — out of okf_verify + /// into the MCP host, and out of the CLI as a stack trace. The emitter now + /// signals it as a YamlEmitException (an , + /// like the parser's own), which that filter already covered. + /// + /// The throw lands in the PREPARE loop, before any write, so batch + /// atomicity holds: nothing is written and Records is empty. + /// + [Fact] + public void A_document_that_parses_but_cannot_be_emitted_is_reported_not_thrown() + { + using var tmp = new TempDir(); + tmp.Write("metrics/dau.md", Fm + "---\n\nbody\n"); + tmp.Write("metrics/deep.md", DeepYamlDocument.Text()); + var before = Read(tmp, "metrics/dau.md"); + + var outcome = WriterOver(tmp).RecordVerifications(["metrics/dau", "metrics/deep"], "human:ada"); + + Assert.False(outcome.Recorded); + Assert.Contains("nesting depth limit exceeded", outcome.Message); + Assert.StartsWith("Error: ", outcome.Message); + Assert.Empty(outcome.Records); + // The earlier concept in the batch is untouched: the failure happened + // while preparing, not while writing. + Assert.Equal(before, Read(tmp, "metrics/dau.md")); + } + /// /// Pins the deliberate divergence from BundleValidator.IsIso8601DateTime /// (which validates only the date part and ignores everything after the diff --git a/tests/OKF4net.Tests/Yaml/YamlRoundtripTests.cs b/tests/OKF4net.Tests/Yaml/YamlRoundtripTests.cs index b360dd47..a6195fac 100644 --- a/tests/OKF4net.Tests/Yaml/YamlRoundtripTests.cs +++ b/tests/OKF4net.Tests/Yaml/YamlRoundtripTests.cs @@ -117,6 +117,27 @@ public void Emitting_a_pathologically_deep_value_throws_instead_of_overflowing_t v = new YamlSequence([v]); } - Assert.Throws(() => v.ToYamlString()); + // YamlEmitException, NOT InvalidOperationException: the type is what + // makes this errors-as-data everywhere. Every layer that converts + // library failures into data catches OkfException (the writer's + // RunTool, the CLI's top-level handler); a bare + // InvalidOperationException matched neither filter and escaped both. + Assert.Throws(() => v.ToYamlString()); + } + + /// + /// The reachable version of the guard above: not a tree assembled in + /// memory, but a document a bundle can hold. The parser counts block and + /// flow nesting on two independent counters while the emitter counts both + /// on one, so 600 + 600 levels parse and then fail to emit — proving the + /// throw is reachable from ordinary input, which is what makes its type + /// matter. + /// + [Fact] + public void A_document_can_parse_and_still_exceed_the_emitters_depth() + { + var document = OkfDocument.Parse(DeepYamlDocument.Text()); + + Assert.Throws(() => document.Frontmatter.AsMapping().ToYamlString()); } } From 55e75edd0b08f7880361af0db7d4d2c141702277 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Sat, 29 Aug 2026 14:35:37 +0200 Subject: [PATCH 24/27] fix(cli): make verify's stdin form idempotent and validate flags first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems in the same few lines of `CmdVerify`. `okf audit --trust unverified` deliberately exits 0 with empty output when nothing needs attention, but `okf verify -` on that same empty stream exited 1 with `error: no concept ids on standard input`. The pipeline this branch advertises in the README, the CHANGELOG, the ROADMAP and the CLI docs page was therefore not idempotent, and failed under `set -e` exactly when the bundle was healthy. The cheapest operator workaround, `|| true`, also swallows a genuine partial-write failure — the one outcome the Records-before-throw design exists to surface — so this was a correctness problem, not a cosmetic one. An empty stream is now "nothing to do": write nothing, exit 0, matching `audit`. Every other empty/missing-id case stays an error, so a mistyped `okf verify mybundle` (for `validate`) still fails loudly with `missing `. The removed message is not a row in the design spec's §5.3 error table, so no documented contract moved. Second, stdin was drained before `--by`/`--at` were validated, so an already-invalid invocation blocked on the pipe: `okf verify b -` with no `--by` waited on a slow producer instead of failing immediately, and interactively hung until the user found Ctrl-D. The flag values are now checked first — every error the `[Theory]` pins is decided from the argument list alone, so the message ordering is unchanged — and the regression test hands the verb a reader that throws if touched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- CHANGELOG.md | 6 ++++- README.md | 7 +++++ src/OKF4net.Cli/OkfCli.cs | 46 +++++++++++++++++++++++-------- tests/OKF4net.Tests/CliTests.cs | 48 ++++++++++++++++++++++++++++++--- web/src/pages/docs/Cli.tsx | 7 +++++ 5 files changed, 99 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a6076d..a72cd4df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,11 @@ and this project adheres to *default* worklist, which selects on staleness alone. `…` also accepts a single `-`, reading concept ids from standard input, so `okf audit … --trust unverified | cut -d' ' -f1 | okf verify … --by - human:ada -` closes the loop in one line. `--dry-run` shows what would be + human:ada -` closes the loop in one line. An empty stream on that pipeline + is "nothing to do", not an error: `verify -` writes nothing and exits 0, + matching `audit`'s own empty-worklist exit, so the loop stays idempotent + and safe under `set -e` when the bundle needs no attention. Naming no + concept at all (`okf verify `) is still an error. `--dry-run` shows what would be recorded without writing; `--at ` overrides the default of "now" (a bare date, an offset, or fractional seconds are rejected). A batch is validated (existence, §11 conformance, no duplicate id) before the diff --git a/README.md b/README.md index c8c61b68..136154dc 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,13 @@ from standard input, so the two verbs compose into one line: okf audit bundles/acme_retail --trust unverified | cut -d' ' -f1 | okf verify bundles/acme_retail --by human:ada - ``` +An empty stream there is "nothing to do", not an error: `okf audit` exits 0 +printing nothing when the worklist is empty, and `okf verify -` on that +stream writes nothing and exits 0 too, so the loop is idempotent and safe +under `set -e` on a healthy bundle. Naming no concept at all +(`okf verify `) is still an error — that is the mistyped-`validate` +case, and it stays loud. + Every named concept is checked for existence and §11 conformance before anything is written, so a batch is rejected as a whole at that stage; a mid-batch I/O failure can still leave the concepts already written stamped diff --git a/src/OKF4net.Cli/OkfCli.cs b/src/OKF4net.Cli/OkfCli.cs index 859e9787..e255f17e 100644 --- a/src/OKF4net.Cli/OkfCli.cs +++ b/src/OKF4net.Cli/OkfCli.cs @@ -606,18 +606,11 @@ private static int CmdVerify(string[] args, TextReader stdin, TextWriter stdout) throw new CliOperationException("missing "); } - if (ids.Contains("-")) + // Decided from the ARGUMENTS, before anything reads the pipe. + var readFromStdin = ids is ["-"]; + if (!readFromStdin && ids.Contains("-")) { - if (ids.Count > 1) - { - throw new CliOperationException("\"-\" (stdin) cannot be combined with explicit concept ids"); - } - - ids = ReadIdsFrom(stdin); - if (ids.Count == 0) - { - throw new CliOperationException("no concept ids on standard input"); - } + throw new CliOperationException("\"-\" (stdin) cannot be combined with explicit concept ids"); } // Validated only now: an invocation naming no concept at all is the @@ -658,6 +651,37 @@ private static int CmdVerify(string[] args, TextReader stdin, TextWriter stdout) throw new CliOperationException($"--at is not a UTC timestamp of the form yyyy-MM-ddTHH:mm:ssZ: \"{at}\""); } + // Read LAST of this invocation's inputs, once every flag value is + // known to be usable. Draining the pipe first made an already-doomed + // invocation (`okf verify b -` with no --by) wait behind a slow + // producer, or hang on a terminal until the user found Ctrl-D, before + // printing an error it could have printed immediately. The message + // ordering above is unchanged — every one of those errors is decided + // from the argument list alone. + if (readFromStdin) + { + ids = ReadIdsFrom(stdin); + + // An empty stream is "nothing to do", not an error. This is the + // documented `okf audit … --trust unverified | cut … | okf verify + // … -` pipeline, and `audit` deliberately exits 0 printing nothing + // when the bundle needs no attention; failing here made the + // headline pipeline non-idempotent and broke it under `set -e` + // exactly when the bundle was healthy. The cheapest workaround for + // that, `|| true`, would also swallow a genuine partial-write + // failure — the one outcome the Records-before-throw design exists + // to surface — so this is a correctness fix, not a cosmetic one. + // + // Every other empty/missing-id case stays an error: `okf verify + // ` naming no concept at all is still `missing + // ` above, which is what keeps a mistyped `okf verify + // mybundle` (for `validate`) loud. + if (ids.Count == 0) + { + return 0; + } + } + var bundle = Load(path); // Refused here as well as in the writer, so the message reads like its diff --git a/tests/OKF4net.Tests/CliTests.cs b/tests/OKF4net.Tests/CliTests.cs index 7ad29096..718adc2b 100644 --- a/tests/OKF4net.Tests/CliTests.cs +++ b/tests/OKF4net.Tests/CliTests.cs @@ -1109,9 +1109,6 @@ public void Verify_refuses_an_actor_carrying_a_control_character() // arm must win, because the well-formedness message echoes the value and // would put the refused newline straight into stderr. [InlineData(new[] { "verify", "BUNDLE", "metrics/dau", "--by", "\nrecorded x human:ceo" }, "error: --by must not contain control characters\n")] - // "-" (stdin) with nothing on it -- Run() below passes TextReader.Null, - // whose ReadLine() returns null immediately, so ReadIdsFrom sees zero ids. - [InlineData(new[] { "verify", "BUNDLE", "-", "--by", "human:ada" }, "error: no concept ids on standard input\n")] public void Verify_rejects_bad_invocations(string[] args, string expected) { using var tmp = new TempDir(); @@ -1124,6 +1121,51 @@ public void Verify_rejects_bad_invocations(string[] args, string expected) Assert.Equal(expected, r.Err); } + /// + /// The documented pipeline (okf audit … --trust unverified | cut … | + /// okf verify … -) must be idempotent. okf audit --trust + /// unverified deliberately exits 0 with no output when nothing needs + /// attention, so verify on that empty stream is "nothing to do" — + /// exiting 1 there made the headline pipeline fail under set -e + /// exactly when the bundle was healthy, and the obvious operator + /// workaround (|| true) would also have swallowed a real + /// partial-write failure. + /// + [Fact] + public void Verify_exits_zero_when_standard_input_is_empty() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = TestPaths.RunWithStdin(string.Empty, "verify", bundle, "-", "--by", "human:ada"); + + Assert.Equal(0, r.Code); + Assert.Equal(string.Empty, r.Out); + Assert.Equal(string.Empty, r.Err); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + + /// + /// An invocation already doomed by its own arguments must not drain the + /// pipe first: behind a slow producer that is a pointless wait, and on a + /// terminal it hangs until the user finds Ctrl-D. The reader here throws + /// if anything reads it, so this fails rather than merely being slow. The + /// message ordering the [Theory] above pins is unaffected — every + /// one of those errors is decided from the argument list alone. + /// + [Fact] + public void Verify_validates_the_flags_before_reading_standard_input() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + + var r = TestPaths.RunWithReader(new ThrowingReader(), "verify", bundle, "-"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: verify requires --by \n", r.Err); + } + [Fact] public void Verify_refuses_to_mix_stdin_with_explicit_ids() { diff --git a/web/src/pages/docs/Cli.tsx b/web/src/pages/docs/Cli.tsx index 04e99c2f..ab8ac033 100644 --- a/web/src/pages/docs/Cli.tsx +++ b/web/src/pages/docs/Cli.tsx @@ -348,6 +348,13 @@ export default function Cli() { directly, closing the loop in one line:

+          

+ An empty stream there is nothing to do, not an error: okf audit exits{' '} + 0 printing nothing when the worklist is empty, and okf verify - on that stream + writes nothing and exits 0 too — so the loop is idempotent and safe under{' '} + set -e on a healthy bundle. Naming no concept at all (okf verify <bundle>) + is still an error. +

Re-running okf audit … --trust unverified afterward prints nothing — the concept it just stamped left the worklist. --dry-run shows what would be recorded without writing;{' '} From c63432fe2cc5b5de48c51763a5d5a96aebce32e9 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Sat, 29 Aug 2026 14:36:53 +0200 Subject: [PATCH 25/27] test(verify): close three gaps a source mutation walked through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these survived a deliberate mutation of the source with the full suite green, which means nothing pinned them: - `at ?? "(now)"` in the dry-run renderer. Every existing dry-run test passes `--at`, so the null branch was never rendered — while the website publishes `would record metrics/gross-margin human:ada (now)` as captured output. - `outcome.Message.Replace("Error: ", …)`. No test asserted the stderr of a failed `okf verify` at all, so a regression printing `error: Error: …` would have shipped. Reached via `metrics/dau` plus `metrics//dau`: distinct strings to the CLI's duplicate check, one file to the writer's resolved-path check. - `ReadIdsFrom`'s `line.Trim()`. Blank-line skipping was covered, the trim was not — and ids arriving from a pipe carry whatever whitespace produced them. Verified by re-applying all three mutations: exactly these three tests fail, one per mutation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- tests/OKF4net.Tests/CliTests.cs | 71 +++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/OKF4net.Tests/CliTests.cs b/tests/OKF4net.Tests/CliTests.cs index 718adc2b..65b07c75 100644 --- a/tests/OKF4net.Tests/CliTests.cs +++ b/tests/OKF4net.Tests/CliTests.cs @@ -994,6 +994,30 @@ public void Verify_reads_ids_from_stdin_when_the_id_is_a_dash() r.Out); } + ///

+ /// Ids arriving from a pipe carry whatever whitespace produced them — a + /// cut field, a CRLF-terminated line — so ReadIdsFrom trims + /// each one. Blank-line skipping is covered by the test above; the trim + /// was not, and dropping line.Trim() left the suite green while + /// every such id turned into "unknown concept". + /// + [Fact] + public void Verify_trims_each_id_read_from_standard_input() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + + var r = TestPaths.RunWithStdin( + " metrics/dau\t\r\n\tmetrics/rev \n", + "verify", bundle, "-", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z"); + + Assert.Equal(0, r.Code); + Assert.Equal( + "recorded metrics/dau human:ada 2026-08-28T09:14:00Z\n" + + "recorded metrics/rev human:ada 2026-08-28T09:14:00Z\n", + r.Out); + } + /// /// Fully validated first: every id is resolved before anything is written, so one /// unknown id leaves the whole bundle untouched. @@ -1062,6 +1086,27 @@ public void Verify_dry_run_writes_nothing() Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); } + /// + /// Without --at a dry run has no timestamp to report and prints the + /// literal (now) — the shape the website publishes as captured + /// output. Every other dry-run test passes --at, so that null + /// branch was unexercised: mutating at ?? "(now)" to any other + /// string left the whole suite green. + /// + [Fact] + public void Verify_dry_run_without_at_reports_now() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run("verify", bundle, "metrics/dau", "--by", "human:ada", "--dry-run"); + + Assert.Equal(0, r.Code); + Assert.Equal("would record metrics/dau human:ada (now)\n", r.Out); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + /// /// An actor carrying a newline could otherwise forge a whole recorded /// … line — the renderer interpolates by into a line-oriented @@ -1270,6 +1315,32 @@ public void A_document_that_cannot_be_re_emitted_exits_cleanly_rather_than_crash Assert.DoesNotContain(" at ", r.Err); } + /// + /// The one path where the writer's own message reaches stderr: two + /// spellings of one concept ("metrics/dau" and "metrics//dau") differ as + /// strings, so the CLI's own duplicate check passes them, and both resolve + /// to the same file, so the writer's resolved-path check refuses the + /// batch. CmdVerify strips the writer's Error: prefix + /// before rethrowing, because the CLI adds its own error: — + /// dropping that Replace ships error: Error: …, and no test + /// asserted the stderr of a failed okf verify at all until this + /// one. + /// + [Fact] + public void Verify_reports_a_writer_failure_without_doubling_the_error_prefix() + { + using var tmp = new TempDir(); + var bundle = NewBundleWithTwoConcepts(tmp); + var before = File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md")); + + var r = Run("verify", bundle, "metrics/dau", "metrics//dau", "--by", "human:ada"); + + Assert.Equal(1, r.Code); + Assert.Equal("error: concept 'metrics//dau' is named more than once.\n", r.Err); + Assert.Equal(string.Empty, r.Out); + Assert.Equal(before, File.ReadAllText(Path.Combine(bundle, "metrics", "dau.md"))); + } + /// The loop, end to end: audit lists it, verify clears it. [Fact] public void Audit_then_verify_removes_the_concept_from_the_unverified_worklist() From 97a2e5218b0a5e96cd0fdfd1350fc5877ac9e9d9 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Sat, 29 Aug 2026 14:41:03 +0200 Subject: [PATCH 26/27] docs(verify): correct the actor forms and scope three overclaims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent:` is documented as a §7 actor form in five places, all new on this branch, and it is not one. `Actor.Parse` knows exactly three: `human:`, `process:`, `/`. There is no `agent:` prefix — `agent:assistant/1.0` merely falls through to the producer branch and stores the producer name `agent:assistant`, which nothing downstream flags. The `[Description]` on `okf_verify`'s `by` is an LLM's only source of truth for how an agent stamps a concept, so that line was actively teaching models to write a malformed producer name; it now names the three forms and says outright that there is no `agent:` prefix. Fixed alongside it in `VerifyUsageMessage`, README, CHANGELOG and ROADMAP, matching the statements that already had it right. `src/OKF4net.Agents/README.md`, shipped in the NuGet package README, had a pronoun that rebound when two sentences were inserted before it: as published, "It records a `{by, at}` review stamp" attached to `okf_regenerate_indexes`. Named explicitly and moved back beside the `okf_verify` sentence it belongs to. Three scoping corrections, no behaviour change: - The README's honesty box explains the frontmatter reflow but not that `Serialize()` also normalizes the body to LF — so on a CRLF checkout the diff is 100% of the file, which matters precisely because the box's argument is that a reviewer sees the assertion in the diff. Verified: a CRLF concept comes back with zero CR bytes. The prescribed `okf fmt -w` mitigation does cover it, and now says so. - `VerificationOutcome`/`RecordVerifications` claimed `Records` lists "what actually landed". `File.WriteAllText` truncates and writes in place, so a failure mid-file can leave a target half-written while `Records` omits it. Now scoped to what it really means — the writes that returned — with the residual stated and a ROADMAP entry for atomic write-then-rename. The primitive is deliberately NOT changed here: it sits between the late reparse-point guard and the bundle lock, and deserves its own pass with tests for `File.Replace` semantics rather than a swap on the eve of a merge. - The duplicate-detection comment justified `OrdinalIgnoreCase` with "resolves to the same file on a case-insensitive filesystem (Windows/macOS)" — the exact OS-based heuristic `Bundle.cs` rejects, because case-sensitivity belongs to the volume, not the OS. The behaviour is a deliberate, asymmetric-cost choice; the comment now says that, and states the residual (on a case-sensitive volume holding both `metrics/dau.md` and `metrics/DAU.md`, a batch naming both is refused) instead of reasoning it away. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- CHANGELOG.md | 24 +++++++++++++-- README.md | 13 +++++--- ROADMAP.md | 18 ++++++++++- src/OKF4net.Agents/OkfBundleTools.cs | 5 ++-- src/OKF4net.Agents/README.md | 9 +++--- src/OKF4net/BundleConceptWriter.cs | 45 +++++++++++++++++++++++----- 6 files changed, 93 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a72cd4df..ddafc989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ and this project adheres to from the same actor replacing, a `{by, at}` entry in each named concept's `verified` list, so — for a `human:` actor — the concept clears audit's trust-filtered (`--trust unverified`/`unverified,machine-confirmed`) - selection. A `process:`/`agent:` actor is accepted symmetrically (§7) but + selection. A `process:` or `/` actor is accepted symmetrically (§7) but only moves the concept from `unverified` to `machine-confirmed`, which that same filter still selects. Verification only moves the trust dimension (§5.3) — it never touches @@ -37,7 +37,11 @@ and this project adheres to is "nothing to do", not an error: `verify -` writes nothing and exits 0, matching `audit`'s own empty-worklist exit, so the loop stays idempotent and safe under `set -e` when the bundle needs no attention. Naming no - concept at all (`okf verify `) is still an error. `--dry-run` shows what would be + concept at all (`okf verify `) is still an error. An actor carrying + a control character is refused by `RecordVerifications` itself, so a `--by` + value can never forge a line in the verb's own line-oriented output; reading + an actor out of an existing bundle (`Actor.Parse`, `Trust.DeriveTier`) stays + permissive, as it must. `--dry-run` shows what would be recorded without writing; `--at ` overrides the default of "now" (a bare date, an offset, or fractional seconds are rejected). A batch is validated (existence, §11 conformance, no duplicate id) before the @@ -137,6 +141,22 @@ and this project adheres to ### Fixed +- **`YamlEmitter`'s nesting guard now throws `YamlEmitException`** (an + `OkfException`, like the parser's `YamlParseException`) instead of a bare + `InvalidOperationException`. The parser enforces its 1000-level cap with two + independent counters — one for block nesting, one for flow — while the + emitter has a single counter covering both, so a frontmatter mixing the two + can parse and then fail to re-emit. That exception matched no catch filter + in the library: it escaped `BundleConceptWriter`'s errors-as-data contract, + threw out of the `okf_verify` tool into its host, and killed the CLI with a + stack trace. Every existing filter already covers `OkfException`, so the + failure is now data on all three paths. Reconciling the two counters with + the one is a separate, read-path question and is left open. +- **`okf` reports an unanticipated library failure as `error: `, + exit 1**, instead of a stack trace and exit 127. `OkfCli.Run` caught only + its own internal `CliOperationException`; it now also catches + `OkfException`, the library's expected-error base. Applies to all nine + verbs. An unexpected BCL exception still crashes loudly, on purpose. - The CLI's `--version` is now checked against `` in `Directory.Build.props` by a test. The two are maintained separately and had drifted: the 0.2.0 winget package shipped a binary printing diff --git a/README.md b/README.md index 136154dc..fbbe3067 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ each named concept's `verified` list. It is the verb that answers what `okf audit` asks about trust: audit finds concepts a human has never reviewed (`--trust unverified` / `unverified,machine-confirmed`), verify records that the review happened, and — for a `human:` actor — the reviewed concept -clears that trust-filtered selection. A `process:`/`agent:` actor is accepted +clears that trust-filtered selection. A `process:` or `/` actor is accepted symmetrically (§7), but only moves the concept from `unverified` to `machine-confirmed` (§5.3), which `--trust unverified,machine-confirmed` still selects. Verification only moves the trust dimension (§5.3) — it never @@ -244,7 +244,7 @@ case, and it stays loud. Every named concept is checked for existence and §11 conformance before anything is written, so a batch is rejected as a whole at that stage; a mid-batch I/O failure can still leave the concepts already written stamped -(`okf verify`'s output lists exactly what landed). `--dry-run` prints what +(`okf verify` lists the concepts it wrote before it stopped). `--dry-run` prints what would be recorded without writing anything; `--at ` overrides the default of "now" for reproducible scripting — a bare date, a numeric offset, or fractional seconds are all rejected, not silently rounded. @@ -263,8 +263,13 @@ numeric offset, or fractional seconds are all rejected, not silently rounded. > canonical form (the same shape `okf fmt` produces) — a flow-style mapping > or an inline list expands to one entry per line, so a three-line stamp can > land as a much larger diff with the new `verified` entry buried inside a -> reformat. Run `okf fmt -w` on the bundle first, as its own reviewed commit, -> if you want a review's diff to be the stamp and nothing else. **Never infer +> reformat. The body is normalized too, to LF line endings, so on a bundle +> checked out with CRLF the reformat is *the entire file* and the assertion a +> reviewer is supposed to see is one changed line in a wall of them. Run +> `okf fmt -w` on the bundle first, as its own reviewed commit, if you want a +> review's diff to be the stamp and nothing else — it produces the same +> canonical shape, so a `verify` run after it differs only by the stamp +> lines. **Never infer > a stamp from a PR approval** — that turns "a human > approved this diff" into "a human vouches for this knowledge," which are > different every time a PR touches a file for a reason other than reviewing diff --git a/ROADMAP.md b/ROADMAP.md index 286f4331..57efeb7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ are the concrete entry points. about trust: it records a review by adding, or from the same actor replacing, a `{by, at}` entry in a named concept's `verified` list (§5.2), so — for a `human:` actor — the concept clears audit's trust-filtered - selection at the next pass. A `process:`/`agent:` actor is accepted + selection at the next pass. A `process:` or `/` actor is accepted symmetrically (§7) but only moves the concept from `unverified` to `machine-confirmed`, which `--trust unverified,machine-confirmed` still selects. Verification only moves the trust dimension (§5.3) — `stale_after` is @@ -50,6 +50,22 @@ are the concrete entry points. extension (`digest`, `scope`, `note` on the stamp) is planned to recreate this information inside the bundle instead — that question is answered by git, on purpose. + - **Atomic write-then-rename in `BundleConceptWriter`.** Every write path + in the class ends at `File.WriteAllText`, which truncates the target and + writes in place, so a failure mid-write (full disk, device error) can + leave a concept truncated or half-written. `RecordVerifications` reports + the concepts whose write returned, and that file is not among them — so + the report is not wrong, but "exactly what landed" is a stronger claim + than the primitive supports, and the docs now say so. Closing it means + writing to a temporary file in the same directory and `File.Replace`-ing + it over the target. Deliberately its own pass rather than a footnote to + `okf verify`: the call sits immediately after the late reparse-point + re-check and inside the per-bundle lock, so a replacement needs tests for + `File.Replace` semantics (cross-volume, existing-file, permissions, + what happens to the backup), for the path-safety guard still holding + against the *temporary* name, and for the lock — a security-sensitive + seam that must not be swapped in passing. Pre-existing and shared by + every write path; not introduced by verification. - **Per-verb `--help` for the CLI.** `okf audit --help` today prints `error: missing `, and so do `okf validate --help` and every other verb: the CLI has one global usage block and no per-verb help, so a verb's diff --git a/src/OKF4net.Agents/OkfBundleTools.cs b/src/OKF4net.Agents/OkfBundleTools.cs index 7eb6df0c..ee9f4d96 100644 --- a/src/OKF4net.Agents/OkfBundleTools.cs +++ b/src/OKF4net.Agents/OkfBundleTools.cs @@ -32,7 +32,8 @@ public sealed class OkfBundleTools private const string VerifyUsageMessage = "Usage: okf_verify records a review — comma-separated concept ids, plus a well-formed " - + "§7 actor (human:, agent:/, process:). Example: " + + "§7 actor — one of exactly three forms: human:, process:, or " + + "/ (no agent: prefix). Example: " + "okf_verify(\"metrics/dau, metrics/revenue\", \"human:ada\")."; /// @@ -584,7 +585,7 @@ public string WriteConcept( [Description("Record a review of one or more concepts: adds or replaces the caller's { by, at } entry in each concept's `verified` list. The stamp is a dated declaration, not a proof — the same rules as the okf verify CLI verb.")] public string Verify( [Description("Comma-separated concept ids (paths without .md). Explicit ids only — there is no whole-bundle form.")] string conceptIds, - [Description("The §7 actor recording the review, e.g. human:ada, agent:assistant/1.0, process:nightly. Must not contain control characters.")] string by, + [Description("The §7 actor recording the review — one of exactly three forms: human: (e.g. human:ada), process: (e.g. process:nightly), or / for an agent or tool (e.g. assistant/1.0). There is no agent: prefix: writing agent:assistant/1.0 stores the producer name \"agent:assistant\". Must not contain control characters.")] string by, [Description("UTC timestamp in the exact form yyyy-MM-ddTHH:mm:ssZ, e.g. 2026-08-28T09:14:00Z — no fractional seconds, no offset, no bare date. Omit for now.")] string? at = null) { var ids = (conceptIds ?? string.Empty) diff --git a/src/OKF4net.Agents/README.md b/src/OKF4net.Agents/README.md index a1277f30..fc4ba5a2 100644 --- a/src/OKF4net.Agents/README.md +++ b/src/OKF4net.Agents/README.md @@ -45,14 +45,15 @@ OKF rules (non-empty `type`, `title`, `description`) before touching disk; `okf_verify` deliberately enforces only §11 conformance (a non-empty `type`) instead — recording a review is not producing content, and refusing a reviewer because a concept is missing a `description` would make precisely -the concepts an audit surfaces unstampable. `okf_append_log` validates only +the concepts an audit surfaces unstampable. What `okf_verify` writes is a +`{by, at}` review stamp (§5.2) — a dated declaration, not a proof; see the +project README's `okf verify` section for what it does and doesn't +guarantee. `okf_append_log` validates only its own `kind`/`text` arguments (non-empty, no embedded newline or null byte) and re-renders `log.md` through the §9 model — it does not touch concept documents at all. `okf_regenerate_indexes` performs no document validation whatsoever; it only rebuilds `index.md` listings from whatever is -already on disk. It records a `{by, at}` review stamp (§5.2) — a dated -declaration, not a proof; see the project README's `okf verify` section for -what it does and doesn't guarantee. Bundle content is treated as untrusted +already on disk. Bundle content is treated as untrusted and is never injected as a system message. `OkfContextProvider` (an `AIContextProvider`, registered via diff --git a/src/OKF4net/BundleConceptWriter.cs b/src/OKF4net/BundleConceptWriter.cs index 881950ae..49f7b660 100644 --- a/src/OKF4net/BundleConceptWriter.cs +++ b/src/OKF4net/BundleConceptWriter.cs @@ -26,8 +26,24 @@ namespace OKF4net; /// — unknown id, malformed actor, non-conformant document — writes nothing. /// But writing several files cannot be atomic: if the third write fails on /// I/O, the first two are already on disk. is then -/// false while lists what actually landed, and -/// names them. +/// false while lists the concepts whose write returned +/// successfully, and names them. +/// +/// The precise scope of that list. An entry means "this file's write +/// call completed", which is stronger than "was validated" (the reason +/// Records is built in the write loop, not the prepare loop) and +/// weaker than "the file on disk is now exactly one of these two versions". +/// The underlying primitive is , +/// which truncates and writes IN PLACE, so a failure part-way through one +/// file — a full disk, a device error — can leave that file truncated or +/// half-written while Records omits it, having never returned. That is +/// a property of the write primitive shared by EVERY write path in this class, +/// not something verification introduced; closing it needs write-then-rename, +/// which has to be designed against the reparse-point guard and the bundle +/// lock it sits between (see ROADMAP). Until then, the honest reading of a +/// failed batch is: the concepts listed are stamped, the ones after them were +/// not written, and the one it stopped on is unknown — re-run it, or check +/// that file. /// /// Whether the whole batch was written. /// A confirmation, or what went wrong and how far it got. @@ -483,7 +499,10 @@ public string AppendToConceptAtomic( /// .NET, so a failure during the write phase (I/O, permissions, a reparse /// point appearing after the late re-check) leaves the concepts already /// written stamped. That case reports Recorded = false with - /// Records listing what did land — see . + /// Records listing the concepts whose write returned — which is not + /// quite the same as "the file on disk is intact", since the write + /// primitive truncates in place; states + /// exactly what the list does and does not promise. /// The lock is also in-process, so an external actor mutating the bundle /// mid-batch is not stopped: the same documented limit as this class's /// reparse-point guard. @@ -587,12 +606,22 @@ public VerificationOutcome RecordVerifications(IReadOnlyList conceptIds, // file twice would build both versions from the same original // content and write it twice, reporting two records for the single // stamp that survives — a result that reads like two reviews. + // // Checked on the RESOLVED target path, not the raw id string that - // was passed in: two case-variant spellings of the same concept - // ("metrics/dau" / "metrics/DAU") resolve to the same file on a - // case-insensitive filesystem (Windows/macOS) and must collide too - // — the same OrdinalIgnoreCase reasoning the BundleLocks registry - // above uses for exactly this class of bug. + // was passed in, and case-INSENSITIVELY. Note this is NOT the + // "Windows/macOS are case-insensitive" heuristic Bundle.cs + // explicitly rejects (see Bundle.PathComparison): case-sensitivity + // is a property of the volume, not the OS, so no OS test could + // decide this correctly either way. The comparison is deliberately + // pessimistic instead — a batch is refused whenever two ids COULD + // name one file — because the cost of the two errors is not + // symmetric: collapsing two spellings on a case-insensitive volume + // silently double-reports a single stamp, while the residual here + // is that on a case-SENSITIVE volume genuinely holding both + // metrics/dau.md and metrics/DAU.md, a batch naming both is + // refused and must be run as two. Stated rather than reasoned + // away: that refusal is real, and this is the same call the + // BundleLocks registry above makes for the same class of bug. var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < targets.Count; i++) { From 5d302570f4644a09752db3aafe0f870459bf7f72 Mon Sep 17 00:00:00 2001 From: Julien CHABLE Date: Sat, 29 Aug 2026 14:45:52 +0200 Subject: [PATCH 27/27] docs(verify): correct the agent: actor form at its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit found `agent:/` presented as one of the three §7 actor forms in five shipped places, including the [Description] an LLM reads. Those were fixed; this fixes where the error came from. `Actor.Parse` knows `human:`, `process:` and `/`. `agent:x/1.0` validates only by falling through to the producer branch, so what lands in a bundle is `producer = "agent:x"` — and nothing downstream flags it. Corrected in place with a dated note rather than left as a historical record: this spec is the binding authority for any follow-up cycle, and a future implementer reading it would copy the wrong form again. The whole branch has been about false written claims propagating; leaving the origin intact would be the same mistake one level up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TDM9Aozt9YciNJ3oncrFxG --- docs/superpowers/plans/2026-08-28-okf-verify.md | 12 ++++++++++-- .../specs/2026-08-28-okf-verify-design.md | 10 +++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-okf-verify.md b/docs/superpowers/plans/2026-08-28-okf-verify.md index ddaa5225..62e7c434 100644 --- a/docs/superpowers/plans/2026-08-28-okf-verify.md +++ b/docs/superpowers/plans/2026-08-28-okf-verify.md @@ -4,6 +4,14 @@ **Goal:** Enregistrer une relecture — une estampille datée `{ by, at }` dans le champ `verified` d'un concept — pour que la worklist d'`okf audit` ait enfin une sortie. +> **Correction 2026-08-29 (post-audit).** Ce document présentait `agent:/` +> comme une des trois formes d'acteur §7. C'est faux : `Actor.Parse` ne connaît que +> `human:`, `process:` et `/`. Un identifiant `agent:x/1.0` +> ne valide qu'en retombant sur la branche producteur, donnant `producer = "agent:x"`. +> L'erreur venait d'ici et s'est propagée jusqu'à la `[Description]` que lit le modèle ; +> elle est corrigée à la source plutôt que laissée en registre daté, pour qu'un futur +> implémenteur ne la recopie pas. + **Architecture:** Un écrivain gouverné unique dans le cœur (`BundleConceptWriter.RecordVerifications`, read-modify-write atomique sur le frontmatter), consommé par un verbe CLI et par un tool agent mutateur. Deux prérequis d'infrastructure CLI (positionnels multiples, seam stdin) précèdent le tout. **Tech Stack:** C# / net10.0, xunit, zéro dépendance tierce, Native AOT pour le CLI. @@ -1590,7 +1598,7 @@ Dans `src/OKF4net.Agents/OkfBundleTools.cs` — la constante d'usage, à côté ```csharp private const string VerifyUsageMessage = "Usage: okf_verify records a review — comma-separated concept ids, plus a well-formed " - + "§7 actor (human:, agent:/, process:). Example: " + + "§7 actor (human:, process:, /). Example: " + "okf_verify(\"metrics/dau, metrics/revenue\", \"human:ada\")."; ``` @@ -1615,7 +1623,7 @@ La méthode : [Description("Record a review of one or more concepts: adds or replaces the caller's { by, at } entry in each concept's `verified` list. The stamp is a dated declaration, not a proof — the same rules as the okf verify CLI verb.")] public string Verify( [Description("Comma-separated concept ids (paths without .md). Explicit ids only — there is no whole-bundle form.")] string conceptIds, - [Description("The §7 actor recording the review, e.g. human:ada, agent:assistant/1.0, process:nightly.")] string by, + [Description("The §7 actor recording the review, e.g. human:ada, process:nightly, okf4net/0.5.0.")] string by, [Description("ISO-8601 UTC timestamp; omit for now.")] string? at = null) { var ids = (conceptIds ?? string.Empty) diff --git a/docs/superpowers/specs/2026-08-28-okf-verify-design.md b/docs/superpowers/specs/2026-08-28-okf-verify-design.md index f9200d0b..bc659472 100644 --- a/docs/superpowers/specs/2026-08-28-okf-verify-design.md +++ b/docs/superpowers/specs/2026-08-28-okf-verify-design.md @@ -4,6 +4,14 @@ Date : 2026-08-28 Statut : validé en brainstorming (design approuvé section par section, second avis indépendant intégré), prêt pour le plan d'implémentation +> **Correction 2026-08-29 (post-audit).** Ce document présentait `agent:/` +> comme une des trois formes d'acteur §7. C'est faux : `Actor.Parse` ne connaît que +> `human:`, `process:` et `/`. Un identifiant `agent:x/1.0` +> ne valide qu'en retombant sur la branche producteur, donnant `producer = "agent:x"`. +> L'erreur venait d'ici et s'est propagée jusqu'à la `[Description]` que lit le modèle ; +> elle est corrigée à la source plutôt que laissée en registre daté, pour qu'un futur +> implémenteur ne la recopie pas. + ## 1. Objectif `okf audit` (spec du 2026-08-21) a donné au bundle sa première question @@ -361,7 +369,7 @@ ROADMAP. [Description("Record a review of one or more concepts: adds or replaces the caller's { by, at } entry in each concept's `verified` list. The stamp is a dated declaration, not a proof — same rules as the okf verify CLI verb.")] public string Verify( [Description("Comma-separated concept ids (paths without .md). Explicit ids only — there is no whole-bundle form.")] string conceptIds, - [Description("The §7 actor recording the review, e.g. human:alice, agent:assistant/1.0, process:nightly. Required, well-formed.")] string by, + [Description("The §7 actor recording the review, e.g. human:alice, process:nightly, okf4net/0.5.0. Required, well-formed.")] string by, [Description("ISO-8601 UTC timestamp; omit for now.")] string? at = null) ```