From d10aa5129a7a1e9ca9934651a3c54e5c560e9bb9 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Wed, 30 Sep 2026 07:15:47 +0200 Subject: [PATCH] feat(approve): per-doc approval level, AI sign-off where a doc allows it A doc may now say who can sign it off: `approval: human` (the default when unset) needs a person, `approval: ai` lets an AI review alone approve it. Customer-facing docs and verbatim-blocks always need a person, and `approval: ai` on them is an error. - km approve ... --by WHO [--at DATE] moves a review doc to accepted and writes approved_by / approved_at in place (no YAML round-trip), then gates it with km validate and restores it if that fails. An AI approver is written ai: and is refused before any write where the level does not allow it; only a review doc can be approved. A doc whose review_by has passed (as after a demotion) is refused unless --review-by sets a new date, since the next demote would undo it; both compare with today, like demote, so a backdated --at gets no way round them. A refused doc is restored byte for byte, and CRLF docs are read like every other km command reads them. - km validate fails an accepted doc whose ai: approver its level does not allow. It is metadata, not authentication, like the rest of the gate. - reviewed_by (list) records pre-reviews, such as a PR bot's model, date and PR. It is not an approval: promote and --replace drop it with the approval, and a cross-repo promote also drops approval, so the target brain decides its own level. - status_rules keys are now read generically as _, so accepted_requires (and any renamed served status) needs no code of its own, and a malformed key fails fast instead of being ignored; a key the brain adds must also name a status the schema knows, while km's own keys stay valid in a brain that renames its statuses. The team template now requires owner and review_by for accepted, which its CONVENTIONS already stated. The rule lives in one place, km.common.approval_problem, shared by approve and validate, and keys on the configured served status, not the literal word "accepted". --- README.md | 1 + skills/km/SKILL.md | 18 +++- src/km/approve.py | 83 +++++++++++++++++ src/km/cli.py | 1 + src/km/common.py | 34 +++++++ src/km/promote.py | 16 ++-- src/km/schema.base.yaml | 7 ++ .../team/CONVENTIONS.team.template.md | 6 ++ .../team/schema.local.team.template.yaml | 4 + src/km/validate.py | 55 +++++++---- tests/gate_smoke.sh | 92 +++++++++++++++++++ 11 files changed, 291 insertions(+), 26 deletions(-) create mode 100644 src/km/approve.py diff --git a/README.md b/README.md index 5cb4674..656618b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ km gen-index [--check] regenerate every '## Documents' list in _index.md km demote [--apply] demote served docs past their review date km serve build the per-audience bundle under dist/served/ km promote ... move a note into the brain as a review doc +km approve DOC... --by WHO sign review docs off; ai: only where the doc says approval: ai km init DIR [--team ...] create a brain from the templates km upgrade move a brain's km pins to this version ``` diff --git a/skills/km/SKILL.md b/skills/km/SKILL.md index 5c58ed3..3db6bc3 100644 --- a/skills/km/SKILL.md +++ b/skills/km/SKILL.md @@ -286,7 +286,7 @@ Do not offer for simple lookups or single-source answers. All writes follow CONVENTIONS.md for frontmatter, folder placement, and naming. Always confirm before writing. Commit with `docs(): `. -- **Status:** New documents always start as `status: draft`. Never set `status: accepted` without explicit user confirmation — always ask first. +- **Status:** New documents always start as `status: draft`. Never set `status: accepted` without explicit user confirmation — always ask first. Sign a doc off with `km approve --by ` (see "Approval" below), not by editing `status` by hand. - **Save:** Auto-detect type (note/concept/decision/transcript). Unclear folder → `inbox/` - **Decision:** Extract title, context, alternatives, consequences. Use `type: decision` - **Transcript:** Extract decisions + action items. Sections: Attendees, Summary, Decisions, Actions, Transcript @@ -413,6 +413,22 @@ Known limitations (advisory scope, follow-ups): whole-repo mode sees TRACKED fil per-file mode does not check reverse edges (a target flipping to `draft` is caught only in a full run); link-form coverage is inline/reference/wiki/HTML/angle-bracket (not exhaustive). +## Approval (`km approve`) + +`km approve ... --by WHO [--at YYYY-MM-DD] [--review-by YYYY-MM-DD]` moves a `status: review` +doc to `accepted` and records `approved_by` / `approved_at`, then gates it with `km validate` and +restores it byte for byte if that fails. A doc whose `review_by` has passed (typically one CI +demoted) is refused until `--review-by` sets a new date. + +- **Levels:** `approval: human` (the default when unset) needs a person; `approval: ai` lets an AI + review alone sign off, written `--by ai:`. `audience: customer` docs and `verbatim-block`s + always need a person. `km validate` enforces it on signed-off docs (metadata, not authentication). +- **Pre-reviews:** `reviewed_by` (a list) records them, e.g. a PR bot's model, date and PR; it is not + an approval. `promote` and `--replace` drop it with the approval; a cross-repo promote also drops + `approval`, so the target brain decides its own level. +- **Required fields:** `status_rules` keys are `_`, e.g. + `accepted_requires: [owner, review_by]` in `schema.local.yaml`. + ## Index completeness (opt-in) Set `check_index: true` at the top of `schema.local.yaml` to have `km validate` lint each diff --git a/src/km/approve.py b/src/km/approve.py new file mode 100644 index 0000000..a476eb5 --- /dev/null +++ b/src/km/approve.py @@ -0,0 +1,83 @@ +"""Sign review docs off: `status: accepted` plus `approved_by` and `approved_at`. + +Who may sign off is the doc's `approval` level (see km.common.approval_problem); only a doc in +`status: review` can be approved. Each doc is edited in place (those three lines, no YAML round-trip, +like `km demote`) and then gated by `km validate`; a doc that fails is restored unchanged. + +A doc whose `review_by` has already passed is refused unless `--review-by` sets a new date, or +the next `km demote` would take the approval straight back. + +Run: km approve [--root DIR] ... --by WHO [--at YYYY-MM-DD] [--review-by YYYY-MM-DD] +""" +from __future__ import annotations + +import argparse +import datetime +import sys +from pathlib import Path + +from km.common import ROOT, SERVED_STATUS, approval_problem, set_fm_line, split_frontmatter +from km.paths import write_text +from km.promote import run_gate + + +def approve_one(doc: str, by: str, at: datetime.date, review_by: datetime.date | None) -> str | None: + """Sign one doc off. Returns why it was refused, or None.""" + path = Path(doc).resolve() + try: + rel = path.relative_to(ROOT).as_posix() + except ValueError: + return f"{doc}: outside the brain" + if not path.is_file(): + return f"{rel}: no such file" + old = path.read_bytes() # restored byte for byte if the gate fails (a BOM included) + text = old.decode("utf-8-sig").replace("\r\n", "\n") # strict decode (written back); CRLF like read() + fm, raw, body = split_frontmatter(text) + if fm is None: + return f"{rel}: no frontmatter" + if fm.get("status") != "review": + return f"{rel}: only a review doc can be approved (status: {fm.get('status')!r})" + why = approval_problem(fm, by) + if why: + return f"{rel}: {why}" + if review_by is None: + try: + due = datetime.date.fromisoformat(str(fm.get("review_by"))[:10]) if fm.get("review_by") else None + except ValueError: + return f"{rel}: review_by {fm.get('review_by')!r} is not a YYYY-MM-DD date; pass --review-by" + if due and due < max(at, datetime.date.today()): # km demote compares with today + return f"{rel}: review_by {due} has passed; pass --review-by with a new date" + lines = [("status", SERVED_STATUS), ("approved_by", f'"{by}"'), ("approved_at", at.isoformat())] + if review_by: + lines.append(("review_by", review_by.isoformat())) + for key, value in lines: + raw = set_fm_line(raw, key, value) + write_text(path, raw + body) + gate = run_gate(path) + if gate.returncode != 0: + path.write_bytes(old) + return f"{rel}: km validate fails after the change, left unchanged:\n{gate.stdout.rstrip()}" + print(f"approve: {rel} accepted, approved_by {by} on {at.isoformat()}") + return None + + +def main() -> int: + ap = argparse.ArgumentParser(prog="km approve") + ap.add_argument("docs", nargs="+") + ap.add_argument("--by", required=True, help="approver: initials, or ai: where the doc says approval: ai") + ap.add_argument("--at", type=datetime.date.fromisoformat, default=datetime.date.today(), + help="approval date, YYYY-MM-DD") + ap.add_argument("--review-by", type=datetime.date.fromisoformat, help="set a new review_by date") + a = ap.parse_args() + if not a.by.strip(): + ap.error("--by must name the approver") + if a.review_by and a.review_by < max(a.at, datetime.date.today()): + ap.error(f"--review-by {a.review_by} is in the past; the next km demote would undo the approval") + refused = [why for doc in a.docs if (why := approve_one(doc, a.by, a.at, a.review_by))] + for why in refused: + print(f"approve: REFUSED {why}") + return 1 if refused else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/km/cli.py b/src/km/cli.py index 4014c4e..f0ec772 100644 --- a/src/km/cli.py +++ b/src/km/cli.py @@ -29,6 +29,7 @@ "demote": ("km.demote", "demote served docs past review_by (--apply to write)"), "serve": ("km.serve", "build the served bundle under dist/served/"), "promote": ("km.promote", "move a note into the brain as a review doc, or `promote stub`"), + "approve": ("km.approve", "sign review docs off as accepted (--by WHO)"), "init": ("km.init", "create a brain: `init DIR --initials XX`, `--team ...` for a team brain"), "upgrade": ("km.upgrade", "move a brain's km pins to this version; drop km files copied in earlier"), } diff --git a/src/km/common.py b/src/km/common.py index b1f6bd3..658e4f2 100644 --- a/src/km/common.py +++ b/src/km/common.py @@ -57,6 +57,40 @@ def _merge(base: dict, local: dict | None) -> dict: SERVED_STATUS = _gate.get("served_status", "accepted") CUSTOMER_AUDIENCE = _gate.get("customer_audience", "customer") +# Approval: a doc may be signed off by an AI review alone only when it says `approval: ai`; a +# customer-facing doc or verbatim-block always needs a person. An AI approver is written +# `approved_by: "ai:"`. Metadata, not authentication: it catches slips, not a determined author. +APPROVAL_FIELDS = ("approved_by", "approved_at") + + +def is_ai_approver(who) -> bool: + return str(who or "").strip().lower().startswith("ai:") + + +def human_only(fm: dict) -> bool: + return fm.get("audience") == CUSTOMER_AUDIENCE or fm.get("type") == "verbatim-block" + + +def approval_problem(fm: dict, approver=None) -> str | None: + """Why `approver` (default: the doc's approved_by) may not sign this doc off, or None.""" + if fm.get("approval") == "ai" and human_only(fm): + return "'approval: ai' is not allowed on a customer-facing doc or verbatim-block" + if is_ai_approver(fm.get("approved_by") if approver is None else approver) and fm.get("approval") != "ai": + return ("a customer-facing doc or verbatim-block needs a human approver" if human_only(fm) + else "an AI approver needs 'approval: ai'") + return None + + +def set_fm_line(raw: str, key: str, value: str) -> str: + """Set `key: value` in a raw '---' frontmatter block in place: replace the line, or add it last.""" + line = f"{key}: {value}" + new, n = re.subn(rf"(?m)^{re.escape(key)}:.*$", lambda _m: line, raw, count=1) + if n: + return new + end = raw.rstrip("\n").rfind("\n---") + return raw[:end] + "\n" + line + raw[end:] + + _FM = re.compile(r"^(---\n(.*?)\n---\n?)(.*)$", re.S) diff --git a/src/km/promote.py b/src/km/promote.py index 97113bf..69948a5 100644 --- a/src/km/promote.py +++ b/src/km/promote.py @@ -8,7 +8,7 @@ * The candidate is gated in a temp file inside the repo, then atomically renamed onto the target on PASS; a failing promote writes nothing and never clobbers. * An existing served doc is NOT overwritten without --replace, and --replace INVALIDATES the - approval (status -> review, approved_* dropped, timestamp bumped). + approval (status -> review, approved_* and reviewed_by dropped, timestamp bumped). * Slug collisions are a HARD STOP; a non-served target is refused; a peer-brain target is refused (correct a foreign brain via a PR against its own repo, never in the parent). * A verbatim-block is never --replace'd (change it only via supersede). @@ -43,7 +43,7 @@ import yaml -from km.common import ROOT, SCHEMA +from km.common import APPROVAL_FIELDS, ROOT, SCHEMA from km.paths import write_text @@ -102,7 +102,8 @@ def run_gate(target: Path): env={**os.environ, "PYTHONIOENCODING": "utf-8"}) # the child writes UTF-8 too -_PROVENANCE = ("status", "approved_by", "approved_at", "supersedes", "superseded_by", "contribution") +_REVIEW = (*APPROVAL_FIELDS, "reviewed_by") # sign-off and pre-reviews: they hold for the text they saw +_PROVENANCE = ("status", *_REVIEW, "supersedes", "superseded_by", "contribution") _STUB_DROPS = _PROVENANCE + ("audience", "review_by") # a stub is never served _NOT_STUBBABLE = {"README.md", "CLAUDE.md", "CONVENTIONS.md", "SKILL.md", "index.md", "log.md", "_index.md"} @@ -289,8 +290,8 @@ def main() -> int: return 2 fm = ex_fm fm["status"] = "review" - fm.pop("approved_by", None) - fm.pop("approved_at", None) + for _k in _REVIEW: # a review of the old text no longer holds + fm.pop(_k, None) fm["timestamp"] = today target = existing ident = {"type": src_fm.get("type"), "title": src_fm.get("title"), @@ -331,8 +332,9 @@ def main() -> int: fm["owner"] = a.owner fm.setdefault("audience", "internal") fm.setdefault("review_by", review_by) - if cross_repo: # these reference the SOURCE repo and do not resolve in the target - for _k in ("related", "sources", "translates", "project", "evidence"): + if cross_repo: # these reference the SOURCE repo and do not resolve in the target; and the + # target decides for itself whether an AI review may approve the doc (unset = human) + for _k in ("related", "sources", "translates", "project", "evidence", "approval"): fm.pop(_k, None) if isinstance(fm.get("resource"), str) and not fm["resource"].startswith(("http://", "https://")): fm.pop("resource", None) # a non-web resource: URI points into the source machine/repo diff --git a/src/km/schema.base.yaml b/src/km/schema.base.yaml index 1f86d89..7c087ee 100644 --- a/src/km/schema.base.yaml +++ b/src/km/schema.base.yaml @@ -75,6 +75,12 @@ fields: approved_at: # when sign-off happened (audit-grade record inside the repo) required: false format: iso8601 + approval: # who may sign off: ai or human (default); rules in km.common.approval_problem + required: false + enum: [ai, human] + reviewed_by: # pre-reviews the doc went through (reviewer or model, date, PR); not an approval + required: false + type: list audience: # replaces the fragile free-form `customer-facing` tag required: false enum: [internal, customer] @@ -109,6 +115,7 @@ status_rules: superseded_forbids: [contribution] # a stubbed doc no longer has a pending contribution obsolete_forbids: [superseded_by] accepted_recommends: [approved_by, approved_at] # soft rollout; flip to *_requires once backfilled + # accepted_requires: [owner, review_by] # a brain may make fields mandatory for accepted # Universal defaults — brains EXTEND these in schema.local.yaml (lists are concatenated). exempt_files: [CLAUDE.md, README.md, CONVENTIONS.md, SKILL.md] diff --git a/src/km/templates/team/CONVENTIONS.team.template.md b/src/km/templates/team/CONVENTIONS.team.template.md index 7feb574..7cff7bb 100644 --- a/src/km/templates/team/CONVENTIONS.team.template.md +++ b/src/km/templates/team/CONVENTIONS.team.template.md @@ -56,6 +56,9 @@ language: # required on served docs description: "One-sentence summary." owner: # responsible for keeping it true (staleness) review_by: 2027-01-15 # required once accepted +approval: # who may sign off; unset = human (see Status lifecycle) +approved_by: "> # set by `km approve`, with approved_at +reviewed_by: [" (PR #N)"] # pre-reviews, not an approval ticket: ABC-123 # provenance to the work item this doc came from resource: "https://…" # URI of an external asset (datasheet/repo/tracker) project: @@ -90,6 +93,9 @@ draft → review → accepted → superseded | obsolete ``` - `accepted` = reviewed, current, in the served set. Requires `owner` + `review_by`. +- Sign-off is `km approve --by `. With `approval: ai` an AI review alone may sign + off (`--by ai:`); without it a person must. Customer-facing docs and verbatim-blocks always + need a person. - CI **demotes** an `accepted` doc back to `review` when its `review_by` lapses, so it drops out of the served set automatically. Staleness is prevented, not just warned. An accepted doc that links to a demoted one then fails the gate; the weekly run turns red so the maintainer re-reviews either. diff --git a/src/km/templates/team/schema.local.team.template.yaml b/src/km/templates/team/schema.local.team.template.yaml index 7a0aa52..6ae1a36 100644 --- a/src/km/templates/team/schema.local.team.template.yaml +++ b/src/km/templates/team/schema.local.team.template.yaml @@ -26,6 +26,10 @@ gate: # project_prefix: projects/ # subfolder names are project codes, flagged outside their folder # project_code_pattern: 'p[0-9]+' # only matching subfolder names count as codes +# accepted means reviewed and current: it must name who keeps it true and when to re-check it. +status_rules: + accepted_requires: [owner, review_by] + # Repo-only fields (shallow-merged over the base schema). fields: ticket: # provenance to the work item this doc came from diff --git a/src/km/validate.py b/src/km/validate.py index f518cd2..20b2d72 100644 --- a/src/km/validate.py +++ b/src/km/validate.py @@ -43,8 +43,8 @@ import yaml -from km.common import EXEMPT, OVERLAY, ROOT, SCHEMA, tracked_md -from km.paths import git +from km.common import EXEMPT, OVERLAY, ROOT, SCHEMA, approval_problem, tracked_md +from km.paths import BASE_SCHEMA, git from km.pins import movable_refs from km.common import RESERVED as RESERVED_NF from km.common import SKIP as SKIP_PREFIXES @@ -57,7 +57,27 @@ FIELDS = SCHEMA["fields"] TYPE_RULES = SCHEMA.get("type_rules", {}) SOFT_RULES = SCHEMA.get("type_rules_soft", {}) -STATUS_RULES = SCHEMA.get("status_rules", {}) +def _status_rules(raw) -> dict[tuple[str, str], list]: + """status_rules keys are `_`; a key the brain adds must also + name a status the schema knows. Anything else fails fast, so a typo never switches a rule off. + km's own keys are exempt from the status check: a brain may rename its statuses.""" + known = set((FIELDS.get("status") or {}).get("enum") or []) + own = set((yaml.safe_load(BASE_SCHEMA.read_text(encoding="utf-8")) or {}).get("status_rules") or {}) + out, bad = {}, [] + for key, fields in (raw or {}).items(): + st, _, kind = str(key).rpartition("_") + if (not st or (known and key not in own and st not in known) + or kind not in ("requires", "recommends", "forbids") or not isinstance(fields, list)): + bad.append(str(key)) + else: + out[(st, kind)] = fields + if bad: + sys.exit(f"schema: status_rules key(s) {', '.join(bad)} must be _: " + f"[fields], with a status from the schema") + return out + + +STATUS_RULES = _status_rules(SCHEMA.get("status_rules")) # --- km gate config (opt-in) with fail-fast validation --- _GATE_KEYS = { @@ -536,21 +556,20 @@ def _term_errors(rel: str, scan: str) -> list[tuple[str, str, str]]: warnings.append((f"soft:{t}", rel, f"type '{t}' should have '{rec}'")) st = fm.get("status") - if st == "superseded": - for r in STATUS_RULES.get("superseded_requires", []): - if r not in fm: - errors.append(("status-rule", rel, f"superseded requires '{r}'")) - for r in STATUS_RULES.get("superseded_forbids", []): - if r in fm: - warnings.append(("status-rule", rel, f"superseded should not set '{r}'")) - if st == "obsolete": - for r in STATUS_RULES.get("obsolete_forbids", []): - if r in fm: - warnings.append(("status-rule", rel, f"obsolete should not set '{r}'")) - if st == "accepted": - for r in STATUS_RULES.get("accepted_recommends", []): - if r not in fm: - warnings.append(("status-soft", rel, f"accepted should record '{r}'")) + for (_st, _kind), _fields in STATUS_RULES.items(): + if _st != st: + continue + for r in _fields: + if _kind == "requires" and r not in fm: + errors.append(("status-rule", rel, f"{st} requires '{r}'")) + elif _kind == "forbids" and r in fm: + warnings.append(("status-rule", rel, f"{st} should not set '{r}'")) + elif _kind == "recommends" and r not in fm: + warnings.append(("status-soft", rel, f"{st} should record '{r}'")) + # an ai: approver counts only on a signed-off doc; a contradicting `approval` always does + _why = approval_problem(fm, None if st == SERVED_STATUS else "") + if _why: + errors.append(("approval", rel, _why)) # --- km GATE (opt-in, advisory) — leak / bergab / freshness (secrets are a separate pass) --- if GATE_ON: diff --git a/tests/gate_smoke.sh b/tests/gate_smoke.sh index eb72632..0bc4062 100644 --- a/tests/gate_smoke.sh +++ b/tests/gate_smoke.sh @@ -219,4 +219,96 @@ errs "$T/bms/pc1.md" | grep -q "gate.term_scan_prefixes must be a list" && ok "t printf '%s' "$BASE_LOCAL" > "$T/schema.local.yaml" rm -rf "$T/projects" "$T/bmsx" "$T/topics/ts2.md" "$T/.git" +# AI approval: an ai: approver needs approval: ai; customer docs and verbatim-blocks need a human +printf '%s' "$BASE_LOCAL" > "$T/schema.local.yaml" +mkdir -p "$T/ap" +doc(){ # doc FILE STATUS "extra frontmatter lines"; TYPE=, AUD= override, RB= (empty) drops review_by + local rb="${RB-2027-01-01}" + printf -- '---\ntype: %s\ntitle: t\ntimestamp: 2026-09-30\nauthor: X\nstatus: %s\naudience: %s\nlanguage: en\ntags: [t]\nowner: X\n%b%b---\nbody\n' \ + "${TYPE:-concept}" "$2" "${AUD:-internal}" "${rb:+review_by: $rb\n}" "${3:-}" > "$1"; } +doc "$T/ap/a1.md" accepted 'approved_by: "ai:model-x"\napproved_at: 2026-09-30\n' +V "$T/ap/a1.md" && no "approval: an ai: approver without approval: ai fails" || ok "approval: an ai: approver without approval: ai fails" +doc "$T/ap/a2.md" accepted 'approval: ai\napproved_by: "ai:model-x"\napproved_at: 2026-09-30\n' +V "$T/ap/a2.md" && ok "approval: ai lets an ai: approver sign off" || no "approval: ai lets an ai: approver sign off" +AUD=customer doc "$T/ap/a3.md" accepted 'approval: ai\napproved_by: "ai:model-x"\napproved_at: 2026-09-30\n' +V "$T/ap/a3.md" && no "approval: a customer doc never takes an ai: approver" || ok "approval: a customer doc never takes an ai: approver" +AUD=customer doc "$T/ap/a4.md" review 'approval: ai\n' +V "$T/ap/a4.md" && no "approval: 'approval: ai' on a customer doc is an error" || ok "approval: 'approval: ai' on a customer doc is an error" +TYPE=verbatim-block doc "$T/ap/a5.md" review 'approval: ai\n' +V "$T/ap/a5.md" && no "approval: 'approval: ai' on a verbatim-block is an error" || ok "approval: 'approval: ai' on a verbatim-block is an error" +doc "$T/ap/a6.md" accepted 'approved_by: RPA\napproved_at: 2026-09-30\n' +V "$T/ap/a6.md" && ok "approval: a human approver passes at the default level" || no "approval: a human approver passes at the default level" +doc "$T/ap/a7.md" review 'approval: maybe\n' +V "$T/ap/a7.md" && no "approval: an unknown level fails the enum" || ok "approval: an unknown level fails the enum" +printf '%s' "$BASE_LOCAL" > "$T/schema.local.yaml"; printf 'status_rules:\n accepted_requires: [review_by]\n' >> "$T/schema.local.yaml" +RB= doc "$T/ap/a8.md" accepted 'approved_by: RPA\napproved_at: 2026-09-30\n' +V "$T/ap/a8.md" && no "accepted_requires: a missing field fails" || ok "accepted_requires: a missing field fails" +V "$T/ap/a6.md" && ok "accepted_requires: a doc with the field passes" || no "accepted_requires: a doc with the field passes" +printf 'meta: {profile: smoke}\ngate:\n enabled: true\n served_status: published\nfields:\n status:\n enum: [draft, review, published, superseded, obsolete]\n' > "$T/schema.local.yaml" +doc "$T/ap/a9.md" published 'approved_by: "ai:model-x"\napproved_at: 2026-09-30\n' +km validate --root "$T" "$T/ap/a9.md" 2>&1 | grep -q "an AI approver needs" && ok "approval: the rule follows a renamed served status" || no "approval: the rule follows a renamed served status" +printf '%s' "$BASE_LOCAL" > "$T/schema.local.yaml"; printf 'status_rules:\n acepted_require: [owner]\n' >> "$T/schema.local.yaml" +km validate --root "$T" "$T/ap/a6.md" 2>&1 | grep -q "must be _" && ok "status_rules: a malformed key fails fast" || no "status_rules: a malformed key fails fast" +printf '%s' "$BASE_LOCAL" > "$T/schema.local.yaml"; printf 'status_rules:\n acepted_requires: [owner]\n' >> "$T/schema.local.yaml" +km validate --root "$T" "$T/ap/a6.md" 2>&1 | grep -q "with a status from the schema" && ok "status_rules: a misspelled status fails fast" || no "status_rules: a misspelled status fails fast" +printf '%s' "$BASE_LOCAL" > "$T/schema.local.yaml"; printf 'status_rules:\n accepted_requires: [review_by]\n' >> "$T/schema.local.yaml" + +# km approve: sets status/approved_by/approved_at, refuses what the rule forbids, restores on a failed gate +doc "$T/ap/p1.md" review 'approval: ai\n' +km approve --root "$T" "$T/ap/p1.md" --by ai:model-x --at 2026-09-30 >/dev/null 2>&1 +{ grep -q '^status: accepted$' "$T/ap/p1.md" && grep -q '^approved_by: "ai:model-x"$' "$T/ap/p1.md" && grep -q '^approved_at: 2026-09-30$' "$T/ap/p1.md" && V "$T/ap/p1.md"; } \ + && ok "approve: an ai doc is signed off by ai:" || no "approve: an ai doc is signed off by ai:" +doc "$T/ap/p2.md" review ''; cp "$T/ap/p2.md" "$T/p2.bak" +out="$(km approve --root "$T" "$T/ap/p2.md" --by ai:model-x 2>&1)" +out_has "needs 'approval: ai'" && ! out_has "km validate fails" && cmp -s "$T/ap/p2.md" "$T/p2.bak" \ + && ok "approve: ai: refused before writing at the default level" || no "approve: ai: refused before writing at the default level" +AUD=customer doc "$T/ap/p3.md" review '' +km approve --root "$T" "$T/ap/p3.md" --by ai:model-x 2>&1 | grep -q "needs a human approver" \ + && ok "approve: ai: refused on a customer doc" || no "approve: ai: refused on a customer doc" +km approve --root "$T" "$T/ap/p2.md" --by RPA >/dev/null 2>&1 && grep -q '^approved_by: "RPA"$' "$T/ap/p2.md" \ + && ok "approve: a human signs off a default doc" || no "approve: a human signs off a default doc" +doc "$T/ap/p4.md" draft '' +km approve --root "$T" "$T/ap/p4.md" --by RPA 2>&1 | grep -q "only a review doc" && grep -q '^status: draft$' "$T/ap/p4.md" \ + && ok "approve: a draft is refused" || no "approve: a draft is refused" +RB= doc "$T/ap/p5.md" review ''; cp "$T/ap/p5.md" "$T/p5.bak" +km approve --root "$T" "$T/ap/p5.md" --by RPA 2>&1 | grep -q "km validate fails" && cmp -s "$T/ap/p5.md" "$T/p5.bak" \ + && ok "approve: a doc failing the gate after the change is restored" || no "approve: a doc failing the gate after the change is restored" +km approve --root "$T" "$T/ap/p2.md" --by RPA --at 30.09.2026 >/dev/null 2>&1; [ $? -eq 2 ] && ok "approve: a bad --at is refused" || no "approve: a bad --at is refused" +RB=2020-01-01 doc "$T/ap/p6.md" review '' +km approve --root "$T" "$T/ap/p6.md" --by RPA --at 2026-09-30 2>&1 | grep -q "review_by 2020-01-01 has passed" && grep -q '^status: review$' "$T/ap/p6.md" \ + && ok "approve: a past review_by is refused" || no "approve: a past review_by is refused" +km approve --root "$T" "$T/ap/p6.md" --by RPA --at 2026-09-30 --review-by 2027-06-30 >/dev/null 2>&1 && grep -q '^review_by: 2027-06-30$' "$T/ap/p6.md" && grep -q '^status: accepted$' "$T/ap/p6.md" \ + && ok "approve: --review-by renews the date" || no "approve: --review-by renews the date" +RB=2026-06-01 doc "$T/ap/p8.md" review '' +km approve --root "$T" "$T/ap/p8.md" --by RPA --at 2026-01-01 2>&1 | grep -q "has passed" && grep -q '^status: review$' "$T/ap/p8.md" \ + && ok "approve: a backdated --at does not get round a past review_by" || no "approve: a backdated --at does not get round a past review_by" +km approve --root "$T" "$T/ap/p8.md" --by RPA --review-by 2020-01-01 >/dev/null 2>&1; [ $? -eq 2 ] && grep -q '^status: review$' "$T/ap/p8.md" \ + && ok "approve: a past --review-by is refused" || no "approve: a past --review-by is refused" +RB=soon doc "$T/ap/p10.md" review '' +km approve --root "$T" "$T/ap/p10.md" --by RPA 2>&1 | grep -q "is not a YYYY-MM-DD date" && grep -q '^status: review$' "$T/ap/p10.md" \ + && ok "approve: an unreadable review_by is refused" || no "approve: an unreadable review_by is refused" +doc "$T/ap/p9.md" review ''; python3 -c "import sys;p=sys.argv[1];b=open(p,'rb').read();open(p,'wb').write(b.replace(b'\n',b'\r\n'))" "$T/ap/p9.md" +km approve --root "$T" "$T/ap/p9.md" --by RPA >/dev/null 2>&1 && grep -q '^status: accepted' "$T/ap/p9.md" \ + && ok "approve: a CRLF doc is approved" || no "approve: a CRLF doc is approved" +RB= doc "$T/ap/p7.md" review ''; { printf '\357\273\277'; cat "$T/ap/p7.md"; } > "$T/p7.tmp" && mv "$T/p7.tmp" "$T/ap/p7.md"; cp "$T/ap/p7.md" "$T/p7.bak" +km approve --root "$T" "$T/ap/p7.md" --by RPA 2>&1 | grep -q "km validate fails" && cmp -s "$T/ap/p7.md" "$T/p7.bak" \ + && ok "approve: a refused doc keeps its bytes, BOM included" || no "approve: a refused doc keeps its bytes, BOM included" +printf '%s' "$BASE_LOCAL" > "$T/schema.local.yaml" + +# promote drops reviewed_by (new doc and --replace); a cross-repo promote also drops approval +printf -- '---\ntype: note\ntitle: R\ntimestamp: 2026-09-30\nauthor: X\nstatus: draft\ntags: [t]\napproval: ai\nreviewed_by: ["model-x 2026-09-30 (PR #1)"]\n---\nbody\n' > "$T/inbox/rv.md" +km promote --root "$T" rv-topic "$T/inbox/rv.md" --folder ap >/dev/null 2>&1 +{ [ -f "$T/ap/rv-topic.md" ] && ! grep -q '^reviewed_by:' "$T/ap/rv-topic.md" && grep -q '^approval: ai' "$T/ap/rv-topic.md"; } \ + && ok "promote: drops reviewed_by, keeps approval in the same brain" || no "promote: drops reviewed_by, keeps approval in the same brain" +doc "$T/ap/rp.md" accepted 'approved_by: RPA\napproved_at: 2026-09-30\nreviewed_by: ["model-x 2026-09-29 (PR #1)"]\n' +printf -- '---\ntype: concept\ntitle: t\ntimestamp: 2026-09-30\nauthor: X\nstatus: draft\ntags: [t]\n---\nnew text\n' > "$T/inbox/rp.md" +km promote --root "$T" rp "$T/inbox/rp.md" --replace >/dev/null 2>&1 +{ grep -q '^status: review' "$T/ap/rp.md" && ! grep -q '^reviewed_by:' "$T/ap/rp.md"; } \ + && ok "promote --replace: drops reviewed_by with the approval" || no "promote --replace: drops reviewed_by with the approval" +X="$(mktemp -d)"; printf -- '---\ntype: note\ntitle: C\ntimestamp: 2026-09-30\nauthor: X\nstatus: draft\ntags: [t]\napproval: ai\n---\nbody\n' > "$X/cr.md" +km promote --root "$T" cr-topic "$X/cr.md" --folder ap --author X >/dev/null 2>&1 +{ [ -f "$T/ap/cr-topic.md" ] && ! grep -q '^approval:' "$T/ap/cr-topic.md"; } \ + && ok "promote cross-repo: the target decides the approval level" || no "promote cross-repo: the target decides the approval level" +rm -rf "$X" "$T/ap" "$T/p2.bak" "$T/p5.bak" "$T/p7.bak" + summary smoke || exit 1