From f4101cdd9a9c8d507894bcd23235c83f63186c92 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 20 Jul 2026 02:07:36 +0200 Subject: [PATCH] CUST-WP-0060 T01-T03: canon ratified active, terminology v0.2, schemas + validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - work-record-types_v0.1.md + autonomy-lanes_v0.1.md: status active (founder ratification 2026-07-20, hub decision f4640f9e); registry grandfathers legacy task-id variants (-T1, -LEGACY-Txx) - workplan-terminology-fleet: v0.2 addendum — work-record umbrella, kind vocabulary, suggestion as legacy bridge name (T02) - canon/standards/schemas/work-records/: spine + intake/decision/ engagement JSON schemas (T03); conditional spine requirements, historical grace for terminal records - tools/validate_work_records.py: registry+schema validator for CI (T04); jsonschema with inlined spine refs, fallback checks without; proof runs clean: the-custodian 384 records, binky-control 45 Co-Authored-By: Claude Fable 5 --- canon/standards/autonomy-lanes_v0.1.md | 4 +- .../schemas/work-records/decision.schema.json | 43 ++++ .../work-records/engagement.schema.json | 29 +++ .../schemas/work-records/intake.schema.json | 44 ++++ .../schemas/work-records/spine.schema.json | 39 ++++ canon/standards/work-record-types.yaml | 9 +- canon/standards/work-record-types_v0.1.md | 4 +- .../workplan-terminology-fleet_v0.1.md | 20 +- tools/validate_work_records.py | 193 ++++++++++++++++++ 9 files changed, 378 insertions(+), 7 deletions(-) create mode 100644 canon/standards/schemas/work-records/decision.schema.json create mode 100644 canon/standards/schemas/work-records/engagement.schema.json create mode 100644 canon/standards/schemas/work-records/intake.schema.json create mode 100644 canon/standards/schemas/work-records/spine.schema.json create mode 100644 tools/validate_work_records.py diff --git a/canon/standards/autonomy-lanes_v0.1.md b/canon/standards/autonomy-lanes_v0.1.md index c594953..14524c6 100644 --- a/canon/standards/autonomy-lanes_v0.1.md +++ b/canon/standards/autonomy-lanes_v0.1.md @@ -3,7 +3,7 @@ id: canon-autonomy-lanes type: standard title: "Autonomy Lanes (Fleet) v0.1" domain: custodian -status: draft +status: active version: "0.1" created: "2026-07-20" updated: "2026-07-20" @@ -16,7 +16,7 @@ origin: binky-control/AutonomyPolicy.md (ratified for Binky 2026-07-16, DEC-2026 # Autonomy Lanes (Fleet) v0.1 -> Draft pending founder ratification (CUST-WP-0060-T01). Promotes the +> Ratified by founder 2026-07-20 (CUST-WP-0060-T01, hub decision f4640f9e). Promotes the > binky-control AutonomyPolicy lane model to fleet canon, unchanged in > substance. binky-control's `AutonomyPolicy.md` remains the company-level > policy instance; this standard makes the lane vocabulary and enforcement diff --git a/canon/standards/schemas/work-records/decision.schema.json b/canon/standards/schemas/work-records/decision.schema.json new file mode 100644 index 0000000..aa98a3c --- /dev/null +++ b/canon/standards/schemas/work-records/decision.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://canon.coulomb.social/schemas/work-records/decision.schema.json", + "title": "Decision", + "description": "Authored YAML block for kind=decision (approval package + resolution). Id: {PREFIX}-DEC-YYYY-NNN; legacy DEC-YYYY-NNN grandfathered. Lifecycle: prepared -> resolved(approved|rejected|revised|deferred). 'deferred' is also accepted as a stored status (legacy binky template).", + "type": "object", + "required": ["id", "title", "status", "lane"], + "properties": { + "id": { "type": "string", "pattern": "^([A-Z]+-)?DEC-[0-9]{4}-[0-9]{3}$" }, + "kind": { "const": "decision" }, + "title": { "type": "string", "minLength": 5 }, + "status": { "enum": ["prepared", "resolved", "deferred"] }, + "outcome": { + "type": "string", + "description": "Free text beginning with approved|rejected|revised|deferred; qualifiers allowed (e.g. 'approved with edits')." + }, + "lane": { "$ref": "spine.schema.json#/$defs/lane" }, + "owner": { "$ref": "spine.schema.json#/$defs/owner" }, + "tags": { "$ref": "spine.schema.json#/$defs/tags" }, + "created_at": { "$ref": "spine.schema.json#/$defs/date" }, + "needed_by": { "type": "string" }, + "attention_cost": { "type": "string" }, + "agent_recommendation": { "type": "string" }, + "evidence": { "type": "array" }, + "options": { "type": "array" }, + "fallback_if_no_response": { "type": "string" }, + "resolved_at": { "$ref": "spine.schema.json#/$defs/date" }, + "resolved_by": { "type": "string" }, + "consequence": { "type": "string" }, + "state_hub_decision_id": { "$ref": "spine.schema.json#/$defs/uuid" } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "resolved" } }, "required": ["status"] }, + "then": { "required": ["outcome", "resolved_at", "resolved_by"] } + }, + { + "if": { "properties": { "status": { "const": "prepared" } }, "required": ["status"] }, + "then": { "required": ["agent_recommendation", "fallback_if_no_response"] } + } + ], + "additionalProperties": true +} diff --git a/canon/standards/schemas/work-records/engagement.schema.json b/canon/standards/schemas/work-records/engagement.schema.json new file mode 100644 index 0000000..978879f --- /dev/null +++ b/canon/standards/schemas/work-records/engagement.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://canon.coulomb.social/schemas/work-records/engagement.schema.json", + "title": "Engagement", + "description": "Authored YAML block for kind=engagement (scheduled external interaction). Id: {PREFIX}-ENG-YYYY-NNN; legacy OH-YYYY-NNN grandfathered. Lifecycle: queued -> prepared -> done.", + "type": "object", + "required": ["id", "title", "status"], + "properties": { + "id": { "type": "string", "pattern": "^([A-Z]+-ENG-[0-9]{4}-[0-9]{3}|OH-[0-9]{4}-[0-9]{3})$" }, + "kind": { "const": "engagement" }, + "title": { "type": "string", "minLength": 5 }, + "status": { "enum": ["queued", "prepared", "done"] }, + "counterparty": { "type": "string" }, + "lane": { "$ref": "spine.schema.json#/$defs/lane", "description": "Defaults to orange for engagements; explicit value wins." }, + "owner": { "$ref": "spine.schema.json#/$defs/owner" }, + "tags": { "$ref": "spine.schema.json#/$defs/tags" }, + "prepared_material": { "type": "array" }, + "deadline_pressure": { "type": "string" }, + "origin": { "type": "string" }, + "state_hub_engagement_id": { "$ref": "spine.schema.json#/$defs/uuid" } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "prepared" } }, "required": ["status"] }, + "then": { "required": ["prepared_material", "counterparty"] } + } + ], + "additionalProperties": true +} diff --git a/canon/standards/schemas/work-records/intake.schema.json b/canon/standards/schemas/work-records/intake.schema.json new file mode 100644 index 0000000..8c6a3aa --- /dev/null +++ b/canon/standards/schemas/work-records/intake.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://canon.coulomb.social/schemas/work-records/intake.schema.json", + "title": "Intake Item", + "description": "Authored YAML block for kind=intake. Id: {PREFIX}-IN-NNNN; legacy AWQ-NNN grandfathered. Lifecycle: open -> vetted -> routed -> closed(promoted|declined|absorbed).", + "type": "object", + "required": ["id", "title"], + "properties": { + "id": { "type": "string", "pattern": "^([A-Z]+-IN-[0-9]{4}|AWQ-[0-9]{3})$" }, + "kind": { "const": "intake" }, + "title": { "type": "string", "minLength": 5 }, + "status": { "enum": ["open", "vetted", "routed", "closed"] }, + "outcome": { "enum": ["promoted", "declined", "absorbed"] }, + "lane": { "$ref": "spine.schema.json#/$defs/lane" }, + "owner": { "$ref": "spine.schema.json#/$defs/owner" }, + "tags": { "$ref": "spine.schema.json#/$defs/tags" }, + "priority": { "enum": ["high", "medium", "low"] }, + "pulls_from": { "type": "array" }, + "promoted_to": { "type": "string", "description": "Canonical id of the record this intake was promoted to." }, + "origin": { "type": "string" }, + "notes": { "type": "string" }, + "state_hub_intake_id": { "$ref": "spine.schema.json#/$defs/uuid" } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "closed" } }, "required": ["status"] }, + "then": { "required": ["outcome"] } + }, + { + "if": { "properties": { "outcome": { "const": "promoted" } }, "required": ["outcome"] }, + "then": { "required": ["promoted_to"] } + }, + { + "if": { + "not": { + "properties": { "status": { "const": "closed" } }, + "required": ["status"] + } + }, + "then": { "required": ["lane"] } + } + ], + "additionalProperties": true +} diff --git a/canon/standards/schemas/work-records/spine.schema.json b/canon/standards/schemas/work-records/spine.schema.json new file mode 100644 index 0000000..76f42d9 --- /dev/null +++ b/canon/standards/schemas/work-records/spine.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://canon.coulomb.social/schemas/work-records/spine.schema.json", + "title": "Work Record Spine (shared definitions)", + "description": "Shared spine per canon/standards/work-record-types_v0.1.md. Kind schemas $ref these definitions. additionalProperties stays open: kind-specific fields extend the spine, never replace it.", + "$defs": { + "lane": { + "enum": ["green", "blue", "yellow", "orange", "red"] + }, + "uuid": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "date": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "tags": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z0-9-]+$" } + }, + "owner": { + "type": "string", + "minLength": 2 + }, + "spineOpen": { + "type": "object", + "description": "Spine requirements for records in a non-terminal status.", + "required": ["id", "lane"], + "properties": { + "lane": { "$ref": "#/$defs/lane" }, + "owner": { "$ref": "#/$defs/owner" }, + "tags": { "$ref": "#/$defs/tags" }, + "uuid": { "$ref": "#/$defs/uuid" }, + "created_at": { "$ref": "#/$defs/date" } + } + } + } +} diff --git a/canon/standards/work-record-types.yaml b/canon/standards/work-record-types.yaml index 982f95e..8831637 100644 --- a/canon/standards/work-record-types.yaml +++ b/canon/standards/work-record-types.yaml @@ -2,7 +2,7 @@ # CI and fix-consistency validate id patterns against this file. # New kinds/prefixes land here by canon PR only. version: "0.1" -status: draft +status: active kinds: - kind: workplan id_patterns: @@ -13,6 +13,13 @@ kinds: - "^[A-Z]+-WP-[0-9]{4}-T[0-9]{2,3}$" - "^ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}-T[0-9]{2}$" lifecycle: [wait, todo, progress, done, cancel] + legacy_patterns: + - pattern: "^[A-Z]+-WP-[0-9]{4}-T[0-9]$" + source: early single-digit task suffixes (pre task-block convention) + grandfathered: true + - pattern: "^[A-Z]+-WP-[0-9]{4}-LEGACY-T[0-9]{2}$" + source: archived-workplan legacy task renumbering + grandfathered: true - kind: intake id_patterns: - "^[A-Z]+-IN-[0-9]{4}$" diff --git a/canon/standards/work-record-types_v0.1.md b/canon/standards/work-record-types_v0.1.md index ab2f546..fce2ed6 100644 --- a/canon/standards/work-record-types_v0.1.md +++ b/canon/standards/work-record-types_v0.1.md @@ -3,7 +3,7 @@ id: canon-work-record-types type: standard title: "Work Record Types & Identity (Fleet) v0.1" domain: custodian -status: draft +status: active version: "0.1" created: "2026-07-20" updated: "2026-07-20" @@ -16,7 +16,7 @@ machine_copy: canon/standards/work-record-types.yaml # Work Record Types & Identity (Fleet) v0.1 -> Draft pending founder ratification (CUST-WP-0060-T01). Source: +> Ratified by founder 2026-07-20 (CUST-WP-0060-T01, hub decision f4640f9e). Source: > `research/WorkOrchestrationArchitectureDraft.md` v0.2 (founder-reviewed > 2026-07-20). Extends — does not replace — > `workplan-terminology-fleet_v0.1.md` and ADR-001/ADR-005. diff --git a/canon/standards/workplan-terminology-fleet_v0.1.md b/canon/standards/workplan-terminology-fleet_v0.1.md index f5d2a4b..7cbc75a 100644 --- a/canon/standards/workplan-terminology-fleet_v0.1.md +++ b/canon/standards/workplan-terminology-fleet_v0.1.md @@ -4,14 +4,15 @@ type: standard title: "Workplan Terminology (Fleet) v0.1" domain: custodian status: active -version: "0.1" +version: "0.2" created: "2026-07-08" -updated: "2026-07-08" +updated: "2026-07-20" scope: fleet related_workplans: - CUST-WP-0055 - STATE-WP-0054 - STATE-WP-0069 + - CUST-WP-0060 supersedes: none --- @@ -43,6 +44,21 @@ Authoritative interface matrix for State Hub: | Hub index row | **workplan** (product term) | DB entity keyed by UUID; may still use legacy column names internally | | Task within a workplan | **task** | `id: {WP}-TNN` in workplan file blocks | +## Work-record umbrella (v0.2 addendum, CUST-WP-0060) + +The workplan and task definitions above are unchanged. Since v0.2 they are +two *kinds* of the fleet umbrella term **work record** — any identified, +lifecycle-bearing coordination artefact. The full kind registry (workplan, +task, **intake item**, **decision**, **engagement**, **register entry**), +the conventional spine, identity layering (UUIDv7 internal, canonical names +external), and grandfathered legacy id schemes (`AWQ-`, `DEC-`, `OH-`) are +normative in `work-record-types_v0.1.md` (+ machine registry +`work-record-types.yaml`). Autonomy-lane vocabulary is normative in +`autonomy-lanes_v0.1.md`. "Suggestion" is a legacy bridge name for the +intake kind (state-hub suggestions table: read-only legacy pending +close-out). This file keeps its `_v0.1` name for reference stability; the +frontmatter `version` field is authoritative. + Use **workplan** in human-facing prose: `SCOPE.md`, `AGENTS.md`, `INTENT.md`, README, workplan bodies, operator runbooks, and agent session guidance. diff --git a/tools/validate_work_records.py b/tools/validate_work_records.py new file mode 100644 index 0000000..dadb876 --- /dev/null +++ b/tools/validate_work_records.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Validate work-record YAML blocks against the canon kind registry. + +Canon: canon/standards/work-record-types_v0.1.md (+ work-record-types.yaml) +Schemas: canon/standards/schemas/work-records/*.schema.json + +Scans Markdown files for fenced ```yaml and ```task blocks. A block is a +work record iff it parses to a mapping whose `id` matches a registered (or +grandfathered legacy) id pattern. Records are validated against their +kind's JSON schema; ``task`` blocks get pattern + lifecycle checks (they +are already parsed authoritatively by state-hub). + +Blocks with an id-like string that matches *no* registered pattern produce +a warning (CI-level sidetrack hint; the authoritative detector is +fix-consistency C-25). + +Usage: + validate_work_records.py [--repo PATH] [--canon PATH] [--strict] + +--strict turns sidetrack warnings into errors. Exit 1 on any error. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +import yaml + +FENCE_RE = re.compile(r"```(yaml|task)\n(.*?)```", re.S) +ID_LIKE_RE = re.compile(r"^[A-Z][A-Z0-9]*(-[A-Z0-9]+)+-?[0-9]*$") +SKIP_DIRS = {".git", "node_modules", ".venv", "history", "agents_backup"} +TERMINAL = {"closed", "resolved", "done", "cancel", "finished", "archived"} + +# task blocks are validated inline (state-hub owns their full parsing) +TASK_ID_RE = re.compile(r"^([A-Z]+-WP-[0-9]{4}-(LEGACY-)?T[0-9]{1,3}" + r"|ADHOC-[0-9]{4}-[0-9]{2}-[0-9]{2}-T[0-9]{2})$") +TASK_STATUS = {"wait", "todo", "progress", "done", "cancel", + "in_progress", "blocked"} # aliases per migration window + + +def load_registry(canon: Path) -> list[dict]: + reg = yaml.safe_load( + (canon / "canon/standards/work-record-types.yaml").read_text()) + kinds = [] + for k in reg["kinds"]: + pats = [re.compile(p) for p in k.get("id_patterns", [])] + pats += [re.compile(lp["pattern"]) + for lp in k.get("legacy_patterns", [])] + kinds.append({"kind": k["kind"], "patterns": pats}) + return kinds + + +def load_validators(canon: Path): + """Return {kind: callable(block) -> list[str]} using jsonschema if + available, else minimal fallback checks.""" + schema_dir = canon / "canon/standards/schemas/work-records" + schemas = {p.stem.replace(".schema", ""): json.loads(p.read_text()) + for p in schema_dir.glob("*.schema.json")} + try: + import jsonschema + + spine_defs = schemas["spine"]["$defs"] + + def inline(node): + """Replace spine.schema.json#/$defs/X refs with the def body + so no ref registry is needed (works on any jsonschema>=4).""" + if isinstance(node, dict): + ref = node.get("$ref", "") + if ref.startswith("spine.schema.json#/$defs/"): + merged = dict(spine_defs[ref.rsplit("/", 1)[1]]) + merged.update({k: v for k, v in node.items() + if k != "$ref"}) + return inline(merged) + return {k: inline(v) for k, v in node.items()} + if isinstance(node, list): + return [inline(v) for v in node] + return node + + def make(kind): + validator = jsonschema.Draft202012Validator( + inline(schemas[kind])) + + def check(block): + return [f"{e.json_path}: {e.message}" + for e in validator.iter_errors(block)] + return check + + return {k: make(k) for k in schemas if k != "spine"} + except ImportError: + def fallback(kind): + def check(block): + errs = [] + if not block.get("title") and kind != "task": + errs.append("missing title (fallback check)") + if block.get("status") not in TERMINAL and \ + "lane" not in block and kind == "intake": + errs.append("open record missing lane (fallback check)") + return errs + return check + print("note: jsonschema not installed — minimal fallback checks only", + file=sys.stderr) + return {k: fallback(k) for k in schemas if k != "spine"} + + +def classify(record_id: str, kinds: list[dict]) -> str | None: + for k in kinds: + if any(p.match(record_id) for p in k["patterns"]): + return k["kind"] + return None + + +def iter_blocks(md: Path, kinds: list[dict]): + for fence, body in FENCE_RE.findall(md.read_text(errors="replace")): + try: + docs = list(yaml.safe_load_all(body)) + except yaml.YAMLError as exc: + # only an error if the raw text plausibly holds a registered id + raw_ids = re.findall(r"^id:\s*(\S+)", body, re.M) + if any(classify(r.strip("\"'"), kinds) for r in raw_ids): + yield fence, None, f"unparseable YAML block: {exc}" + continue + for data in docs: + if isinstance(data, dict): + yield fence, data, None + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--repo", default=".", type=Path) + ap.add_argument("--canon", default=None, type=Path, + help="the-custodian checkout (default: script's repo)") + ap.add_argument("--strict", action="store_true") + args = ap.parse_args() + canon = args.canon or Path(__file__).resolve().parents[1] + + kinds = load_registry(canon) + validators = load_validators(canon) + errors, warnings, checked = [], [], 0 + + md_files = [p for p in sorted(args.repo.rglob("*.md")) + if not any(part in SKIP_DIRS for part in p.parts)] + for md in md_files: + rel = md.relative_to(args.repo) + for fence, block, parse_err in iter_blocks(md, kinds): + if parse_err: + errors.append(f"{rel}: {parse_err}") + continue + rid = block.get("id") + if not isinstance(rid, str): + continue + if "NNN" in rid or rid.endswith("-TNN"): + continue # template placeholder, not a record + kind = classify(rid, kinds) + if kind is None: + if ID_LIKE_RE.match(rid): + warnings.append( + f"{rel}: '{rid}' looks like a work-record id but " + f"matches no registered pattern — unregistered " + f"species are sidetracks (work-record-types_v0.1)") + continue + checked += 1 + if fence == "task" or kind == "task": + if not TASK_ID_RE.match(rid): + errors.append(f"{rel}: task id '{rid}' malformed") + st = block.get("status") + if st is not None and st not in TASK_STATUS: + errors.append(f"{rel}: {rid}: bad task status '{st}'") + continue + if kind in validators: + for msg in validators[kind](block): + # historical grace: terminal records only get id/status + # structural errors, not spine completeness + if block.get("status") in TERMINAL and "required" in msg: + continue + errors.append(f"{rel}: {rid}: {msg}") + + for w in warnings: + print(f"WARN {w}") + for e in errors: + print(f"ERROR {e}") + print(f"work-records: {checked} checked, " + f"{len(errors)} errors, {len(warnings)} warnings") + if errors or (args.strict and warnings): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())