← Files APODICTIC Development EditorARCHIVED FILE

scripts/scene_ethics.py

14.8 KB · Nov 9, 2026 · 00:53 UTC

↓ Download file

#!/usr/bin/env python3
"""scene-ethics — structural integrity for the Scene-Ethics Plan (Nonfiction Pre-Draft, Increment 4).

`validate.sh scene-ethics <run_folder|files>` shells out here. For narrative nonfiction / memoir that
depicts identifiable real people, the writer plans — before drafting — how each depiction will be
handled ethically: consent status, the handling decision (as-is / anonymize / composite / seek
consent / omit), and the fairness reasoning. Each is an apodictic.scene_ethics.v1 block. This is the
writer's ETHICAL plan, kept distinct from the Legal Risk Register (which owns LEGAL exposure); the
two cross-reference via an optional `legal_ref`. This validator owns the ethics-plan contract and
surfaces unresolved depictions.

  E1 invalid item     a scene_ethics block fails its schema (bad consent_status / handling enum,
                      malformed EP-NN id or legal_ref, missing required field, broken JSON).
  E2 duplicate id     two items share an EP-NN id.
  W1 unresolved depiction  handling=as-is AND consent not yet OBTAINED (not-sought OR sought-pending)
                      AND no fairness_check — an identifiable person depicted as-is with neither
                      consent in hand nor a fairness rationale (pending consent can still be refused,
                      with no fallback) (advisory; ERROR --strict). The signature ethics check.
                      Override (per id): <!-- override: scene-ethics-unresolved EP-NN — <rationale> -->.
  W2 no legal cross-check  an as-is depiction of an identifiable person (consent not not-applicable)
                      with no legal_ref — check it against the Legal Risk Register (advisory; ERROR
                      --strict). Realizes the ethics<->legal cross-reference.

The Firewall holds: the writer makes the ethical decisions; this surfaces what needs one. It is not
ethical adjudication and not legal advice (the Legal Risk Register owns legal exposure). Reuses
apodictic_artifacts. An artifact with no scene_ethics block is a no-op. See docs/nonfiction-pre-draft.md.

  scene_ethics.py scene-ethics <run_folder|files...> [--strict]
  scene_ethics.py --self-test

Exit: 0 clean / WARN-only, 1 ERROR (or WARN under --strict), 2 usage.
"""
import glob
import os
import re
import sys

from override_marker import override_targets  # SSoT: code-span-stripped, boundary-matched override scan

try:
    import apodictic_artifacts as art
except ImportError:
    art = None


def _has_block(text, btype):
    """True if `text` carries a real apodictic:<btype> block (a parsed carrier, not a prose mention).

    Classifying on parsed blocks — not a raw substring — keeps a file that merely *names* the marker
    in prose from being misrouted/skipped (the 2026-06-20 resolver-hardening sweep). Gated by
    validate.sh validator-conventions (M2)."""
    if art is None:
        return ("apodictic:%s" % btype) in (text or "")
    return any(bt == btype for bt, _o, _e in art.parse_blocks(text or ""))

_SCHEMA_ID = "apodictic.scene_ethics.v1"
_PLAN_GLOB = "*_Scene_Ethics_Plan_*.md"
# Override markers naming an EP id route through the shared override_marker SSoT (code spans stripped,
# slug boundary-matched): "<!-- override: scene-ethics-unresolved EP-01 — ... -->".


def _read(path):
    try:
        with open(path, encoding="utf-8") as fh:
            return fh.read()
    except (OSError, UnicodeDecodeError):
        return None


def _overrides(text, slug):
    """The set of EP-NN ids overridden for `slug` — via the shared SSoT, so a marker quoted inside a
    code span is not honored as a live directive."""
    return {t[0] for t in override_targets(text, slug, r"(EP-[0-9]+)")}


