diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2d19c62..56088ca8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -119,6 +119,40 @@ 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 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 — for a `human:` actor — the concept clears audit's
+ trust-filtered (`--trust unverified`/`unverified,machine-confirmed`)
+ 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
+ `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. 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. 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
+ 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.
- **A new `okf-render --out ` binary** generates a
self-contained, browsable HTML site from a bundle: one page per concept
(frontmatter table + rendered body), a generated index, navigable
@@ -300,6 +334,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
@@ -359,6 +414,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 eight
+ verbs. An unexpected BCL exception still crashes loudly, on purpose.
- **`generated.by` is an actor again, and the engine versions moved to
`generated.engines`.** §5.2 makes that field an actor and §7 defines an actor as
exactly one of `/`, `human:`, `process:`. It was written
diff --git a/CLAUDE.md b/CLAUDE.md
index 91028a3b..f0ff853a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,10 +36,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`), published Native AOT (`PublishAot`, `InvariantGlobalization`). All logic lives in `OkfCli.Run(args, out, err)` so tests invoke it in-process without spawning a process. Carries no reference to `OKF4net.Viewer`: static-site generation (`render`) was split out into `src/OKF4net.Render/` so this CI-facing validator does not carry that vendored JavaScript.
+- **`src/OKF4net.Cli/`** — the `okf` binary (`validate`/`audit`/`verify`/`info`/`index`/`graph`/`parse`/`fmt`), 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, and it rides on the parsed `CliArgs` rather than widening the dispatch delegate seven other verbs would never use. Every verb is a `VerbSpec` in the `Verbs` table — usage, flag allowlists, per-verb help, positional arity (`Variadic`, set only by `verify`) and handler in one place, so a verb cannot be declared and left unreachable. Carries no reference to `OKF4net.Viewer`: static-site generation (`render`) was split out into `src/OKF4net.Render/` so this CI-facing validator does not carry that vendored JavaScript.
- **`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 7a02dab6..9377b35d 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`/`audit`/`info`/`index`/`graph`/`parse`/`fmt`). | [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`). | [As a CLI](#as-a-cli) |
| `OKF4net.Render` | — (Native AOT `okf-render` binary, no PackageId) | Standalone CLI: generates a browsable HTML site from a bundle. | [As a CLI](#as-a-cli) |
| `OKF4net.Viewer` | — (ships inside the `okf-render` binary, not packed by `release.yml`) | Static HTML site generation for a bundle; backs `okf-render`. | [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) |
@@ -195,6 +195,7 @@ On any OS, you can also build from source — see
```
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)
@@ -237,6 +238,68 @@ 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 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:` 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
+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 -
+```
+
+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
+(`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.
+
+> **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. 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. 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
+> 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.
+
`okf` is `OKF4net.Cli`, published as a self-contained, Native AOT
single-file binary — no .NET runtime installation required on the target
machine. Full command reference with real output samples:
@@ -278,8 +341,8 @@ from source as shown above.
`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)).
@@ -298,9 +361,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 |
|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
@@ -310,6 +373,7 @@ append → regenerate → validate → changes-since → get-computation → run
| `okf_search` | Full-text search across concept titles, descriptions, tags and bodies. Returns the best-matching concept id first, then spreads the remaining results across top-level id families, so the list is not in descending score order. |
| `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 (§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. |
@@ -323,8 +387,8 @@ 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.
-That matters most for the three write-capable tools (`okf_write_concept`,
-`okf_append_log`, `okf_regenerate_indexes`), because an injection carried in a
+That matters most for the four write-capable tools (`okf_write_concept`,
+`okf_verify`, `okf_append_log`, `okf_regenerate_indexes`), because an injection carried in a
concept body is only dangerous if it can reach a persistent write.
**`GetTools()` returns them ungated**, and nothing asks on your behalf: the
Agent Framework's approval mechanism is not active by default, so a plain
@@ -605,6 +669,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 1233a49f..2c270611 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -20,14 +20,52 @@ 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).
-- **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
- once — intercepting `--help` inside each command before its positional is
- resolved, which also changes those invocations from exit 1 to exit 0.
+- **`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 — for a `human:` actor — the concept clears audit's trust-filtered
+ 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
+ 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
+ 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.
+ - **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.
- **A typed `OkfDocumentBuilder` method for the shared `usage_window`.** The
builder can now write a *per-entry* §5.1 override (`AddSource(…,
usageWindow:)`), but the shared, top-level `usage_window` — §5.1's normal
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/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..62e7c434
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-28-okf-verify.md
@@ -0,0 +1,1771 @@
+# `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.
+
+> **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.
+
+**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 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.
+- `dotnet format OKF4net.sln` avant le dernier commit (la CI lance `--verify-no-changes`).
+
+## Écart assumé par rapport à la spec
+
+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.
+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` | `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 |
+| `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`. 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**
+
+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);
+ }
+
+ ///
+ /// 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");
+ }
+```
+
+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"`
+**É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. 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`**
+
+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 = [];
+```
+
+**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
+ 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) =>
+ 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());
+ }
+```
+
+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 — `RecordVerifications`
+
+**Files:**
+- Modify: `src/OKF4net/BundleConceptWriter.cs`
+- Test: `tests/OKF4net.Tests/RecordVerificationTests.cs` (créé)
+
+**Interfaces:**
+- 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**
+
+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).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());
+ 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]
+ 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);
+ }
+}
+```
+
+- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
+
+Run: `dotnet test OKF4net.sln --filter "FullyQualifiedName~RecordVerificationTests"`
+Expected: échec de compilation — `RecordVerifications` et `VerificationOutcome` n'existent pas.
+
+- [ ] **Step 3: Implémenter**
+
+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 .
+/// 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);
+```
+
+Puis, dans la classe, la méthode de lot et ses aides privées :
+
+```csharp
+ ///
+ /// 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);
+ }
+```
+
+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): RecordVerifications, 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 (`RecordVerifications`, `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);
+ }
+
+ ///
+ /// 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);
+ }
+```
+
+- [ ] **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}\"");
+ }
+
+ // 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;
+ }
+```
+
+**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**
+
+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 (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.
+
+**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` :
+
+```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);
+
+ // 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")));
+ }
+```
+
+- [ ] **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. 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**
+
+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/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"
+```
+
+---
+
+### 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 (`RecordVerifications`, `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");
+
+ // 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);
+ }
+}
+```
+
+- [ ] **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:, 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, process:nightly, okf4net/0.5.0.")] 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();
+ });
+ }
+```
+
+`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
+ 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 → `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é —
+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` : **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**
+
+`CLAUDE.md` : ajouter `verify` à la liste des verbes, et une ligne disant que
+`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é ? »,
+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 `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.
+
+- [ ] **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"
+```
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..bc659472
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-28-okf-verify-design.md
@@ -0,0 +1,535 @@
+# 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
+
+> **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
+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** — 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.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`.
+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` ;
+`--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.
+ ///
+ /// Les ids des concepts (chemins sans .md), sans doublon.
+ /// L'acteur §7, requis, bien formé.
+ /// 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.
+
+### 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 **à 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
+ 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.**
+ `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 ;
+ 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, 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 ». `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.
+- **`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é, 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
+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` 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.
+
+**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 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` |
+
+### 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, process:nightly, okf4net/0.5.0. 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 `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.
+
+### 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`.
+
+### 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
+ 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).
+- 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.
+- `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
+ 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.
diff --git a/src/OKF4net.Agents/OkfBundleTools.cs b/src/OKF4net.Agents/OkfBundleTools.cs
index 544ebecd..8594f00b 100644
--- a/src/OKF4net.Agents/OkfBundleTools.cs
+++ b/src/OKF4net.Agents/OkfBundleTools.cs
@@ -30,6 +30,23 @@ 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 — one of exactly three forms: human:, process:, or "
+ + "/ (no agent: prefix). 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
@@ -189,8 +206,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_verify, okf_append_log,
+ /// and okf_regenerate_indexes. 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
@@ -203,6 +220,7 @@ internal void InvalidateBundle()
"okf_write_concept",
"okf_append_log",
"okf_regenerate_indexes",
+ "okf_verify",
};
///
@@ -218,8 +236,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
@@ -257,6 +276,7 @@ public IList GetTools(OkfToolMode mode)
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"),
@@ -600,6 +620,102 @@ 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.
+ ///
+ /// 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 — 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)
+ .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;
+ }
+
+ return RunTool(() =>
+ {
+ // 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)
+ {
+ 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
+ // 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/src/OKF4net.Agents/README.md b/src/OKF4net.Agents/README.md
index f65e2c35..fc4ba5a2 100644
--- a/src/OKF4net.Agents/README.md
+++ b/src/OKF4net.Agents/README.md
@@ -26,20 +26,35 @@ 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
-(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.
+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. 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. 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. 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 abc09e62..a6719247 100644
--- a/src/OKF4net.Cli/OkfCli.cs
+++ b/src/OKF4net.Cli/OkfCli.cs
@@ -7,13 +7,13 @@
namespace OKF4net.Cli;
///
-/// The okf command-line tool. Seven subcommands (validate,
-/// audit, info, index, graph, parse,
-/// fmt) over hand-rolled argument parsing -- no third-party
-/// dependencies. (The render verb — static HTML site generation —
-/// lives in the separate okf-render binary, OKF4net.Render, so
-/// this CI-facing validator does not carry the vendored viewer JavaScript it
-/// never executes.)
+/// The okf command-line tool. Eight subcommands (validate,
+/// audit, verify, info, index, graph,
+/// parse, fmt) over hand-rolled argument parsing -- no
+/// third-party dependencies. (The render verb — static HTML site
+/// generation — lives in the separate okf-render binary,
+/// OKF4net.Render, so this CI-facing validator does not carry the
+/// vendored viewer JavaScript it never executes.)
///
/// is the sole public entry point so tests can drive the
/// CLI in-process (capturing stdout/stderr) without spawning a subprocess;
@@ -51,6 +51,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" +
@@ -62,6 +63,9 @@ public static class OkfCli
" -V, --version Show version\n" +
" --json Machine-readable output for validate/info/audit\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";
@@ -93,6 +97,14 @@ public static class OkfCli
/// an error for THIS verb even when another verb defines it -- the
/// allowlist is per-verb, not global, so okf validate b --dot is
/// rejected rather than quietly ignored.
+ ///
+ /// says the verb takes more than one
+ /// positional. It defaults to false so the one-positional rule stays the
+ /// default rather than something each verb must remember to ask for, and
+ /// only verify sets it: verify <bundle> <id>…
+ /// names a bundle and then any number of concepts. Making arity part of
+ /// the declared contract keeps that rule enforced per verb rather than
+ /// relaxed globally for the one verb that needs it.
///
private sealed record VerbSpec(
string Name,
@@ -101,7 +113,8 @@ private sealed record VerbSpec(
string[] ValuedFlags,
string[] ValuelessFlags,
string[] OptionLines,
- Func Run);
+ Func Run,
+ bool Variadic = false);
///
/// Every subcommand, keyed by name — genuinely the single source for
@@ -133,6 +146,17 @@ private sealed record VerbSpec(
" --as-of Evaluate staleness as of YYYY-MM-DD, not today",
" --json Machine-readable output"],
CmdAudit),
+ ["verify"] = new(
+ "verify", "okf verify … | - [--by ] [--at ] [--dry-run]",
+ "Record a review of one or more concepts (§5.2).",
+ ["--by", "--at"], ["--dry-run"],
+ [" --by Who is recording the review (required)",
+ " --at UTC timestamp yyyy-MM-ddTHH:mm:ssZ (default: now)",
+ " --dry-run Show what would be recorded, write nothing"],
+ CmdVerify,
+ // The one variadic verb: a bundle followed by any number of concept
+ // ids, or `-` to read them from stdin one per line.
+ Variadic: true),
["info"] = new(
"info", "okf info [--json]",
"Summarize a bundle (concepts, types, links, version).",
@@ -191,7 +215,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";
@@ -230,7 +262,7 @@ public static int Run(string[] args, TextWriter stdout, TextWriter stderr)
// positional, so `okf validate --help` used to answer
// "error: missing " -- the one question a user asks when
// they do not know what that argument is.
- var parsed = CliArgs.Scan(rest, spec);
+ var parsed = CliArgs.Scan(rest, spec, stdin);
if (parsed.WantsHelp)
{
stdout.Write(HelpFor(spec));
@@ -244,6 +276,21 @@ public static int Run(string[] args, TextWriter stdout, TextWriter stderr)
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.
@@ -285,16 +332,35 @@ 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 = [];
+ /// Whether the verb declared it takes more than one positional — see .
+ private bool _variadic;
+
+ ///
+ /// Standard input, carried here rather than passed to every handler:
+ /// stdin is one of the invocation's inputs, like the arguments beside
+ /// it, and only verify - reads it. Widening the dispatch
+ /// delegate instead would have added a parameter seven other verbs
+ /// never use.
+ ///
+ private TextReader _stdin = TextReader.Null;
+
private CliArgs()
{
}
+ /// Standard input for the verbs that read it — in practice only verify -.
+ internal TextReader Stdin => _stdin;
+
///
/// Scans against 's declared
/// contract, rejecting anything it does not define.
@@ -315,10 +381,10 @@ private CliArgs()
/// side-effecting flag parked after the separator (fmt -- f -w)
/// still never writes, and now says why.
///
- internal static CliArgs Scan(string[] args, VerbSpec spec)
+ internal static CliArgs Scan(string[] args, VerbSpec spec, TextReader stdin)
{
var valuedFlags = spec.ValuedFlags;
- var scanned = new CliArgs { _valuedFlags = valuedFlags };
+ var scanned = new CliArgs { _valuedFlags = valuedFlags, _variadic = spec.Variadic, _stdin = stdin };
for (var i = 0; i < args.Length; i++)
{
@@ -328,7 +394,7 @@ internal static CliArgs Scan(string[] args, VerbSpec spec)
{
// Nothing past the separator is a flag -- that is what it is
// for (a path starting with `-`). They are positionals, and
- // so bound by the one-positional rule like any other.
+ // so bound by the verb's declared arity like any other.
for (var j = i + 1; j < args.Length; j++)
{
scanned.TakePositional(args[j]);
@@ -343,7 +409,10 @@ internal static CliArgs Scan(string[] args, VerbSpec spec)
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.TakeOption(token, spec);
continue;
@@ -394,15 +463,21 @@ private void TakeOption(string token, VerbSpec spec)
_flags[token] = null;
}
- /// Fills the single positional slot, or reports the surplus token rather than dropping it.
+ ///
+ /// Appends a positional, enforcing the arity the verb declared. A verb
+ /// that takes one — every verb but verify — still reports the
+ /// surplus token rather than dropping it; a variadic one keeps them all
+ /// in order, since verify <bundle> <id>… is the whole
+ /// point of that flag.
+ ///
private void TakePositional(string token)
{
- if (_positional is not null)
+ if (!_variadic && _positionals.Count > 0)
{
throw new CliOperationException($"unexpected argument: {token}");
}
- _positional = token;
+ _positionals.Add(token);
}
/// Whether help was asked for. Answered centrally in , before any command body runs.
@@ -442,7 +517,10 @@ private void TakePositional(string token)
/// 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;
}
/// Loads a bundle, converting a failure into the CLI's error arm.
@@ -737,6 +815,186 @@ private static void WriteAuditReport(TextWriter stdout, string bundlePath, Audit
}
}
+ /// Implements the verify subcommand.
+ private static int CmdVerify(CliArgs parsed, TextWriter stdout)
+ {
+ // 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 ");
+ }
+
+ // Decided from the ARGUMENTS, before anything reads the pipe.
+ var readFromStdin = ids is ["-"];
+ if (!readFromStdin && ids.Contains("-"))
+ {
+ throw new CliOperationException("\"-\" (stdin) cannot be combined with explicit concept ids");
+ }
+
+ // 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 ");
+ }
+
+ // 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}\"");
+ }
+
+ // 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}\"");
+ }
+
+ // 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(parsed.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
+ // 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");
+ }
+
+ // 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)
+ {
+ 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");
+ }
+ }
+
+ 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;
+ }
+
+ // 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);
+ // 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(CliArgs parsed, TextWriter stdout)
{
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/src/OKF4net.Mcp/OkfMcpToolset.cs b/src/OKF4net.Mcp/OkfMcpToolset.cs
index 5d6fd5b2..b3f3091a 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 50157b2e..2a32bd70 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:
```
The server is **read-only by default**: the write tools `okf_write_concept`,
-`okf_append_log` and `okf_regenerate_indexes` are not registered unless you set
-`OKF_MCP_WRITABLE=1`.
+`okf_verify`, `okf_append_log` and `okf_regenerate_indexes` are not registered
+unless you set `OKF_MCP_WRITABLE=1`.
Bundle content is untrusted — it comes from files another agent or a human
contributor may have written — so an injection carried in a concept body can
@@ -83,20 +83,30 @@ 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 eight tools by default (the read-only ones above), or eleven when
-`OKF_MCP_WRITABLE=1` adds the three write tools.
+path-safety, and locking apply unchanged — including each write tool's exact
+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 eight tools by default (the read-only ones above), or twelve when
+`OKF_MCP_WRITABLE=1` adds 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/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 580e9660..7b79df1f 100644
--- a/src/OKF4net/BundleConceptWriter.cs
+++ b/src/OKF4net/BundleConceptWriter.cs
@@ -1,10 +1,55 @@
// 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 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.
+/// 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
@@ -83,7 +128,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.
@@ -439,6 +487,292 @@ 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 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
+ /// point appearing after the late re-check) leaves the concepts already
+ /// written stamped. That case reports Recorded = false with
+ /// 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.
+ ///
+ /// 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.");
+ }
+
+ // 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)
+ {
+ 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.");
+ }
+ }
+
+ // 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.
+ 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);
+ }
+
+ // 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, 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++)
+ {
+ if (!seenPaths.Add(targets[i].TargetPath))
+ {
+ return $"Error: concept '{conceptIds[i]}' is named more than once.";
+ }
+ }
+
+ lock (_bundleLock)
+ {
+ // 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];
+ 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));
+
+ // 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, 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. `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, conceptId, replacedAt) = prepared[i];
+ var writeResult = WriteValidatedContentLocked(target.Id, target.TargetPath, content, existedBefore: true);
+ if (writeResult.StartsWith("Error:", StringComparison.Ordinal))
+ {
+ return records.Count == 0
+ ? writeResult
+ : $"{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}.";
+ }
+ });
+
+ // 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 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)
+ {
+ 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. 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 BuildConformantContent(YamlMapping frontmatter, string body)
+ {
+ var document = new OkfDocument(Frontmatter.FromMapping(frontmatter), body);
+ document.ValidateConformance();
+ return document.Serialize();
+ }
+
/// 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/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/AIFunctionExposureTests.cs b/tests/OKF4net.Tests/Agents/AIFunctionExposureTests.cs
index effc8f0a..b45f23a2 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/AgentIntegrationTests.cs b/tests/OKF4net.Tests/Agents/AgentIntegrationTests.cs
index ae5a361b..807bddc1 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/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..cddafe85
--- /dev/null
+++ b/tests/OKF4net.Tests/Agents/OkfVerifyToolTests.cs
@@ -0,0 +1,350 @@
+// 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:"));
+ }
+
+ ///
+ /// 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")));
+ }
+
+ ///
+ /// 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()
+ {
+ 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")));
+ }
+
+ ///
+ /// 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()
+ {
+ 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")));
+ }
+
+ ///
+ /// 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);
+ }
+
+ ///
+ /// 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);
+ }
+
+ ///
+ /// 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")));
+ }
+}
diff --git a/tests/OKF4net.Tests/CliTests.cs b/tests/OKF4net.Tests/CliTests.cs
index d3794d25..3d465952 100644
--- a/tests/OKF4net.Tests/CliTests.cs
+++ b/tests/OKF4net.Tests/CliTests.cs
@@ -914,8 +914,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()
@@ -1060,4 +1058,492 @@ 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. On a verb with one positional slot that is unobservable —
+ /// `Audit_tokens_after_the_separator_are_never_flags` covers what happens
+ /// there — so it is pinned on `verify`, the one variadic verb, where
+ /// dropping the earlier tokens would silently change which concepts get
+ /// stamped rather than merely rejecting a surplus argument.
+ ///
+ [Fact]
+ public void Separator_keeps_positionals_from_both_sides()
+ {
+ using var tmp = new TempDir();
+ var bundle = NewBundleWithTwoConcepts(tmp);
+
+ // The flags sit before the separator on purpose: after it, `--by` would
+ // be a concept id like any other token, which is what `--` is for.
+ var r = Run("verify", bundle, "metrics/dau", "--by", "human:ada", "--at", "2026-08-28T09:14:00Z", "--", "metrics/rev");
+
+ // Both ids land: the one before the separator is not discarded, and the
+ // one after it is an argument rather than a flag.
+ 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);
+ }
+
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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")));
+ }
+
+ ///
+ /// 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()
+ {
+ 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")));
+ }
+
+ ///
+ /// 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
+ /// 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")]
+ [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")]
+ // --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")]
+ 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);
+ }
+
+ ///
+ /// 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()
+ {
+ 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 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);
+ }
+ }
+
+ ///
+ /// 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 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()
+ {
+ 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
+ /// 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/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/GoldenParityTests.cs b/tests/OKF4net.Tests/GoldenParityTests.cs
index f5dd381f..cc7ee7be 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 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
+ // 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/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);
+ }
+ }
}
diff --git a/tests/OKF4net.Tests/RecordVerificationTests.cs b/tests/OKF4net.Tests/RecordVerificationTests.cs
new file mode 100644
index 00000000..46d48711
--- /dev/null
+++ b/tests/OKF4net.Tests/RecordVerificationTests.cs
@@ -0,0 +1,441 @@
+// 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"));
+ }
+
+ ///
+ /// 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")]
+ 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"));
+ }
+
+ ///
+ /// 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"));
+ }
+
+ ///
+ /// 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
+ /// 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", at);
+
+ 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);
+ }
+}
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());
}
}
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());
}
}
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
index 6ed8cd08..40dea78e 100644
--- a/tests/fixtures/README.md
+++ b/tests/fixtures/README.md
@@ -206,6 +206,39 @@ them a re-capture from the (removed) Rust binary:
`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 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,
+ 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.
+ Revised once, when the §5 temporal-form pass gave the source fixture explicit
+ UTC offsets: this golden is *derived* from `okf_v02/metrics/dau.md`, so a
+ deliberate change to its input has to reach it. Four values moved with the
+ source (`sources[].last_modified`, both `usage_window` bounds, `stale_after`)
+ and nothing else did — re-checked by hand, not re-captured blindly: the
+ `human:ada` stamp is still replaced in place as the second `verified` entry
+ after an untouched `process:nightly`, `generated` is still neither rewritten
+ nor refreshed, and no key was added, dropped or reordered.
## Temporal form (§5) (2026-08-31)
OKF v0.2 §5 requires every timestamp-valued key to be an ISO 8601 datetime with
diff --git a/tests/fixtures/golden/verify-dau.md b/tests/fixtures/golden/verify-dau.md
new file mode 100644
index 00000000..76d1db3b
--- /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-30T00:00:00Z
+usage_window:
+ from: 2026-06-01T00:00:00Z
+ to: 2026-06-30T00:00:00Z
+status: stable
+stale_after: 2099-01-01T00:00:00Z
+---
+
+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
diff --git a/web/src/pages/Cli.tsx b/web/src/pages/Cli.tsx
index 51add9ec..1c6bac1b 100644
--- a/web/src/pages/Cli.tsx
+++ b/web/src/pages/Cli.tsx
@@ -42,7 +42,7 @@ export default function Cli() {
return (
- Seven commands, one binary.
+ Eight commands, one binary.
>
}
lede={
@@ -78,6 +78,13 @@ export default function Cli() {
--stale, --trust, --status, --type
>,
],
+ [
+ 'okf verify …',
+ <>
+ 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'],
['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 8d861378..6881daa1 100644
--- a/web/src/pages/Home.tsx
+++ b/web/src/pages/Home.tsx
@@ -98,6 +98,7 @@ export default function Home() {
Command
Does
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) — 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
@@ -113,9 +114,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
@@ -135,7 +136,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/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/Cli.tsx b/web/src/pages/docs/Cli.tsx
index 240926c4..8c34dbb2 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
@@ -137,14 +151,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 seven `okf` subcommands: synopsis,
+ * Port of `website/docs/cli.html` — the eight `okf` subcommands: synopsis,
* per-command reference with real captured output, exit codes, build.
*/
export default function Cli() {
return (
- Seven subcommands over a bundle or a file, a self-contained Native AOT binary with no
+ Eight 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.
>
@@ -193,6 +207,16 @@ export default function Cli() {
<bundle>
Report trust, freshness and lifecycle across the bundle (§5.3–§5.5)
+ Record a review (§5.2) with --by <actor>; clears the unverified worklist,
+ not staleness
+
+
info
@@ -252,7 +276,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.
@@ -301,6 +327,45 @@ 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:
+
+
+
+ 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;{' '}
+ --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/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/Library.tsx b/web/src/pages/docs/Library.tsx
index 7326fbad..22fcc21c 100644
--- a/web/src/pages/docs/Library.tsx
+++ b/web/src/pages/docs/Library.tsx
@@ -321,10 +321,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.
>,
],
diff --git a/web/src/pages/docs/Mcp.tsx b/web/src/pages/docs/Mcp.tsx
index dc5e6a38..e06fbde1 100644
--- a/web/src/pages/docs/Mcp.tsx
+++ b/web/src/pages/docs/Mcp.tsx
@@ -68,7 +68,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.
@@ -81,7 +81,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',
<>
@@ -116,7 +130,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.
@@ -263,8 +277,8 @@ export default function Mcp() {
- okf-mcp registers only the read tools unless you ask for more — the three writers
- (okf_write_concept, okf_append_log,{' '}
+ okf-mcp registers only the eight read tools unless you ask for more — the four writers
+ (okf_write_concept, okf_verify, okf_append_log,{' '}
okf_regenerate_indexes) are left out entirely. Set OKF_MCP_WRITABLE=1 to add
them, which you want for a working bundle the model maintains, and do not want for a shared reference
bundle it should only consult.
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.
>,
],
[