diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b03f5c1..968e0f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,3 +86,37 @@ jobs: node transport/discord/listen.mjs /tmp/t.json 2>/tmp/err.txt test $? -eq 2 || { echo "expected exit 2"; exit 1; } grep -q "NOPE_TOKEN" /tmp/err.txt + + portfolio-conformance: + runs-on: ubuntu-latest + steps: + - name: Check out table-kit candidate + uses: actions/checkout@v5 + with: + path: table-kit-src + - name: Check out pinned dmcheck candidate + uses: actions/checkout@v5 + with: + repository: chaoz23/dmcheck + ref: ce7ce6f3959227c7db86e3264fd6c5aecda80a48 + path: dmcheck-src + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Build both candidate wheels + run: | + python -m pip install build + mkdir wheels + python -m build --wheel --outdir "$GITHUB_WORKSPACE/wheels" table-kit-src + python -m build --wheel --outdir "$GITHUB_WORKSPACE/wheels" dmcheck-src + - name: Cold-install and run portfolio conformance + run: | + python -m venv "$RUNNER_TEMP/portfolio-conformance" + "$RUNNER_TEMP/portfolio-conformance/bin/python" -m pip install jsonschema + "$RUNNER_TEMP/portfolio-conformance/bin/python" -m pip install \ + --no-index --find-links "$GITHUB_WORKSPACE/wheels" tablekit dmcheck + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/portfolio-conformance/bin/python" \ + "$GITHUB_WORKSPACE/table-kit-src/scripts/check_dmcheck_conformance.py" \ + --tablekit "$RUNNER_TEMP/portfolio-conformance/bin/tablekit" \ + --dmcheck "$RUNNER_TEMP/portfolio-conformance/bin/dmcheck" diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 603d9fa..cbba1ac 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -56,9 +56,9 @@ stream. - Unknown major versions are refused as `unsupported`. - Unknown event types in a future minor are preserved as opaque input and reported as structured unsupported coverage; they never crash or disappear. -- Current unversioned table-kit rows remain the runtime storage format until an - explicit adapter lands. Native IDs and sequence are provenance, not - cryptographic authentication. +- Current unversioned table-kit rows remain the runtime storage format. The + explicit `migrate-events` adapter writes a separate canonical stream; native + IDs and sequence are provenance, not cryptographic authentication. - charactercheck and srdcheck remain pre-session/query evaluators. A host joins their results through session/entity/correlation references; neither tool is required to consume a live TableEvent stream. @@ -67,3 +67,11 @@ Python and TypeScript declarations are deterministically regenerated from the schema enums with `python scripts/generate_contract_types.py`; CI rejects stale artifacts. Runtime remains dependency-free. JSON Schema validation is a test and integration concern. + +CI also builds and cold-installs this candidate with the exact dmcheck commit +named in the workflow, outside either source checkout. The gate exercises a +complete clean result, an exact-evidence finding, the canonical transport gap, +deterministic legacy migration, and redacted content. It validates both event +and evaluation envelopes with the installed table-kit schema and semantic +checks. The pinned commit is an integration lock, not a release dependency; +advance it deliberately when the dmcheck contract consumer changes. diff --git a/scripts/check_dmcheck_conformance.py b/scripts/check_dmcheck_conformance.py new file mode 100644 index 0000000..76267ab --- /dev/null +++ b/scripts/check_dmcheck_conformance.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Cold-installed table-kit -> dmcheck portfolio conformance gate.""" + +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile + +from jsonschema import Draft202012Validator, FormatChecker + +import dmcheck +import tablekit +from tablekit import contracts + + +def _run(command, expected=0, cwd=None): + result = subprocess.run(command, cwd=cwd, capture_output=True, text=True) + if result.returncode != expected: + raise AssertionError( + "%s exited %d, expected %d\nstdout:\n%s\nstderr:\n%s" % ( + " ".join(command), result.returncode, expected, + result.stdout, result.stderr)) + return result + + +def _json(command, expected=0, cwd=None): + result = _run(command, expected=expected, cwd=cwd) + try: + return json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise AssertionError("command did not emit one JSON value: %s" % command) from exc + + +def _assert_cold_install(module): + module_path = Path(module.__file__).resolve() + prefix = Path(sys.prefix).resolve() + if prefix not in module_path.parents: + raise AssertionError("%s was imported outside the test environment: %s" % ( + module.__name__, module_path)) + + +def _validators(tablekit_command): + evaluation_schema = _json([tablekit_command, "contract", "evaluation"]) + event_schema = _json([tablekit_command, "contract", "event"]) + return ( + Draft202012Validator(evaluation_schema, format_checker=FormatChecker()), + Draft202012Validator(event_schema, format_checker=FormatChecker()), + ) + + +def _assert_evaluation(value, validator, status, exit_code, complete, + error_code=None): + validator.validate(value) + contracts.require_evaluation_semantics(value) + assert value["status"] == status + assert value["exit_code"] == exit_code + assert value["authority_status"] == "self_attested" + assert value["coverage"]["complete"] is complete + if error_code is not None: + assert error_code in {item["code"] for item in value["errors"]} + + +def _write_jsonl(path, values): + path.write_text("".join( + json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n" + for value in values), encoding="utf-8") + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--tablekit", default="tablekit") + parser.add_argument("--dmcheck", default="dmcheck") + args = parser.parse_args(argv) + + _assert_cold_install(tablekit) + _assert_cold_install(dmcheck) + evaluation_validator, event_validator = _validators(args.tablekit) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + + # The canonical fixture intentionally contains a transport gap. The + # consumer must preserve the gap as incomplete, never as a false clean. + golden = _json([args.tablekit, "contract", "golden"]) + for event in golden: + event_validator.validate(event) + contracts.require_event_stream(golden) + golden_path = root / "golden.jsonl" + _write_jsonl(golden_path, golden) + golden_result = _json( + [args.dmcheck, "run-events", str(golden_path), "--gm", "gm-dan", + "--table-evaluation"], expected=2, cwd=root) + _assert_evaluation( + golden_result, evaluation_validator, "incomplete", + 2, False, "table_event.transport_gap") + assert golden_result["findings"] == [] + assert golden_result["cursor"]["checked_through_event_id"] == "evt-010" + + # Exercise complete and findings envelopes across the same installed + # boundary, not only refusal behavior. + clean = json.loads(json.dumps(golden[3:6])) + for sequence, event in enumerate(clean, 1): + event["session_sequence"] = sequence + clean[0]["causation_id"] = None + clean_path = root / "clean.jsonl" + _write_jsonl(clean_path, clean) + clean_result = _json([ + args.dmcheck, "run-events", str(clean_path), "--gm", "gm-dan", + "--table-evaluation"], cwd=root) + _assert_evaluation( + clean_result, evaluation_validator, "checked_clean", 0, True) + assert clean_result["findings"] == [] + assert clean_result["errors"] == [] + + finding_events = json.loads(json.dumps([golden[5], golden[7]])) + finding_events[0]["session_sequence"] = 1 + finding_events[0]["causation_id"] = None + finding_events[0]["payload"]["resolves_obligation_ids"] = [] + finding_events[1]["session_sequence"] = 2 + finding_events[1]["causation_id"] = None + finding_path = root / "finding.jsonl" + _write_jsonl(finding_path, finding_events) + finding_result = _json([ + args.dmcheck, "run-events", str(finding_path), "--gm", "gm-dan", + "--table-evaluation"], expected=1, cwd=root) + _assert_evaluation( + finding_result, evaluation_validator, "findings", 1, True) + assert "evt-008" in finding_result["findings"][0]["evidence_refs"] + + # A legacy narration preserves its native source ID but receives a + # deterministic migration ID. Unknown legacy roles cannot fabricate + # eligible conduct evidence, so the consumer must remain incomplete. + legacy_path = root / "legacy.jsonl" + _write_jsonl(legacy_path, [{ + "ts": 1.0, "type": "event", "actor": "GM", "text": "Begin.", + "source": "discord", "source_id": "message-1", + }]) + first_events = root / "legacy-events-1.jsonl" + second_events = root / "legacy-events-2.jsonl" + first_summary = _json([ + args.tablekit, "migrate-events", "--ledger", str(legacy_path), + "--campaign", "ci", "--session", "legacy", + "--out", str(first_events)], cwd=root) + second_summary = _json([ + args.tablekit, "migrate-events", "--ledger", str(legacy_path), + "--campaign", "ci", "--session", "legacy", + "--out", str(second_events)], cwd=root) + assert first_events.read_bytes() == second_events.read_bytes() + assert first_summary["output_sha256"] == second_summary["output_sha256"] + assert first_summary["output_sha256"] == "sha256:" + hashlib.sha256( + first_events.read_bytes()).hexdigest() + migrated = [json.loads(line) for line in first_events.read_text( + encoding="utf-8").splitlines()] + for event in migrated: + event_validator.validate(event) + contracts.require_event_stream(migrated) + assert migrated[0]["source"]["native_id"] == "message-1" + assert migrated[0]["event_id"] != "message-1" + legacy_result = _json([ + args.dmcheck, "run-events", str(first_events), "--gm", "GM", + "--table-evaluation"], expected=2, cwd=root) + _assert_evaluation( + legacy_result, evaluation_validator, "incomplete", + 2, False, "evidence.no_eligible_rules") + assert legacy_result["findings"] == [] + + # Redaction is explicit migration evidence. The consumer must refuse + # to infer the absence of a conduct obligation from hidden prose. + redacted_path = root / "redacted.jsonl" + _write_jsonl(redacted_path, [{ + "ts": 2.0, "type": "qa.inbound", "seat": "Rowan", "chars": 12, + "source": "discord", "source_id": "message-redacted", + }]) + redacted_events = root / "redacted-events.jsonl" + _json([ + args.tablekit, "migrate-events", "--ledger", str(redacted_path), + "--campaign", "ci", "--session", "redacted", + "--out", str(redacted_events)], cwd=root) + redacted_result = _json([ + args.dmcheck, "run-events", str(redacted_events), "--gm", "GM", + "--table-evaluation"], expected=2, cwd=root) + _assert_evaluation( + redacted_result, evaluation_validator, "incomplete", + 2, False, "table_event.content_redacted") + assert redacted_result["findings"] == [] + + print(json.dumps({ + "status": "passed", "authority_status": "self_attested", + "tablekit_version": tablekit.__version__, + "dmcheck_version": dmcheck.__version__, + "cases": ["checked_clean", "findings", "golden_gap", + "legacy_determinism", "redacted_content"], + }, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())