def parse_items(text):
    """[(obj_or_None, schema_errs, index), ...] for each apodictic:scene_ethics block."""
    items = []
    if not text or art is None:
        return items
    schema = art.load_schema(_SCHEMA_ID)
    idx = 0
    for btype, obj, jerr in art.parse_blocks(text):
        if btype != "scene_ethics":
            continue
        idx += 1
        where = "scene_ethics #%d" % idx
        if jerr:
            items.append((None, ["%s: invalid JSON — %s" % (where, jerr)], idx))
            continue
        items.append((obj, art.validate_obj(obj, schema, where), idx))
    return items


def plan(text, strict=False):
    """Run the Scene-Ethics Plan integrity checks. Returns (code, lines)."""
    lines, errs, warns = [], [], []
    items = parse_items(text)
    if not items:
        return 0, ["scene-ethics: no scene_ethics blocks found — nothing to check"]

    # E1 — schema / JSON validity
    for _obj, schema_errs, _idx in items:
        for e in schema_errs:
            errs.append("E1 invalid item: %s" % e)
    valid = [(obj, idx) for obj, schema_errs, idx in items if obj is not None and not schema_errs]

    # E2 — duplicate id
    seen = {}
    for obj, idx in valid:
        seen.setdefault(obj.get("id"), []).append(idx)
    by_id = {}
    for eid, where in sorted(seen.items()):
        if len(where) > 1:
            errs.append("E2 duplicate id: %s appears %d times (ids must be unique)" % (eid, len(where)))
        by_id[eid] = next(o for o, _ in valid if o.get("id") == eid)

    unresolved_overrides = _overrides(text, "scene-ethics-unresolved")
    legalcheck_overrides = _overrides(text, "scene-ethics-legalcheck")
    for eid, obj in sorted(by_id.items()):
        as_is = obj.get("handling") == "as-is"
        consent = obj.get("consent_status")
        has_fairness = bool((obj.get("fairness_check") or "").strip())
        # W1 — unresolved depiction (the ethics signature). An as-is depiction is unresolved while
        # consent is not yet OBTAINED — both not-sought AND sought-pending count (pending can still be
        # refused, with no fallback), unless a fairness rationale is given. (PR #45 review.)
        if (as_is and consent in ("not-sought", "sought-pending") and not has_fairness
                and eid not in unresolved_overrides):
            warns.append("W1 unresolved depiction: %s is depicted as-is with consent %s (not obtained) "
                         "and no fairness rationale — resolve it (obtain consent, add a fairness "
                         "rationale, or choose a fallback: anonymize / composite / seek-consent / "
                         "omit) before drafting" % (eid, consent))
        # W2 — no legal cross-check on an as-is identifiable depiction
        if (as_is and consent != "not-applicable" and not obj.get("legal_ref")
                and eid not in legalcheck_overrides):
            warns.append("W2 no legal cross-check: %s is an as-is depiction with no legal_ref — "
                         "check it against the Legal Risk Register (defamation / privacy / rights)" % eid)

    # Report
    lines.append("scene-ethics: %d depiction(s)%s" % (
        len(items), "" if len(valid) == len(items) else " (%d well-formed)" % len(valid)))
    for obj, _idx in valid:
        lines.append("  %-7s consent=%-13s handling=%s" % (obj.get("id"), obj.get("consent_status"),
                                                           obj.get("handling")))
    for e in errs:
        lines.append("  ERROR: %s" % e)
    for w in warns:
        lines.append("  %s: %s" % ("ERROR (--strict)" if strict else "WARN", w))

    if errs or (strict and warns):
        lines.append("scene-ethics: FAIL (%d error(s)%s)"
                     % (len(errs), ", %d strict warn(s)" % len(warns) if (strict and warns) else ""))
        return 1, lines
    if warns:
        lines.append("WARN: scene-ethics: %d advisory gap(s) — see W1/W2 above" % len(warns))
    else:
        lines.append("scene-ethics: PASS (ethics-plan contract + resolved depictions)")
    return 0, lines


# ---------------------------------------------------------------- resolution

def _newest(paths):
    return max(paths, key=os.path.getmtime) if paths else None


