Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,15 @@ resolution, so legal Reactions and interrupts are not falsely accused.
Transport gaps, unknown versions/types, and zero-compatible streams return
typed `incomplete` status and exit 2; malformed v1 events return `invalid` and
exit 2. Redacted message content is likewise incomplete because absence of
prose cannot prove absence of a conduct obligation. This unreleased adapter is pinned to table-kit PR #11 and does not
imply host attestation.
prose cannot prove absence of a conduct obligation. This unreleased adapter
is pinned to table-kit PR #11 and does not imply host attestation.

Add `--table-evaluation` to `run-events` when a portfolio host needs the
shared `table.evaluation/1.0` envelope. The projection has deterministic
evaluation identity, exact event evidence references, per-rule and aggregate
coverage, cursor/input digest, public-policy digest, and status/exit-code
mapping. It is always `self_attested`; dmcheck cannot confer host authority on
its own output.

Transcript formats: UTF-8 JSONL of `{ts, author, content}`, or a JSON array of Discord-API-shaped messages (`{timestamp, author: {username}, content}`) in chronological or reverse-chronological order. Authors and content are strings. A supplied timestamp must be a finite nonnegative epoch number or a timezone-aware ISO-8601 string; malformed timestamps are rejected rather than silently disabling time-based rules. Source adapters should preserve immutable `id`, `audience`, `reply_to`/`correlation_id`, and `roll_id` fields so dmcheck can prove which question, roll, or event a response closes; ordinary later GM chatter is not treated as an answer.

Expand Down
9 changes: 7 additions & 2 deletions dmcheck/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
apply_charter_overrides)
from ._version import __version__
from .table_event import (KNOWN_EVENT_TYPES, TABLE_EVENT_SCHEMA_VERSION,
TableEventProjection, evaluate_table_event_path,
load_table_events)
TableEventProjection,
evaluate_table_event_contract_path,
evaluate_table_event_path, load_table_events)
from .table_evaluation import (TABLE_EVALUATION_SCHEMA_VERSION,
project_table_evaluation)

__all__ = [
"EvaluationResult", "InputValidationError", "ValidationIssue", "RULES",
Expand All @@ -18,4 +21,6 @@
"public_charter_digest", "redact_output", "__version__",
"KNOWN_EVENT_TYPES", "TABLE_EVENT_SCHEMA_VERSION", "TableEventProjection",
"evaluate_table_event_path", "load_table_events",
"TABLE_EVALUATION_SCHEMA_VERSION", "evaluate_table_event_contract_path",
"project_table_evaluation",
]
16 changes: 15 additions & 1 deletion dmcheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import sys

from . import (RULES, __version__, evaluate_paths, evaluate_table_event_path,
load_charter)
evaluate_table_event_contract_path, load_charter)
from .core import (invalid_result, public_charter, public_charter_digest,
redact_output)
from .validation import (InputValidationError, issue, load_craft_input,
Expand All @@ -38,6 +38,7 @@
},
"transcript": "UTF-8 JSONL of {ts, author, content, id?, audience?, reply_to?, correlation_id?, roll_id?} OR a JSON array of Discord-API-shaped messages",
"table_event": "run-events accepts table.event/1.0 JSONL or a JSON array; contract gaps and unsupported variants return incomplete",
"table_evaluation": "run-events --table-evaluation emits self-attested table.evaluation/1.0; host authority is external",
"ledger": "optional JSONL of {ts, type: turn|act|event, id?, actor?, text?}; event IDs correlate narration; actor/turn order never establishes action legality",
"charter": "schema-versioned JSON config; canonical packaged default is dmcheck/default_charter.json; charter.gm is REQUIRED",
"authority": "dmcheck evaluates conduct/communication only; upstream authority and the DM/table decide action legality; srdcheck is advisory",
Expand Down Expand Up @@ -94,13 +95,20 @@ def main(argv=None):
ap.add_argument("--notify-cmd", help="watch: shell command fired per source-observed OPEN finding (finding JSON on stdin)")
ap.add_argument("--evaluation-ts", type=float,
help="run: explicit Unix-seconds evaluation horizon")
ap.add_argument("--table-evaluation", action="store_true",
help="run-events: emit table.evaluation/1.0 instead of the native result")
ap.add_argument("--version", action="version",
version="dmcheck %s" % __version__)
a = ap.parse_args(argv)

