From f59c5ea994c894a1fc2d1f771a1b557065a721fd Mon Sep 17 00:00:00 2001 From: chaoz23 Date: Thu, 20 Aug 2026 11:11:13 -0700 Subject: [PATCH] feat: make the conformance gate class-aware; FAMILY.md v2.1 v2 introduced member classes but stated them only in FAMILY.md's members table. The gate had no way to read them, so the moment v2 merged it began reporting MISSING_PIPE against table-kit -- a transport member that v2 explicitly exempts. The gate contradicted the contract it enforces. That is the same failure as #84 arriving from the other side: a gate that flags a correct state teaches you to bypass it. Members now declare their own class in tool.json: "family_class": "verdict" | "transport" | "adjacent" The declaration lives in the artifact rather than in FAMILY.md's prose. The alternative -- parsing the members table out of the contract -- would couple the gate to markdown formatting, and this parser has already been the defect twice for exactly that kind of brittleness. Gate changes: - CLASS_SURFACES gates --pipe (and MCP, still unprobed) to the verdict class - CLAUSE_1_CLASSES skips honest-lane and stream checks for the adjacent class, which clause 1 does not bind - an undeclared class emits UNDECLARED_CLASS and names the surfaces it did not check, rather than guessing: assuming "verdict" invents breaches for a transport, assuming "transport" hides real ones for a verdict tool - family_class is reported in output tool.json is generated here, so the field is added in gen_tool_json.py and the card regenerated; tests/test_metadata_fresh.py passes. Verified in all three states: srdcheck (verdict) PASS; table-kit undeclared -> UNDECLARED_CLASS + SCHEMA_NOT_FLAG with --pipe correctly unevaluated; table-kit declared transport -> MISSING_PIPE gone, SCHEMA_NOT_FLAG and HONEST_LANE_OVERLOAD correctly retained. Co-Authored-By: Claude Opus 5 --- FAMILY.md | 15 ++++++++- scripts/family_conformance.py | 61 ++++++++++++++++++++++++++++++----- scripts/gen_tool_json.py | 4 +++ tool.json | 1 + 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/FAMILY.md b/FAMILY.md index a1f758a..4dddcf0 100644 --- a/FAMILY.md +++ b/FAMILY.md @@ -1,4 +1,4 @@ -# The check family — shared contract (v2) +# The check family — shared contract (v2.1) Canonical copy: this file, in [srdcheck](https://github.com/chaoz23/srdcheck) (the family's reference implementation). Sibling repos link here. @@ -76,6 +76,14 @@ Members differ by what they produce, and the contract binds them accordingly. | CLI `--pipe` | ✅ | — | — | | MCP server | ✅ | — | — | + **A member declares its own class** in `tool.json` as + `"family_class": "verdict" | "transport" | "adjacent"`. The declaration lives + in the artifact, not in this file's prose, so tooling reads it without + parsing markdown and a member owns its own classification. A member that + declares no class is not assumed to have one: the conformance gate reports + the omission and states which class-gated surfaces it therefore did not + check, rather than guessing. + `--pipe` is one-query-in / one-verdict-out. It binds the verdict class because that shape is what a verdict tool *is*; a transport that records a session has no such shape, and requiring it there would produce a surface with no honest @@ -114,6 +122,11 @@ repos pin by link, not by copy. ### Changelog +- **v2.1** — Members declare their class machine-readably as `family_class` in + `tool.json`. v2 introduced classes but left them stated only in this file's + members table, so the conformance gate had no way to apply clause 7 per class + and reported `--pipe` as missing on a transport member that v2 exempts. An + undeclared class is reported as such, never guessed. - **v2** — Introduced member classes (verdict / transport / adjacent) and stated clause 7 per class, so `--pipe` and an MCP server bind the tools whose shape they fit. Corrected the preamble's claim to be wholly descriptive. Widened diff --git a/scripts/family_conformance.py b/scripts/family_conformance.py index ad27e6e..1caaa41 100755 --- a/scripts/family_conformance.py +++ b/scripts/family_conformance.py @@ -58,6 +58,19 @@ "tablekit": ["report"], } +# FAMILY.md v2 clause 7 binds agent-first surfaces per member class. --pipe is +# one-query-in/one-verdict-out and an MCP server exposes callable tools; neither +# shape means anything for a session ledger, so both bind the verdict class only. +CLASS_SURFACES = { + "verdict": {"skill_md", "tool_json", "llms_txt", "schema_flag", "pipe", "mcp_server"}, + "transport": {"skill_md", "tool_json", "llms_txt", "schema_flag"}, + "adjacent": {"skill_md", "tool_json", "llms_txt", "schema_flag"}, +} +# Clause 1 (exit codes are the verdict; the honest lane) binds the verdict class +# fully, and the transport class where it emits a verdict. It does not bind the +# adjacent class: those tools are in different domains and predate the lane. +CLAUSE_1_CLASSES = {"verdict", "transport"} + HONEST_MARKERS = ( "honest", "cannot", "can't", "do not retry", "don't retry", "never retry", "route it to a human", "to a human", "up to the dm", "discretion", "ambiguous", @@ -80,6 +93,21 @@ def die(msg: str) -> None: # ---------------------------------------------------------------- discovery +def read_family_class(repo: Path) -> str | None: + """Read the member class the repo declares for itself. Reading it from the + artifact keeps this gate off FAMILY.md's prose, which would couple it to + markdown formatting -- brittleness that has already bitten this parser.""" + tool_json = repo / "tool.json" + if not tool_json.is_file(): + return None + try: + declared = json.loads(tool_json.read_text()) + except json.JSONDecodeError: + return None + value = declared.get("family_class") or declared.get("class") + return value if value in CLASS_SURFACES else None + + def _project_scripts(pyproject: Path) -> dict[str, str]: """Read [project.scripts]. Falls back to a regex on Python 3.10, which has no tomllib -- the only table needed is a flat name = "module:attr" mapping.""" @@ -251,6 +279,8 @@ def audit(repo: Path) -> dict: tool, argv = discover_entry(repo) parsed = parse_skill(skill) + family_class = read_family_class(repo) + surfaces = CLASS_SURFACES.get(family_class, set()) findings: list[dict] = [] def add(rule, severity, message, evidence): @@ -270,12 +300,24 @@ def add(rule, severity, message, evidence): envelope_p = run(repo, argv, probe_args) if probe_args else None # --- clause 7: the flags are codified by name ------------------------------- - pipe_p = run(repo, argv, ["--pipe"], stdin="") - if "unrecognized arguments: --pipe" in pipe_p["stderr"] or \ - "unknown option" in pipe_p["stderr"] or "unknown command" in pipe_p["stderr"]: - add("MISSING_PIPE", "high", - "FAMILY.md clause 7 codifies `--pipe` by name; the CLI rejects it.", - f"`{tool} --pipe` -> exit {pipe_p['code']}: {oneline(pipe_p['stderr'])}") + if family_class is None: + add("UNDECLARED_CLASS", "medium", + "tool.json declares no `family_class`, so FAMILY.md v2's per-class " + "clause-7 surfaces (`--pipe`, MCP server) were NOT evaluated. Add " + '`"family_class": "verdict" | "transport" | "adjacent"`. Reporting ' + "this rather than assuming a class: guessing `verdict` would invent " + "breaches for a transport, and guessing `transport` would hide real " + "ones for a verdict tool.", + f"{repo / 'tool.json'}") + + if "pipe" in surfaces: + pipe_p = run(repo, argv, ["--pipe"], stdin="") + if "unrecognized arguments: --pipe" in pipe_p["stderr"] or \ + "unknown option" in pipe_p["stderr"] or "unknown command" in pipe_p["stderr"]: + add("MISSING_PIPE", "high", + f"FAMILY.md clause 7 codifies `--pipe` by name and binds it to the " + f"{family_class} class; the CLI rejects it.", + f"`{tool} --pipe` -> exit {pipe_p['code']}: {oneline(pipe_p['stderr'])}") if schema_p["code"] != 0: add("SCHEMA_NOT_FLAG", "high", @@ -308,7 +350,8 @@ def add(rule, severity, message, evidence): f"{sorted(parsed['documented']) or 'nothing'}") # --- clause 1: the honest lane must not be overloaded with usage errors ------ - bad_code = badflag_p["code"] + # Skipped for the adjacent class, which clause 1 does not bind. + bad_code = badflag_p["code"] if family_class in CLAUSE_1_CLASSES else None for hc in sorted(parsed["honest"]): if bad_code == hc and hc not in parsed["usage"]: # Quote the clause that actually carries the honest-lane language, not @@ -344,6 +387,7 @@ def add(rule, severity, message, evidence): return { "tool": tool, + "family_class": family_class, "repo": str(repo), "entry": argv, "documented_codes": sorted(parsed["documented"]), @@ -390,7 +434,8 @@ def main(argv: list[str] | None = None) -> int: for r in results: mark = "PASS" if r["conformant"] else "FAIL" print(f"\n{mark} {r['tool']} ({r['repo']})") - print(f" documented {r['documented_codes']} · observed {r['observed_codes']}" + print(f" class={r['family_class'] or 'UNDECLARED'} · " + f"documented {r['documented_codes']} · observed {r['observed_codes']}" f" · honest-lane {r['honest_lane_codes']}") for f in r["findings"]: print(f" [{f['severity']}] {f['rule']}") diff --git a/scripts/gen_tool_json.py b/scripts/gen_tool_json.py index e922cc0..6864e2e 100644 --- a/scripts/gen_tool_json.py +++ b/scripts/gen_tool_json.py @@ -62,6 +62,10 @@ def build(): } return { "name": "srdcheck", + # FAMILY.md v2 clause 7 binds agent-first surfaces per member class. + # Declared here so the conformance gate reads it from the artifact + # rather than parsing the contract's prose. + "family_class": "verdict", "version": __version__, "description": ( "Deterministic rules verdicts over the SRD for game-running " diff --git a/tool.json b/tool.json index 05adad6..bf26a73 100644 --- a/tool.json +++ b/tool.json @@ -1,5 +1,6 @@ { "name": "srdcheck", + "family_class": "verdict", "version": "0.9.0", "description": "Deterministic rules verdicts over the SRD for game-running agents. Exit codes are scoped results: 0 passes the named checked scope, 1 conflicts with it, 2 cannot-adjudicate (unknown content or a DM-authority decision, including when the caller is the agent-DM — an honest refusal, not an error). Applied rules carry citations; boundary refusals do not invent them.", "status": "alpha; versioned machine schemas and the N/N-1 semantic window are reported by `capabilities`",