def resolve(paths):
    if len(paths) == 1 and os.path.isdir(paths[0]):
        return _newest(glob.glob(os.path.join(paths[0], _PLAN_GLOB)))
    for p in paths:
        if _has_block(_read(p) or "", "scene_ethics"):
            return p
    return paths[0] if paths else None


def run(paths, strict=False):
    path = resolve(paths)
    if not path:
        return 2, ["scene-ethics: no Scene-Ethics Plan artifact found (need a *_Scene_Ethics_Plan_*.md "
                   "or a file with apodictic:scene_ethics blocks)"]
    text = _read(path)
    if text is None:
        return 2, ["scene-ethics: cannot read %s" % path]
    return plan(text, strict=strict)


# ---------------------------------------------------------------- self-test

def run_self_test():
    import json as _j
    rc = {"v": 0}

    def chk(name, cond):
        print("  %s: %s" % (name, "OK" if cond else "FAIL"))
        if not cond:
            rc["v"] = 1

    # non-UTF8 artifact: _read must degrade to the unreadable path (None), never
    # a traceback (the disposition_check adjacent-exception class, swept repo-wide)
    import tempfile as _tf
    _fd, _nu = _tf.mkstemp(suffix=".md")
    with os.fdopen(_fd, "wb") as _fh:
        _fh.write(b"\xff\xfenot utf-8\xff")
    chk("non_utf8_read_returns_none", _read(_nu) is None)
    os.unlink(_nu)

    def item(eid, subject="the narrator's former manager (named)", depiction="portrayed making a remark",
             consent="not-sought", handling="anonymize", fairness=None, legal=None):
        obj = {"schema": _SCHEMA_ID, "id": eid, "subject": subject, "depiction": depiction,
               "consent_status": consent, "handling": handling}
        if fairness is not None:
            obj["fairness_check"] = fairness
        if legal is not None:
            obj["legal_ref"] = legal
        return "<!-- apodictic:scene_ethics\n%s\n-->" % _j.dumps(obj)

    # clean: anonymized (mitigated) -> no W1/W2
    chk("clean_anonymize", plan(item("EP-01", handling="anonymize"))[0] == 0)
    # no block -> no-op
    chk("no_items_noop", plan("# notes\nno depictions\n")[0] == 0)

    # E1 — bad enum / id / missing field / bad legal_ref / JSON
    chk("e1_bad_consent", plan(item("EP-01", consent="maybe"))[0] == 1)
    chk("e1_bad_handling", plan(item("EP-01", handling="publish"))[0] == 1)
    chk("e1_bad_id", plan(item("EP-1"))[0] == 1)
    chk("e1_bad_legal_ref", plan(item("EP-01", legal="LR1"))[0] == 1)
    chk("e1_missing_field", plan(item("EP-01").replace('"depiction"', '"depic"'))[0] == 1)
    code, lines = plan('<!-- apodictic:scene_ethics\n{"schema":"apodictic.scene_ethics.v1"\n-->')
    chk("e1_bad_json", code == 1 and any("E1 invalid item" in ln for ln in lines))

    # E2 — duplicate id
    code, lines = plan(item("EP-01", handling="omit") + "\n" + item("EP-01", handling="composite"))
    chk("e2_duplicate_id", code == 1 and any("E2 duplicate" in ln for ln in lines))

    # W1 — unresolved depiction: as-is + not-sought + no fairness => advisory; ERROR --strict; override
    code, lines = plan(item("EP-01", handling="as-is", consent="not-sought"))
    chk("w1_unresolved", code == 0 and any("W1 unresolved depiction" in ln for ln in lines))
    chk("w1_unresolved_strict_fails",
        plan(item("EP-01", handling="as-is", consent="not-sought"), strict=True)[0] == 1)
    # a fairness rationale resolves W1 (but W2 still wants a legal_ref — provide one to be clean)
    chk("w1_fairness_resolves",
        not any("W1" in ln for ln in plan(item("EP-01", handling="as-is", consent="not-sought",
                                               fairness="shown in full context with the subject's rationale",
                                               legal="LR-01"))[1]))
    # consent obtained resolves W1
    chk("w1_consent_resolves",
        not any("W1" in ln for ln in plan(item("EP-01", handling="as-is", consent="obtained", legal="LR-02"))[1]))
    # sought-pending is NOT resolved for an as-is depiction (PR #45 review) — even with a legal_ref
    # clearing W2, W1 must still fire (consent can be refused, with no fallback)
    code, lines = plan(item("EP-01", handling="as-is", consent="sought-pending", legal="LR-01"))
    chk("w1_pending_unresolved", code == 0 and any("W1 unresolved depiction" in ln for ln in lines))
    chk("w1_pending_strict_fails",
        plan(item("EP-01", handling="as-is", consent="sought-pending", legal="LR-01"), strict=True)[0] == 1)
    # a fairness rationale resolves a pending as-is depiction
    chk("w1_pending_fairness_resolves",
        not any("W1" in ln for ln in plan(item("EP-01", handling="as-is", consent="sought-pending",
                                               fairness="context + the subject's rationale shown", legal="LR-02"))[1]))
    ov = "<!-- override: scene-ethics-unresolved EP-01 — public-record quote, fairness in the draft -->\n"
    chk("w1_override",
        not any("W1" in ln for ln in plan(ov + item("EP-01", handling="as-is", consent="not-sought", legal="LR-03"))[1]))

    # W2 — as-is identifiable depiction with no legal_ref => advisory; override; cleared by legal_ref
    code, lines = plan(item("EP-01", handling="as-is", consent="obtained"))   # resolved ethically, but no legal_ref
    chk("w2_no_legal_ref", code == 0 and any("W2 no legal cross-check" in ln for ln in lines))
    chk("w2_legal_ref_clears",
        not any("W2" in ln for ln in plan(item("EP-01", handling="as-is", consent="obtained", legal="LR-01"))[1]))
    # not-applicable consent (public figure acting publicly) as-is needs no legal cross-check
    chk("w2_not_applicable_ok",
        not any("W2" in ln for ln in plan(item("EP-01", handling="as-is", consent="not-applicable"))[1]))
    chk("w2_strict_fails",
        plan(item("EP-01", handling="as-is", consent="obtained"), strict=True)[0] == 1)
    # anonymized handling needs no legal cross-check (mitigated)
    chk("w2_anonymize_no_w2",
        not any("W2" in ln for ln in plan(item("EP-01", handling="anonymize"))[1]))

    # run-folder + explicit-file resolution
    import tempfile
    import shutil
    d = tempfile.mkdtemp()
    try:
        p = os.path.join(d, "Proj_Scene_Ethics_Plan_run.md")
        with open(p, "w", encoding="utf-8", newline="") as fh:
            fh.write("# Scene Ethics Plan\n" + item("EP-01", handling="anonymize") + "\n")
        chk("run_folder_resolution", run([d])[0] == 0)
        chk("explicit_file_resolution", run([p])[0] == 0)
        chk("missing_artifact_usage", run([os.path.join(d, "nope.md")])[0] == 2)
    finally:
        shutil.rmtree(d, ignore_errors=True)

    print("Self-test: PASS" if rc["v"] == 0 else "Self-test: FAIL")
    return rc["v"]


def main(argv):
    if "--self-test" in argv:
        return run_self_test()
    args = [a for a in argv[1:] if a != "scene-ethics"]
    strict = "--strict" in args
    paths = [a for a in args if not a.startswith("--")]
    if not paths:
        print("Usage: scene_ethics.py scene-ethics <run_folder|files...> [--strict] | --self-test")
        return 2
    code, lines = run(paths, strict=strict)
    for ln in lines:
        print(ln)
    return code


if __name__ == "__main__":
    sys.exit(main(sys.argv))

SHA-256: 55b44eabfaf701f060069fb3713bcaaf9c0fb739a072ff4cc39a09fa319f382e