if a.schema:
print(json.dumps(SCHEMA, indent=1))
return 0
if a.table_evaluation and a.command != "run-events":
return _print_invalid([
issue("table_evaluation.command", "/table-evaluation",
"--table-evaluation is valid only with run-events")
])
if a.command == "rules":
print(json.dumps(RULES, indent=1))
return 0
Expand Down Expand Up @@ -281,6 +289,12 @@ def main(argv=None):
issue("table_event.ledger_conflict", "/ledger",
"run-events derives its ledger from the event stream; --ledger is not accepted")
])
if a.table_evaluation:
result = evaluate_table_event_contract_path(
a.transcript, charter_path=a.charter, gm=a.gm,
dice_authors=a.dice_bot, mode="closed", now=a.evaluation_ts)
print(json.dumps(result, indent=1))
return result["exit_code"]
result = evaluate_table_event_path(
a.transcript, charter_path=a.charter, gm=a.gm,
dice_authors=a.dice_bot, mode="closed", now=a.evaluation_ts)
Expand Down
216 changes: 216 additions & 0 deletions dmcheck/table_evaluation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""Project dmcheck's native result into ``table.evaluation/1.0``.

The projection is deterministic and always self-attested. A separate
protected host may later bind the same native result to host-owned context;
dmcheck cannot grant that authority to itself.
"""

import hashlib
import json

from ._version import __version__
from .core import public_charter_digest
from .validation import ValidationIssue


TABLE_EVALUATION_SCHEMA_VERSION = "table.evaluation/1.0"


def _canonical_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 _diagnostic(value):
if isinstance(value, ValidationIssue):
value = value.to_dict()
code = value.get("code", "dmcheck.diagnostic") if isinstance(value, dict) else "dmcheck.diagnostic"
message = value.get("message", str(value)) if isinstance(value, dict) else str(value)
result = {"code": str(code)[:256] or "dmcheck.diagnostic",
"message": str(message) or "dmcheck diagnostic"}
pointer = value.get("pointer") if isinstance(value, dict) else None
if isinstance(pointer, str):
result["pointer"] = pointer
return result


def _values(value):
if isinstance(value, (list, tuple, set)):
for item in value:
yield from _values(item)
elif value is not None and str(value):
yield str(value)


def _evidence_refs(finding):
evidence = finding.get("evidence") if isinstance(finding, dict) else None
refs = []
if isinstance(evidence, dict):
for key in ("id", "event_id", "source_id", "correlation_id",
"obligation_id", "roll_id", "event_ids", "source_ids",
"correlation_ids", "obligation_ids", "roll_ids"):
for value in _values(evidence.get(key)):
if len(value) <= 256 and value not in refs:
refs.append(value)
return refs


def _finding(value, policy_version, policy_digest):
refs = _evidence_refs(value)
if not refs:
raise ValueError("TableEvent finding lacks an exact evidence reference")
severity = "advisory" if value.get("severity") == "advisory" else "finding"
return {
"finding_id": str(value["finding_id"])[:256],
"code": str(value.get("rule") or "dmcheck.finding")[:256],
"severity": severity,
"summary": str(value.get("summary") or value.get("detail") or
"dmcheck finding"),
"evidence_refs": refs,
"effective_policy": dict(value.get("effective_policy") or {}),
"policy_version": policy_version,
"policy_digest": policy_digest,
}


def _rule_coverage(result):
eligible = set(result.eligible_rules)
enabled = set((result.charter or {}).get("rules_enabled") or [])
evaluators = []
for rule in sorted(set("R%d" % number for number in range(1, 9))):
if rule in eligible:
evaluators.append({"id": rule, "required": True,
"status": "evaluated", "eligible": 1,
"evaluated": 1, "skipped": 0,
"skip_reasons": []})
elif rule not in enabled:
evaluators.append({
"id": rule, "required": False, "status": "disabled",
"eligible": 0, "evaluated": 0, "skipped": 0,
"skip_reasons": [{"code": "rule.disabled_by_policy",
"message": "%s is disabled by the charter" % rule}],
})
else:
# In a schema-valid, gap-free TableEvent stream, absence of a roll,
# turn, engine event, player question, or hidden-term policy makes
# the corresponding conduct rule not applicable; it is not an
# unreported coverage gap. Adapter blockers return before here.
evaluators.append({"id": rule, "required": True,
"status": "not_applicable", "eligible": 0,
"evaluated": 0, "skipped": 0,
"skip_reasons": []})
return evaluators


def project_table_evaluation(result, projection):
"""Return one schema-shaped self-attested portfolio evaluation."""
if not projection.session_id or not projection.input_digest:
raise ValueError("a validated TableEvent projection is required")
charter = result.charter or {}
policy_digest = (public_charter_digest(charter) if charter
else _canonical_digest({"policy": "unavailable"}))
policy_version = "dmcheck-charter/%s/%s" % (
charter.get("schema_version", "unknown"),
charter.get("charter_version", "unknown"))
native_errors = [_diagnostic(item) for item in result.errors]

if projection.blockers:
compatible = projection.compatible_count
evaluators = [{
"id": "dmcheck.adapter", "required": True, "status": "error",
"eligible": compatible, "evaluated": 0, "skipped": compatible,
"skip_reasons": [_diagnostic(item) for item in projection.blockers],
}]
else:
evaluators = _rule_coverage(result)

projected_findings = []
advisories = []
projection_errors = []
for item in result.findings:
try:
projected = _finding(item, policy_version, policy_digest)
except (KeyError, TypeError, ValueError):
projection_errors.append({
"code": "table_evaluation.finding_evidence_missing",
"message": "a native finding lacked an exact TableEvent evidence reference",
})
continue
(advisories if projected["severity"] == "advisory"
else projected_findings).append(projected)

evaluator_skipped = sum(item["skipped"] for item in evaluators)
complete = (result.status in {"clean", "findings"}
and evaluator_skipped == 0 and not projection_errors)
eligible = projection.compatible_count
evaluated = eligible if complete else 0
skipped = eligible - evaluated
native_errors.extend(projection_errors)

if not complete:
codes = {item["code"] for item in native_errors}
if any(code in {"table_event.unsupported_schema",
"table_event.unsupported_type"} for code in codes):
status = "unsupported"
elif ("evaluation.failed" in codes
or "table_evaluation.finding_evidence_missing" in codes):
status = "internal_error"
elif result.status == "invalid":
status = "invalid"
else:
status = "incomplete"
elif projected_findings:
status = "findings"
elif advisories:
status = "checked_with_advisories"
else:
status = "checked_clean"

errors = native_errors
if not complete and not errors:
errors = [{"code": "dmcheck.incomplete",
"message": "dmcheck could not complete the TableEvent evaluation"}]
evaluation_material = {
"tool": "dmcheck", "version": __version__,
"session_id": projection.session_id,
"input_digest": projection.input_digest,
"native_status": result.status,
"finding_ids": [item["finding_id"] for item in result.findings],
"error_codes": [item["code"] for item in errors],
}
evaluation_id = "dmcheck-" + _canonical_digest(evaluation_material).split(":", 1)[1][:40]
return {
"schema_version": TABLE_EVALUATION_SCHEMA_VERSION,
"evaluation_id": evaluation_id,
"tool": {"name": "dmcheck", "version": __version__},
"subject": {"kind": projection.subject_kind, "id": projection.session_id,
"session_id": (projection.session_id
if projection.subject_kind == "session" else None),
"entity_refs": []},
"status": status,
"exit_code": 0 if status == "checked_clean" else (1 if status in {
"checked_with_advisories", "findings"} else 2),
"authority_status": "self_attested",
"coverage": {
"complete": complete, "evidence_required": True,
"input": projection.event_count,
"compatible": projection.compatible_count,
"eligible": eligible, "evaluated": evaluated,
"skipped": skipped, "evaluators": evaluators,
},
"cursor": {
"checked_through_event_id": projection.checked_through_event_id,
"gap_state": ("none" if complete else
("detected" if any(item["code"] == "table_event.transport_gap"
for item in errors) else "unknown")),
"input_digest": projection.input_digest,
},
"context": {"roster_digest": None, "policy_digest": policy_digest,
"config_digest": None, "source_set_digest": None,
"session_descriptor_digest": None},
"findings": projected_findings,
"advisories": advisories,
"warnings": [],
"errors": errors,
}
Loading