diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d577f7..35fb820 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Three §5 trust signals that didn't do what they promised. The derived trust tier +and `sources[].author` were described as shipped in the 0.3.0 notes and the +README but were never surfaced or checked; `stale_after` was validated for its +form only, so a review deadline could pass unnoticed. + +### Added +- **`stale_after` is enforced, not just parsed** ([#10](https://github.com/MartinForReal/okf-enforcer/issues/10)). + A note whose `stale_after` has passed is flagged as due for review; previously + only the date's *form* was checked, so a deadline from last year passed + silently. Advisory only — §11 keeps it out of conformance. +- **The derived trust tier is shown** ([#11](https://github.com/MartinForReal/okf-enforcer/issues/11)). + The active note's tier (unverified / machine-confirmed / human-reviewed, §5.3) + appears in the status-bar tooltip. It was computed but reached no surface. The + report pane lists only files that *have* issues, so a conformant note would + never show a tier there. +- **`sources[].author` is validated** ([#12](https://github.com/MartinForReal/okf-enforcer/issues/12)). + Warns when an entry's `author` is present but not a non-empty string, + alongside the existing `resource` / `usage_count` / `last_modified` checks. + +All three sit behind **Validate trust & lifecycle fields**, off by default, so no +vault starts reporting anything new unless it had already opted in. + +### Fixed +- README and the 0.3.0 changelog entry claimed derived trust tiers and + `sources[].author` validation that had not shipped. Corrected. + ## [0.4.1] - 2026-07-31 ### Changed @@ -254,10 +282,16 @@ the fallbacks below, so existing vaults keep validating. ### Added - **Provenance / trust / lifecycle validation** (§5), opt-in via **Validate trust & lifecycle fields**. When present, checks `generated`/`verified` shape and the - actor convention (`/`, `human:`, `process:`), derives - trust tiers (unverified / machine-confirmed / human-reviewed), and validates - `status` (`draft|stable|deprecated`), `stale_after` (absolute date), and - `sources` with its credibility signals (`author`/`usage_count`/`last_modified`). + actor convention (`/`, `human:`, `process:`), and + validates `status` (`draft|stable|deprecated`), `stale_after` (absolute date), + and `sources` (`resource`, `usage_count`, `last_modified`). + + > **Correction.** This entry originally read "derives trust tiers (unverified / + > machine-confirmed / human-reviewed)" and listed `author` among the validated + > `sources` signals. Neither was true of 0.3.0: the tier was computed but never + > shown anywhere, and `author` was not checked. Both are fixed in Unreleased + > ([#11](https://github.com/MartinForReal/okf-enforcer/issues/11), + > [#12](https://github.com/MartinForReal/okf-enforcer/issues/12)). - **Attested Computation concepts** (§10). A `type: Attested Computation` note is checked for a required `runtime`, a present computation (inline `# Computation` fence or a `computation` path), and `parameters`/`executor`/`attester` shape. @@ -408,8 +442,13 @@ Initial release. - Batched, non-blocking scan/fix queue with an inline progress bar for large vaults. - Settings for automation toggles, batch size, warning rules, and excluded folders. +[Unreleased]: https://github.com/MartinForReal/okf-enforcer/compare/0.4.1...HEAD +[0.4.1]: https://github.com/MartinForReal/okf-enforcer/releases/tag/0.4.1 [0.4.0]: https://github.com/MartinForReal/okf-enforcer/releases/tag/0.4.0 [0.3.0]: https://github.com/MartinForReal/okf-enforcer/releases/tag/0.3.0 +[0.2.2]: https://github.com/MartinForReal/okf-enforcer/releases/tag/0.2.2 +[0.2.1]: https://github.com/MartinForReal/okf-enforcer/releases/tag/0.2.1 +[0.2.0]: https://github.com/MartinForReal/okf-enforcer/releases/tag/0.2.0 [0.1.3]: https://github.com/MartinForReal/okf-enforcer/releases/tag/0.1.3 [0.1.2]: https://github.com/MartinForReal/okf-enforcer/releases/tag/0.1.2 [0.1.1]: https://github.com/MartinForReal/okf-enforcer/releases/tag/0.1.1 diff --git a/README.md b/README.md index 759777f..c3f0f2a 100755 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ OKF is an open, minimal convention for representing knowledge as a directory of ## Features - **Conformance validation.** Checks every note against OKF v0.2. The hard rules (parseable frontmatter, non-empty `type`, valid `index.md`/`log.md` structure — §11/§8/§9) are reported as **errors**; the spec's recommended fields and SHOULD-guidance are **warnings** you can toggle. The spec's permissive rules are respected — broken links and missing optional fields never fail a bundle. -- **Trust & provenance (v0.2).** Opt-in checks for the §5 families: `generated`/`verified` and the actor convention, derived trust tiers (unverified / machine-confirmed / human-reviewed), `status` (`draft|stable|deprecated`), `stale_after`, and `sources` with its credibility signals. +- **Trust & provenance (v0.2).** Opt-in checks for the §5 families: `generated`/`verified` and the actor convention, `status` (`draft|stable|deprecated`), `stale_after` — both that it's an absolute date and whether it has passed, so a note that's due for review says so — and `sources` with its credibility signals (`author`/`usage_count`/`last_modified`). The note's derived trust tier (unverified / machine-confirmed / human-reviewed) is shown in the status-bar tooltip. - **Attested Computation (v0.2).** Validates `type: Attested Computation` concepts (§10) — a required `runtime`, a present computation (inline `# Computation` fence or a `computation` path), and `parameters`/`executor`/`attester` shape. - **v0.1 → v0.2 migration.** A command rewrites a legacy `timestamp` into `generated: { by, at }` and lifts a body `# Citations` list into `sources`, then offers to declare `okf_version: "0.2"` in the root index. - **Vault-wide report.** A compact, collapsible side panel lists every non-conformant note, errors first, with a one-line summary of conformant / error / warning counts. Hidden by default; opens only on demand. -- **Status-bar indicator.** A single status-bar item shows the active note's state (`✓` / `⚠` / `✖`) with details in its tooltip. Click it to auto-fix the current note. +- **Status-bar indicator.** A single status-bar item shows the active note's state (`✓` / `⚠` / `✖`) with details in its tooltip — including the note's derived trust tier, when trust checks are on. Click it to auto-fix the current note. - **Auto-fix.** Inserts missing frontmatter (`type`, `title`, `generated`) non-destructively — it never overwrites values you've set. - **Prompt for required fields.** When a note is missing a meaningful `type`, a dialog lets you set `type`, `title`, and `description` directly. - **On-save & on-create hooks.** New notes, edited notes, and notes added by the **Importer** plugin are brought into conformance automatically. @@ -53,7 +53,7 @@ Configure under **Settings → OKF Enforcer**: - **Generate index.md on startup** — off by default, since the hooks above already keep listings current while the plugin is running. Turn it on to bring every folder's `index.md` up to date once when the plugin loads, for what changed while Obsidian was closed — a vault synced from another machine, or edited outside it. It runs before the startup scan, so the scan judges the listings as they now stand rather than the ones it is about to replace, and it runs quietly: progress shows in the status bar, with no notice at the end. - **Rebuild existing index.md** — off by default, so generating over an existing index adds what's missing, fixes links that point at the wrong path, and removes entries for notes that are gone, changing nothing else. Turn it on to rewrite the listing from the folder's contents instead, which also refreshes every description, re-sorts the entries, and re-groups them under their current `type` — at the cost of any prose in the file, which a rebuild discards apart from the section named below. - **Subdirectory description section** — heading in a subfolder's `index.md` whose first paragraph becomes that folder's description in the parent listing (e.g. `Purpose`). Blank by default, which leaves subdirectory entries undescribed. Additive generation never touches the file's prose, and this is the one section a rebuild carries over, so the description you write survives a refresh either way. -- **Rules** — **Warn on missing recommended fields**, **Warn on missing tags**, **Validate trust & lifecycle fields**, and **Validate Attested Computation concepts** select which optional checks to surface. `index.md` / `log.md` structure is always checked: §11 makes it one of the three requirements for a conformant bundle, so it isn't a toggle. +- **Rules** — **Warn on missing recommended fields**, **Warn on missing tags**, **Validate trust & lifecycle fields**, and **Validate Attested Computation concepts** select which optional checks to surface. **Validate trust & lifecycle fields** also decides whether the note's trust tier appears in the status-bar tooltip, since the tier is derived from the same §5 `verified` events the checks read. `index.md` / `log.md` structure is always checked: §11 makes it one of the three requirements for a conformant bundle, so it isn't a toggle. - **Excluded folders** — paths skipped during validation and index generation (default: `Templates`). Use this for attachment folders you'd rather not have an `index.md` in. The Obsidian config folder (e.g. `.obsidian`) is always skipped automatically. - **Batch size** — files processed per async chunk (lower = smoother UI on very large vaults). - **Enable Portent validation** _(experimental / beta — the Portent spec is pre-1.0 and may change)_ — layer the [Portent](https://portent.md) spec on top of OKF: default type vocabulary (`Project`, `Operation`, `Responsibility`, `Task`, `Event`, `Note`, `Topic`, `Person`), lifecycle metadata (optional and format-free — a single `status`/`state` value, boolean `organized`/`archived`, or omitted entirely when organized by default), and relationship shape (`belongs_to` single wikilink, `related_to` list of wikilinks). All Portent findings are warnings — they never break OKF conformance. diff --git a/main.js b/main.js index 4a440ae..0c12233 100755 --- a/main.js +++ b/main.js @@ -190,6 +190,36 @@ function normalizeVerified(data) { if (typeof v === "object") return [v]; return []; } +function trustTier(data) { + var _a; + const events = normalizeVerified(data); + if (events.length === 0) return "unverified"; + for (const e of events) { + if (String((_a = e["by"]) != null ? _a : "").startsWith("human:")) return "human-reviewed"; + } + return "machine-confirmed"; +} +function trustTierOfContent(content) { + const { hasFm, raw } = splitFrontmatter(content); + if (!hasFm) return null; + try { + const parsed = (0, import_obsidian.parseYaml)(raw); + if (parsed && typeof parsed === "object") { + return trustTier(parsed); + } + } catch (e) { + return null; + } + return null; +} +function isStale(data, today = /* @__PURE__ */ new Date()) { + const raw = data["stale_after"]; + if (!raw) return false; + const s = String(raw).slice(0, 10); + if (!ISO_DATE_RE.test(s)) return false; + const t = today.toISOString().slice(0, 10); + return t >= s; +} function basename(path) { const f = path.split("/").pop() || path; return f.replace(/\.md$/i, ""); @@ -779,6 +809,12 @@ function validateTrustFamilies(data) { rule: "\xA75.5", message: "`stale_after` should be an absolute date (`YYYY-MM-DD`)." }); + } else if (isStale(data)) { + issues.push({ + severity: "warning", + rule: "\xA75.5", + message: `\`stale_after\` (${s}) has passed; this concept is due for review.` + }); } } if ("sources" in data && data["sources"] != null) { @@ -807,6 +843,13 @@ function validateTrustFamilies(data) { message: `\`sources[${i}]\` is missing the required \`resource\`.` }); } + if ("author" in e && (typeof e["author"] !== "string" || e["author"].trim().length === 0)) { + issues.push({ + severity: "warning", + rule: "\xA75.1", + message: `\`sources[${i}].author\` should be a non-empty string.` + }); + } if ("usage_count" in e && typeof e["usage_count"] !== "number") { issues.push({ severity: "warning", @@ -1577,7 +1620,7 @@ var OkfPlugin = class extends import_obsidian3.Plugin { this.isRoot(file), this.settings ); - this.updateStatus(postIssues); + this.updateStatus(postIssues, content); const remainingErrors = postIssues.filter((i) => i.severity === "error"); if (remainingErrors.length > 0) { this.promptForRequiredFields(file, remainingErrors); @@ -1599,16 +1642,17 @@ var OkfPlugin = class extends import_obsidian3.Plugin { this.isRoot(file), this.settings ); - this.updateStatus(issues); + this.updateStatus(issues, content); if (openReport) { this.renderResults(issues.length ? [{ path: file.path, issues }] : [], 1); void this.activateView(); if (!issues.length) new import_obsidian3.Notice("OKF: active note is conformant \u2705"); } } - updateStatus(issues) { + updateStatus(issues, content) { const errs = issues.filter((i) => i.severity === "error").length; const warns = issues.filter((i) => i.severity === "warning").length; + const tier = this.settings.warnTrustFields && content !== void 0 ? trustTierOfContent(content) : null; this.statusEl.removeClass( "okf-statusbar-ok", "okf-statusbar-bad", @@ -1625,13 +1669,18 @@ var OkfPlugin = class extends import_obsidian3.Plugin { this.statusEl.addClass("okf-statusbar-ok"); } if (issues.length === 0) { - this.statusEl.setAttribute( - "aria-label", - "Active note conforms to OKF v0.2 \u2014 click to scan the vault" - ); + const lines = ["Active note conforms to OKF v0.2"]; + if (tier) lines.push(`Trust tier: ${tier}`); + lines.push(""); + lines.push("Click to scan the whole vault"); + this.statusEl.setAttribute("aria-label", lines.join("\n")); } else { const lines = issues.slice(0, 8).map((i) => `${i.severity === "error" ? "\u2716" : "\u26A0"} ${i.rule} ${i.message}`); if (issues.length > 8) lines.push(`\u2026and ${issues.length - 8} more`); + if (tier) { + lines.push(""); + lines.push(`Trust tier: ${tier}`); + } lines.push(""); lines.push("Click to scan the whole vault"); this.statusEl.setAttribute("aria-label", lines.join("\n")); @@ -1781,7 +1830,7 @@ Click to open the report` this.isRoot(file), this.settings ); - this.updateStatus(issues); + this.updateStatus(issues, content); } async fixAll() { if (this.busy) { @@ -2193,7 +2242,7 @@ var OkfSettingTab = class extends import_obsidian3.PluginSettingTab { }, { name: "Validate trust & lifecycle fields", - desc: "Check the v0.2 trust fields on notes that carry them: `verified`, `status`, `stale_after`, `sources` (\xA75). Advisory; off by default.", + desc: "Check the v0.2 trust fields on notes that carry them: `verified`, `status`, `stale_after` (including whether it has passed), `sources` (\xA75), and show the note's trust tier in the status-bar tooltip. Advisory; off by default.", control: (row) => row.addToggle( (tg) => tg.setValue(s.warnTrustFields).onChange((v) => { s.warnTrustFields = v; diff --git a/main.ts b/main.ts index f45ef51..b16143a 100755 --- a/main.ts +++ b/main.ts @@ -16,6 +16,7 @@ import { DEFAULT_SETTINGS, validateContent, applyFixes, + trustTierOfContent, isReserved, isExcluded, basename, @@ -409,7 +410,7 @@ export default class OkfPlugin extends Plugin { this.isRoot(file), this.settings ); - this.updateStatus(postIssues); + this.updateStatus(postIssues, content); const remainingErrors = postIssues.filter((i) => i.severity === "error"); if (remainingErrors.length > 0) { @@ -437,7 +438,7 @@ export default class OkfPlugin extends Plugin { this.isRoot(file), this.settings ); - this.updateStatus(issues); + this.updateStatus(issues, content); if (openReport) { this.renderResults(issues.length ? [{ path: file.path, issues }] : [], 1); void this.activateView(); @@ -445,9 +446,16 @@ export default class OkfPlugin extends Plugin { } } - private updateStatus(issues: OkfIssue[]) { + private updateStatus(issues: OkfIssue[], content?: string) { const errs = issues.filter((i) => i.severity === "error").length; const warns = issues.filter((i) => i.severity === "warning").length; + // The derived trust tier (§5.3) describes the note rather than faulting it, + // so it rides in the tooltip instead of the issue list. The report pane only + // lists files that have issues, which would hide the tier on a clean note. + const tier = + this.settings.warnTrustFields && content !== undefined + ? trustTierOfContent(content) + : null; this.statusEl.removeClass( "okf-statusbar-ok", "okf-statusbar-bad", @@ -465,15 +473,20 @@ export default class OkfPlugin extends Plugin { } // Tooltip carries the detail so we don't need a Notice for routine checks. if (issues.length === 0) { - this.statusEl.setAttribute( - "aria-label", - "Active note conforms to OKF v0.2 — click to scan the vault" - ); + const lines = ["Active note conforms to OKF v0.2"]; + if (tier) lines.push(`Trust tier: ${tier}`); + lines.push(""); + lines.push("Click to scan the whole vault"); + this.statusEl.setAttribute("aria-label", lines.join("\n")); } else { const lines = issues .slice(0, 8) .map((i) => `${i.severity === "error" ? "✖" : "⚠"} ${i.rule} ${i.message}`); if (issues.length > 8) lines.push(`…and ${issues.length - 8} more`); + if (tier) { + lines.push(""); + lines.push(`Trust tier: ${tier}`); + } lines.push(""); lines.push("Click to scan the whole vault"); this.statusEl.setAttribute("aria-label", lines.join("\n")); @@ -627,7 +640,7 @@ export default class OkfPlugin extends Plugin { this.isRoot(file), this.settings ); - this.updateStatus(issues); + this.updateStatus(issues, content); } async fixAll() { @@ -1158,7 +1171,7 @@ class OkfSettingTab extends PluginSettingTab { }, { name: "Validate trust & lifecycle fields", - desc: "Check the v0.2 trust fields on notes that carry them: `verified`, `status`, `stale_after`, `sources` (§5). Advisory; off by default.", + desc: "Check the v0.2 trust fields on notes that carry them: `verified`, `status`, `stale_after` (including whether it has passed), `sources` (§5), and show the note's trust tier in the status-bar tooltip. Advisory; off by default.", control: (row) => row.addToggle((tg) => tg.setValue(s.warnTrustFields).onChange((v) => { diff --git a/validator.ts b/validator.ts index b1e159c..d838559 100755 --- a/validator.ts +++ b/validator.ts @@ -167,6 +167,28 @@ export function trustTier( return "machine-confirmed"; } +/** + * Trust tier of a note's frontmatter, or null when it has none we can read. + * A convenience for callers holding raw file content rather than parsed + * frontmatter; unparseable YAML is reported by `validateContent`, so here it + * just means "no tier to show". + */ +export function trustTierOfContent( + content: string +): "unverified" | "machine-confirmed" | "human-reviewed" | null { + const { hasFm, raw } = splitFrontmatter(content); + if (!hasFm) return null; + try { + const parsed: unknown = parseYaml(raw); + if (parsed && typeof parsed === "object") { + return trustTier(parsed as Record); + } + } catch { + return null; + } + return null; +} + /** * Whether a concept is stale per `stale_after` (§5.5): stale when * `today >= stale_after`. False when absent or unparseable. @@ -1168,7 +1190,7 @@ function validateTrustFamilies(data: Record): OkfIssue[] { } } - // `stale_after` (§5.5): an absolute YYYY-MM-DD date. + // `stale_after` (§5.5): an absolute YYYY-MM-DD date, and a review deadline. if ("stale_after" in data && data["stale_after"] != null) { const s = String(data["stale_after"]).slice(0, 10); if (!ISO_DATE_RE.test(s)) { @@ -1177,6 +1199,12 @@ function validateTrustFamilies(data: Record): OkfIssue[] { rule: "§5.5", message: "`stale_after` should be an absolute date (`YYYY-MM-DD`).", }); + } else if (isStale(data)) { + issues.push({ + severity: "warning", + rule: "§5.5", + message: `\`stale_after\` (${s}) has passed; this concept is due for review.`, + }); } } @@ -1207,6 +1235,16 @@ function validateTrustFamilies(data: Record): OkfIssue[] { message: `\`sources[${i}]\` is missing the required \`resource\`.`, }); } + if ( + "author" in e && + (typeof e["author"] !== "string" || e["author"].trim().length === 0) + ) { + issues.push({ + severity: "warning", + rule: "§5.1", + message: `\`sources[${i}].author\` should be a non-empty string.`, + }); + } if ("usage_count" in e && typeof e["usage_count"] !== "number") { issues.push({ severity: "warning",