diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f04d244..64fa0f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,23 @@ jobs: /tmp/charactercheck-cold/bin/charactercheck --schema > /tmp/charactercheck-schema.json grep -q '"snapshot"' /tmp/charactercheck-schema.json grep -q '"internal_error"' /tmp/charactercheck-schema.json + grep -q '"table_evaluation"' /tmp/charactercheck-schema.json + + - name: Installed shared result is value-free and fail-closed + working-directory: /tmp + env: *offline-network + run: | + sample=/tmp/charactercheck-cold/lib/python3.12/site-packages/charactercheck/sample-character.json + set +e + /tmp/charactercheck-cold/bin/charactercheck derive "$sample" \ + --table-evaluation > /tmp/charactercheck-evaluation.json + code=$? + set -e + test $code -eq 2 || { echo "expected unsupported exit 2, got $code"; exit 1; } + grep -q '"schema_version": "table.evaluation/1.0"' /tmp/charactercheck-evaluation.json + grep -q '"status": "unsupported"' /tmp/charactercheck-evaluation.json + grep -q '"authority_status": "self_attested"' /tmp/charactercheck-evaluation.json + grep -qv 'Torvald Brightmantle' /tmp/charactercheck-evaluation.json - name: Installed MCP initializes and lists tools without network working-directory: /tmp diff --git a/AGENTS.md b/AGENTS.md index dd88ce5..54f2072 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Run the offline smoke test first: ```console python3 -m charactercheck selftest python3 -m charactercheck derive examples/sample-character.json --brief +python3 -m charactercheck derive examples/sample-character.json --table-evaluation ``` The example is project-authored synthetic data. A smoke-test pass proves only @@ -84,6 +85,11 @@ The local `--for-dm` projection marks every duplicate of current HP/slots, stance, resources, and inventory as `player-authority`; it is a redaction mode, not authentication or proof of table role. +`derive` and `report` accept `--table-evaluation` for a deterministic, +value-free `table.evaluation/1.0` projection. Treat mutable player-authority +fields as out-of-scope advisories, not evaluator-owned facts. Every envelope is +`self_attested` and remains pre-session character context only. + ## Privacy and capability boundary Default mechanical output omits account identifiers, linked images, notes, diff --git a/CHANGELOG.md b/CHANGELOG.md index 7416eec..906d163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.7.0 - 2026-08-02 +- Add a deterministic, value-free `table.evaluation/1.0` projection for + `derive`/`report`. It preserves canonical field trust, treats mutable + player-authority state as an out-of-scope advisory, maps gaps and input + failures to typed refusals, and remains explicitly self-attested. - Pair every Coverage Inventory answer with its numbered 2024 character-sheet question, and include deduplicated sheet-specific lint questions in `quiz` without supplying answer keys for non-trusted mechanics. diff --git a/README.md b/README.md index 565378d..4b0ba30 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ collection, inventory, modifier, and container-traversal limits. |---|---| | `derive ` | Selected derived fields, provenance, findings, and trust routing | | `derive --brief` | Deterministic chat-sized summary; still subject to findings | +| `derive --table-evaluation` | Value-free, deterministic, self-attested `table.evaluation/1.0` assessment envelope | | `stance ` | Envelope containing stance, canonical assessment, trust, fields, and observation metadata | | `report ` | Canonical field/trust assessments plus findings and identified feature names | | `qa [--full]` | 100-question **Coverage Inventory** pairing each answer with a closed field state; not a validity score | @@ -142,6 +143,14 @@ collection, inventory, modifier, and container-traversal limits. `--pipe` reads refs from standard input. `charactercheck --schema` emits the machine-readable CLI contract. +`--table-evaluation` is also accepted by `report`. It projects canonical field +states and source/policy digests, never character values. Trusted fields count +as evaluated; unsupported, unknown, invalid, and non-authority confirmation +states fail closed. Mutable player-authority fields are outside the evaluator's +scope and become advisories requiring player/session-host reconciliation. The +envelope is always `self_attested`; it is not proof of source identity, +freshness, table role, encounter state, or action legality. + ### Question catalog The Coverage Inventory pairs each of its 100 answers with the corresponding @@ -317,6 +326,12 @@ Exit status is command-specific: | `selftest` | smoke test passed | smoke test failed | — | — | | `doctor` | all checks passed | — | — | at least one diagnostic check failed | +With explicit `--table-evaluation`, the shared contract's exit codes apply: +`0` checked clean, `1` complete with advisories/findings, and `2` incomplete, +unsupported, invalid, or internal error. Native command behavior is unchanged +when the flag is absent. `--brief` and `--table-evaluation` are mutually +exclusive. + The CLI rejects every unsupported command/flag combination with structured `bad_flag` exit 2; notably `--for-dm` is valid only for `seatpack` and `intake`. Missing arguments, diff --git a/charactercheck/__init__.py b/charactercheck/__init__.py index b22c4ba..00a8d13 100644 --- a/charactercheck/__init__.py +++ b/charactercheck/__init__.py @@ -2,4 +2,6 @@ from .engine import derive, fetch, stance __version__ = "0.7.0" -__all__ = ["derive", "fetch", "stance"] +from .table_evaluation import project_table_evaluation + +__all__ = ["derive", "fetch", "stance", "project_table_evaluation"] diff --git a/charactercheck/cli.py b/charactercheck/cli.py index 5526c0c..880edd9 100644 --- a/charactercheck/cli.py +++ b/charactercheck/cli.py @@ -5,6 +5,7 @@ import sys from . import derive, engine, errors, qa +from .table_evaluation import input_digest, project_table_error, project_table_evaluation def _json(value): @@ -55,6 +56,13 @@ def _json(value): "for credentials."), "kinds": list(errors.PUBLIC_ERROR_KINDS), }, + "table_evaluation": { + "flag": "--table-evaluation on derive/report", + "schema_version": "table.evaluation/1.0", + "authority_status": "self_attested", + "privacy": "field assessments and digests only; no character values", + "exit_codes": "0 checked_clean; 1 complete with advisories/findings; 2 refused", + }, } @@ -85,12 +93,25 @@ def main(argv=None): ap.add_argument("--brief", action="store_true", help="derive/report: chat-sized summary") ap.add_argument("--json", dest="json_out", action="store_true", help="doctor: machine-readable output") ap.add_argument("--schema", action="store_true", help="print the I/O contract and exit") + ap.add_argument("--table-evaluation", action="store_true", + help="derive/report: emit a self-attested table.evaluation/1.0 envelope") a = ap.parse_args(argv) if a.schema: print(_json(SCHEMA)) return 0 + if a.table_evaluation and a.brief: + print(_json({ + "ok": False, + "error": "bad_flag", + "message": "--brief and --table-evaluation are mutually exclusive.", + "action": "Drop --brief when requesting the shared JSON envelope.", + "retryable": False, + "exit_code": 2, + }), file=sys.stderr) + return 2 + flag_contract = ( (a.brief, "--brief", {"derive", "report"}, "Use --brief with `derive` or `report`, or drop it."), @@ -102,6 +123,8 @@ def main(argv=None): (a.full, "--full", {"qa"}, "Use it only with qa."), (bool(a.baseline), "--baseline", {"diff"}, "Use it only with diff."), (a.json_out, "--json", {"doctor"}, "Use it only with doctor."), + (a.table_evaluation, "--table-evaluation", {"derive", "report"}, + "Use it only with derive or report."), (a.pipe, "--pipe", {"derive", "stance", "qa", "report", "snapshot", "diff", "quiz", "seatpack", "intake"}, @@ -137,8 +160,13 @@ def main(argv=None): try: if a.command == "derive": r = derive(ref) - print(engine.render_brief(r) if a.brief else _json(r)) - code = max(code, _exit_code(r)) + if a.table_evaluation: + out = project_table_evaluation(r) + print(_json(out)) + code = max(code, out["exit_code"]) + else: + print(engine.render_brief(r) if a.brief else _json(r)) + code = max(code, _exit_code(r)) elif a.command == "intake": print(_json(engine.intake(ref, for_dm=a.for_dm, include_persona=a.include_persona))) @@ -167,6 +195,11 @@ def main(argv=None): print(_json(engine.quiz(ref))) elif a.command == "report": r = derive(ref) + if a.table_evaluation: + out = project_table_evaluation(r) + print(_json(out)) + code = max(code, out["exit_code"]) + continue if a.brief: print(engine.render_report_brief(r)) code = max(code, _exit_code(r)) @@ -177,15 +210,25 @@ def main(argv=None): except errors.CharacterCheckError as e: # Structured, actionable, and on stdout so a piping agent # sees it. Never a traceback: see charactercheck/errors.py. - print(_json(e.as_dict())) - code = max(code, e.exit_code) + if a.table_evaluation: + out = project_table_error(e.as_dict(), input_digest(ref)) + print(_json(out)) + code = max(code, out["exit_code"]) + else: + print(_json(e.as_dict())) + code = max(code, e.exit_code) except Exception as exc: # noqa: BLE001 - redact at the process boundary correlation_id = secrets.token_hex(8) print(f"charactercheck internal_error correlation_id={correlation_id} " f"exception={type(exc).__name__}", file=sys.stderr) e = errors.internal_error(correlation_id) - print(_json(e.as_dict())) - code = max(code, e.exit_code) + if a.table_evaluation: + out = project_table_error(e.as_dict(), input_digest(ref)) + print(_json(out)) + code = max(code, out["exit_code"]) + else: + print(_json(e.as_dict())) + code = max(code, e.exit_code) return code diff --git a/charactercheck/table_evaluation.py b/charactercheck/table_evaluation.py new file mode 100644 index 0000000..196c197 --- /dev/null +++ b/charactercheck/table_evaluation.py @@ -0,0 +1,244 @@ +"""Project CharacterCheck observations into ``table.evaluation/1.0``. + +The projection contains assessments and digests, never character values. It +remains self-attested and cannot promote source, player, or rules-engine data +into encounter, session, or action authority. +""" + +import hashlib +import json + +from . import __version__ + + +TABLE_EVALUATION_SCHEMA_VERSION = "table.evaluation/1.0" +_INVALID_ERRORS = { + "bad_ref", "bad_json", "invalid_character", "input_too_large", + "input_too_deep", "input_limit", "cyclic_reference", "file_policy", + "snapshot_schema", "snapshot_integrity", "snapshot_required", + "snapshot_source_mismatch", +} +_UNSUPPORTED_ERRORS = { + "local_files_disabled", "persona_requires_local", "output_too_large", +} + + +def _digest(value): + encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def input_digest(ref): + """Bind an error result to an input without disclosing the reference.""" + return _digest({"command": "derive", "ref": str(ref)}) + + +def _policy(meta): + value = { + "rules_profile": meta.get("rules_profile"), + "adapter_registry_fingerprint": meta.get("adapter_registry_fingerprint"), + "source_schema_fingerprint": meta.get("source_schema_fingerprint"), + "authority_boundary": meta.get("authority_boundary"), + } + return value, _digest(value) + + +def _diagnostic(code, message, pointer=None, refs=()): + result = {"code": str(code)[:256], "message": str(message)} + if pointer is not None: + result["pointer"] = str(pointer) + if refs: + result["evidence_refs"] = list(dict.fromkeys(refs)) + return result + + +def _finding(field_id, field, policy, policy_digest): + evidence = list(dict.fromkeys( + [field_id] + [str(item) for item in field.get("findings") or []])) + material = {"field": field_id, "state": field.get("state"), + "authority": field.get("authority"), "evidence": evidence} + return { + "finding_id": "charactercheck-" + _digest(material).split(":", 1)[1][:40], + "code": "charactercheck.player_authority", + "severity": "advisory", + "summary": "%s requires player or session-host reconciliation" % field_id, + "evidence_refs": evidence, + "effective_policy": dict(policy, field=field_id, + field_state=field.get("state"), + field_authority=field.get("authority")), + "policy_version": str(policy.get("rules_profile") or "unknown"), + "policy_digest": policy_digest, + } + + +def project_table_evaluation(report): + """Return a deterministic, value-free shared evaluation envelope.""" + if not isinstance(report, dict) or not isinstance(report.get("fields"), dict): + return project_table_error({ + "error": "internal_error", + "message": "CharacterCheck returned an incoherent derived report.", + }, _digest({"report": "incoherent"})) + + meta = report.get("meta") if isinstance(report.get("meta"), dict) else {} + revision = meta.get("source_revision") + if not (isinstance(revision, str) and revision.startswith("sha256:") + and len(revision) == 71): + return project_table_error({ + "error": "internal_error", + "message": "CharacterCheck report lacks a canonical source revision.", + }, _digest({"report": "missing-source-revision"})) + + policy, policy_digest = _policy(meta) + evaluators = [] + errors = [] + advisories = [] + blocking_confirm = 0 + state_counts = {name: 0 for name in ( + "trusted", "confirm", "unsupported", "unknown", "invalid", + "not_applicable")} + + for field_id, field in sorted(report["fields"].items()): + if not isinstance(field, dict): + state, reasons = "invalid", ["field_assessment_invalid"] + authority = None + else: + state = field.get("state") + reasons = [str(item) for item in field.get("findings") or []] + authority = field.get("authority") + if state not in state_counts: + state, reasons = "invalid", ["field_state_invalid"] + state_counts[state] += 1 + + mutable = (state == "confirm" and authority == "player" + and reasons and all(item.startswith("mutable_") for item in reasons)) + if state == "trusted": + evaluator_status, eligible, evaluated, skipped = "evaluated", 1, 1, 0 + skip_reasons = [] + required = True + elif state == "not_applicable" or mutable: + evaluator_status, eligible, evaluated, skipped = "not_applicable", 0, 0, 0 + skip_reasons = [] + required = False + if mutable: + advisories.append(_finding(field_id, field, policy, policy_digest)) + else: + if state == "confirm": + blocking_confirm += 1 + evaluator_status, eligible, evaluated, skipped = ( + "error" if state == "invalid" else "skipped", 1, 0, 1) + skip_reasons = [_diagnostic( + "charactercheck.%s" % state, + "%s is %s and must not be used as trusted character context" % + (field_id, state), pointer=field_id, + refs=[field_id] + reasons)] + errors.extend(skip_reasons) + required = True + evaluators.append({ + "id": field_id, "required": required, "status": evaluator_status, + "eligible": eligible, "evaluated": evaluated, "skipped": skipped, + "skip_reasons": skip_reasons, + }) + + if state_counts["invalid"]: + status = "invalid" + elif state_counts["unknown"]: + status = "incomplete" + elif state_counts["unsupported"]: + status = "unsupported" + elif blocking_confirm: + status = "incomplete" + elif advisories: + status = "checked_with_advisories" + else: + status = "checked_clean" + complete = status in {"checked_clean", "checked_with_advisories"} + eligible = sum(item["eligible"] for item in evaluators) + evaluated = sum(item["evaluated"] for item in evaluators) + skipped = sum(item["skipped"] for item in evaluators) + assessment = { + field_id: { + "state": field.get("state"), "authority": field.get("authority"), + "findings": list(field.get("findings") or []), + } + for field_id, field in sorted(report["fields"].items()) + if isinstance(field, dict) + } + result = { + "schema_version": TABLE_EVALUATION_SCHEMA_VERSION, + "evaluation_id": "charactercheck-" + _digest({ + "version": __version__, "revision": revision, + "assessment": assessment, "policy_digest": policy_digest, + }).split(":", 1)[1][:40], + "tool": {"name": "charactercheck", "version": __version__}, + "subject": { + "kind": "character", + "id": "character-" + revision.split(":", 1)[1][:40], + "session_id": None, + "entity_refs": [], + }, + "status": status, + "exit_code": 0 if status == "checked_clean" else (1 if complete else 2), + "authority_status": "self_attested", + "coverage": { + "complete": complete, "evidence_required": True, + "input": len(evaluators), "compatible": len(evaluators), + "eligible": eligible, "evaluated": evaluated, "skipped": skipped, + "evaluators": evaluators, + }, + "cursor": {"checked_through_event_id": None, + "gap_state": "none" if complete else "unknown", + "input_digest": revision}, + "context": {"roster_digest": None, "policy_digest": policy_digest, + "config_digest": None, "source_set_digest": revision, + "session_descriptor_digest": None}, + "findings": [], + "advisories": advisories, + "warnings": [], + "errors": errors, + } + return result + + +def project_table_error(error, digest): + """Map a public CharacterCheck failure into a privacy-safe refusal.""" + kind = error.get("error") if isinstance(error, dict) else "internal_error" + message = (error.get("message") if isinstance(error, dict) else None) + if kind == "internal_error": + status = "internal_error" + elif kind in _INVALID_ERRORS: + status = "invalid" + elif kind in _UNSUPPORTED_ERRORS: + status = "unsupported" + else: + status = "incomplete" + diagnostic = _diagnostic("charactercheck.%s" % kind, + message or "CharacterCheck could not derive the character") + material = {"version": __version__, "input_digest": digest, "error": kind} + return { + "schema_version": TABLE_EVALUATION_SCHEMA_VERSION, + "evaluation_id": "charactercheck-" + _digest(material).split(":", 1)[1][:40], + "tool": {"name": "charactercheck", "version": __version__}, + "subject": {"kind": "character", + "id": "character-input-" + digest.split(":", 1)[1][:40], + "session_id": None, "entity_refs": []}, + "status": status, "exit_code": 2, + "authority_status": "self_attested", + "coverage": { + "complete": False, "evidence_required": True, + "input": 1, "compatible": 0, "eligible": 0, + "evaluated": 0, "skipped": 0, + "evaluators": [{ + "id": "character-derivation", "required": True, + "status": "error", "eligible": 0, "evaluated": 0, + "skipped": 0, "skip_reasons": [diagnostic], + }], + }, + "cursor": {"checked_through_event_id": None, "gap_state": "unknown", + "input_digest": digest}, + "context": {"roster_digest": None, "policy_digest": None, + "config_digest": None, "source_set_digest": None, + "session_descriptor_digest": None}, + "findings": [], "advisories": [], "warnings": [], + "errors": [diagnostic], + } diff --git a/llms.txt b/llms.txt index 1e0bbb8..2e1891c 100644 --- a/llms.txt +++ b/llms.txt @@ -13,6 +13,7 @@ STATUS: START OFFLINE: python3 -m charactercheck selftest python3 -m charactercheck derive examples/sample-character.json --brief + python3 -m charactercheck derive examples/sample-character.json --table-evaluation INPUT: Exact positive numeric ID, exact HTTPS dndbeyond.com/characters/, a @@ -61,6 +62,12 @@ LIBRARY: were omitted; build is not a package export. Never strip coverage or fixed _semanticGaps by recomposing a plain character dict. +SHARED RESULT: + derive/report --table-evaluation emits deterministic, value-free + table.evaluation/1.0. Mutable player-authority fields are out-of-scope + advisories; unsupported/unknown/invalid fields fail closed. Output is always + self_attested and never grants source, table, encounter, or action authority. + ECOSYSTEM: Adapter IDs/names come from a pinned offline DDB config registry; membership does not mean mechanics are implemented, and runtime performs no config @@ -76,6 +83,8 @@ EXIT (command-specific): runtime/input failures: 3. Unsupported command-specific flags are structured bad_flag exit 2; argparse syntax/usage errors are plain-text exit 2. Never treat exit 0 or 2 as autonomous readiness. + With --table-evaluation, use the shared exit lane: 0 checked clean, 1 complete + with advisories/findings, 2 refused. It is mutually exclusive with --brief. MCP TOOLS: derive, stance, qa, diff, snapshot, seatpack, quiz, report, intake, diff --git a/tests/test_table_evaluation.py b/tests/test_table_evaluation.py new file mode 100644 index 0000000..00c43db --- /dev/null +++ b/tests/test_table_evaluation.py @@ -0,0 +1,115 @@ +"""Shared result projection preserves CharacterCheck's trust boundary.""" + +import copy +import io +import json +import os +import unittest +from contextlib import redirect_stderr, redirect_stdout + +from charactercheck import engine +from charactercheck import errors +from charactercheck.cli import main +from charactercheck.table_evaluation import ( + input_digest, + project_table_error, + project_table_evaluation, +) + + +FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "torvald.json") + + +class TestTableEvaluationProjection(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.report = engine.derive(FIXTURE) + + def test_real_report_is_value_free_and_fail_closed(self): + result = project_table_evaluation(self.report) + self.assertEqual(result["schema_version"], "table.evaluation/1.0") + self.assertEqual(result["status"], "unsupported") + self.assertEqual(result["exit_code"], 2) + self.assertEqual(result["authority_status"], "self_attested") + self.assertFalse(result["coverage"]["complete"]) + encoded = json.dumps(result, sort_keys=True) + self.assertNotIn(self.report["identity"]["name"], encoded) + + def keys(value): + if isinstance(value, dict): + return set(value) | {key for item in value.values() for key in keys(item)} + if isinstance(value, list): + return {key for item in value for key in keys(item)} + return set() + + self.assertNotIn("value", keys(result)) + + def test_player_authority_is_advisory_not_host_attestation(self): + report = copy.deepcopy(self.report) + for field in report["fields"].values(): + if field["state"] == "unsupported": + field["state"] = "trusted" + field["findings"] = [] + result = project_table_evaluation(report) + self.assertEqual(result["status"], "checked_with_advisories") + self.assertTrue(result["coverage"]["complete"]) + self.assertTrue(result["advisories"]) + self.assertTrue(all(item["severity"] == "advisory" + for item in result["advisories"])) + self.assertIsNone(result["context"]["session_descriptor_digest"]) + + def test_projection_is_deterministic_across_observation_time(self): + first = project_table_evaluation(self.report) + later = copy.deepcopy(self.report) + later["meta"]["as_of"] = "2099-01-01T00:00:00Z" + for field in later["fields"].values(): + field["as_of"] = later["meta"]["as_of"] + self.assertEqual(first, project_table_evaluation(later)) + + def test_unknown_and_invalid_fields_do_not_become_findings(self): + for state, expected in (("unknown", "incomplete"), + ("invalid", "invalid")): + with self.subTest(state=state): + report = copy.deepcopy(self.report) + field = report["fields"]["identity.name"] + field["state"] = state + field["findings"] = ["synthetic-gap"] + result = project_table_evaluation(report) + self.assertEqual(result["status"], expected) + self.assertFalse(result["coverage"]["complete"]) + self.assertEqual(result["findings"], []) + self.assertTrue(result["errors"]) + + def test_public_errors_map_without_disclosing_the_reference(self): + secret_ref = "/private/campaign/character-123.json" + digest = input_digest(secret_ref) + cases = ( + (errors.bad_ref(secret_ref), "invalid"), + (errors.not_public(secret_ref), "incomplete"), + (errors.local_files_disabled(), "unsupported"), + ) + for error, expected in cases: + with self.subTest(kind=error.kind): + result = project_table_error(error.as_dict(), digest) + self.assertEqual(result["status"], expected) + self.assertEqual(result["exit_code"], 2) + self.assertNotIn(secret_ref, json.dumps(result, sort_keys=True)) + + def test_cli_flag_uses_shared_exit_lane(self): + output = io.StringIO() + with redirect_stdout(output): + code = main(["derive", FIXTURE, "--table-evaluation"]) + result = json.loads(output.getvalue()) + self.assertEqual(code, 2) + self.assertEqual(result["status"], "unsupported") + + def test_brief_and_shared_envelope_cannot_silently_compete(self): + output = io.StringIO() + with redirect_stderr(output): + code = main(["derive", FIXTURE, "--brief", "--table-evaluation"]) + self.assertEqual(code, 2) + self.assertEqual(json.loads(output.getvalue())["error"], "bad_flag") + + +if __name__ == "__main__": + unittest.main() diff --git a/tool.json b/tool.json index b074b81..1847b0b 100644 --- a/tool.json +++ b/tool.json @@ -13,6 +13,7 @@ "example": "python3 -m charactercheck derive examples/sample-character.json --brief", "commands": { "charactercheck derive [--brief]": "selected derivation plus canonical fields, findings, trust, and observation metadata", + "charactercheck derive --table-evaluation": "value-free deterministic self-attested table.evaluation/1.0 assessment envelope; also accepted by report", "charactercheck stance ": "stance envelope with canonical assessment, findings, trust, and observation metadata", "charactercheck qa [--full]": "100-question Coverage Inventory pairing answers with closed states; not validity", "charactercheck report [--brief]": "canonical fields/trust plus findings and identified feature names", @@ -70,10 +71,18 @@ "diff_invariant": "distinct snapshots with any nonempty omission coverage or omitted modifier restriction semantics emit $ in unsupported_changes and relationship indeterminate; same-revision named non-mechanical deltas are mechanically_unchanged; exact identity is unchanged" }, "library": { - "canonical": "package-level derive(ref) and stance(ref) preserve trust and source coverage", + "canonical": "package-level derive(ref), stance(ref), and project_table_evaluation(report) preserve trust and source coverage", "plain_fetch": "fetch(ref) raises source_coverage when returning a plain character would discard omission metadata", "raw_workspace": "build is not package-exported; engine.build also raises source_coverage on incomplete raw sources" }, + "shared_evaluation": { + "schema_version": "table.evaluation/1.0", + "authority_status": "self_attested", + "privacy": "field states, authority labels, bounded evidence codes, and source/policy digests only; no character values", + "mutable_state": "player-authority mutable fields are out-of-scope advisories requiring player/session-host reconciliation", + "exit_codes": "0 checked_clean; 1 complete with advisories/findings; 2 incomplete, unsupported, invalid, or internal_error", + "boundary": "pre-session character context only; not source authentication, freshness, encounter/session authority, or action legality" + }, "mcp": { "command": "charactercheck-mcp", "transport": "stdio",