← Files Agentic Course RedesignARCHIVED FILE

scripts/create_material_processing_eligibility.py

13 KB · Sep 30, 2026 · 23:14 UTC

↓ Download file

#!/usr/bin/env python3
"""Preview or atomically create one deterministic Gate-0A eligibility record."""

from __future__ import annotations

import argparse
import importlib.util
import json
import os
import tempfile
from datetime import datetime
from pathlib import Path


SCRIPT_PATH = Path(__file__).resolve()
OUTPUT_RELATIVE_PATH = Path("01_Control/material-processing-eligibility.json")


def _locate_template_path() -> Path:
    relative = Path("01_Control/material-processing-eligibility.template.json")
    candidates: list[Path] = []
    for ancestor in SCRIPT_PATH.parents:
        candidates.extend(
            (
                ancestor / "course-project-template" / relative,
                ancestor / "assets" / "project-template" / relative,
                ancestor / relative,
            )
        )
    for candidate in candidates:
        if candidate.is_file():
            return candidate
    raise FileNotFoundError(
        "material-processing eligibility template not found in shared-core, "
        "plugin assets or workspace overlay"
    )


TEMPLATE_PATH = _locate_template_path()


def _load_source_manifest_module():
    sibling = SCRIPT_PATH.with_name("source_manifest.py")
    spec = importlib.util.spec_from_file_location("_acr_source_manifest", sibling)
    if spec is None or spec.loader is None:
        raise ImportError(f"cannot load eligibility semantics from {sibling}")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


SOURCE_MANIFEST = _load_source_manifest_module()


class _MemoryRecord:
    """Minimal read-only Path interface for the existing eligibility validator."""

    def __init__(self, value: dict[str, object]):
        self._text = json.dumps(value, ensure_ascii=False)

    def is_file(self) -> bool:
        return True

    def read_text(self, encoding: str) -> str:
        if encoding.lower().replace("-", "") != "utf8":
            raise ValueError("eligibility validator requested an unexpected encoding")
        return self._text


def parse_bool(value: str) -> bool:
    lowered = value.lower()
    if lowered == "true":
        return True
    if lowered == "false":
        return False
    raise argparse.ArgumentTypeError("expected exactly true or false")


def target_is_dangerously_broad(path: Path) -> bool:
    resolved = path.resolve(strict=False)
    home = Path.home().resolve(strict=False)
    anchor = Path(resolved.anchor).resolve(strict=False)
    return resolved == anchor or resolved == home or len(resolved.parts) < 4


def _offset_datetime(value: str, label: str) -> datetime:
    try:
        parsed = datetime.fromisoformat(value.split("[", 1)[0])
    except ValueError as exc:
        raise ValueError(f"{label} must be an ISO-8601 date-time") from exc
    if parsed.tzinfo is None:
        raise ValueError(f"{label} must include a UTC offset")
    return parsed


def _require_text(value: str, label: str) -> None:
    if not value.strip():
        raise ValueError(f"{label} must not be blank")


def _infer_outcome(record: dict[str, object]) -> str:
    environment = record["environment"]
    material = record["material_scope"]
    assert isinstance(environment, dict) and isinstance(material, dict)
    category = material["declared_category"]
    sensitivity = material["sensitivity_classification"]
    assessment = material["assessment_security_classification"]

    if category == "mixed":
        return "fail_closed_until_segregated"
    if (
        category == "uncertain"
        or sensitivity == "mixed_or_uncertain"
        or assessment == "mixed_or_uncertain"
    ):
        return "fail_closed_until_clarified"
    if environment["category"] == "personal_or_unmanaged" and (
        category == "institution_internal_or_restricted"
        or material["contains_institution_internal_or_restricted_material"]
        or material["contains_student_personal_data"]
    ):
        return "route_only"
    validation = SOURCE_MANIFEST.validate_eligibility(
        _MemoryRecord(
            {
                **record,
                "status": "approved",
                "reconfirmation_required": False,
                "decision": {**record["decision"], "outcome": "proceed"},
                "fingerprint": None,
            }
        )
    )
    # Fingerprint and receipt errors are expected until the record is finalised.
    substantive = [
        error
        for error in validation["errors"]
        if error not in {
            "Gate-0A eligibility fingerprint is missing or mismatched",
            "Gate-0A lecturer declaration receipt is incomplete",
        }
    ]
    return "proceed" if not substantive else "fail_closed_until_clarified"


def build_record(args: argparse.Namespace) -> tuple[dict[str, object], dict[str, object]]:
    template = json.loads(TEMPLATE_PATH.read_text(encoding="utf-8"))
    if not isinstance(template, dict):
        raise ValueError("eligibility template must be a JSON object")
    for value, label in (
        (args.eligibility_id, "eligibility-id"),
        (args.decision_reason, "decision-reason"),
        (args.approved_processing_scope, "approved-processing-scope"),
        (args.lecturer_declaration_reference, "lecturer-declaration-reference"),
    ):
        _require_text(value, label)
    _offset_datetime(args.recorded_at, "recorded-at")
    if args.policy_expires_at:
        _offset_datetime(args.policy_expires_at, "policy-expires-at")

    environment = template["environment"]
    material = template["material_scope"]
    decision = template["decision"]
    environment.update(
        {
            "category": args.environment_category,
            "exact_environment_reference": args.exact_environment_reference,
            "institutional_policy_reference": args.institutional_policy_reference,
            "approved_scope": args.approved_scope,
            "policy_expires_at": args.policy_expires_at,
        }
    )
    material.update(
        {
            "declared_category": args.material_category,
            "ai_processing_authority_confirmed": args.ai_processing_authority_confirmed,
            "contains_institution_internal_or_restricted_material": args.contains_institution_internal_or_restricted_material,
            "contains_student_personal_data": args.contains_student_personal_data,
            "sensitivity_classification": args.sensitivity_classification,
            "assessment_security_classification": args.assessment_security_classification,
            "assessment_security_handling_authorised": args.assessment_security_handling_authorised,
        }
    )
    template.update(
        {
            "eligibility_id": args.eligibility_id,
            "lecturer_declaration_reference": args.lecturer_declaration_reference,
            "recorded_at": args.recorded_at,
        }
    )
    decision.update(
        {
            "reason": args.decision_reason,
            "approved_processing_scope": args.approved_processing_scope,
        }
    )
    outcome = _infer_outcome(template)
    template["status"] = {
        "proceed": "approved",
        "route_only": "route_only",
        "fail_closed_until_segregated": "failed_closed",
        "fail_closed_until_clarified": "failed_closed",
    }[outcome]
    template["reconfirmation_required"] = outcome != "proceed"
    decision["outcome"] = outcome
    template["fingerprint"] = SOURCE_MANIFEST.canonical_eligibility_fingerprint(template)

    intake_validation = SOURCE_MANIFEST.validate_eligibility(_MemoryRecord(template))
    if outcome == "proceed" and not intake_validation["ok"]:
        raise ValueError(
            "generated proceed record failed the canonical eligibility validator: "
            + "; ".join(intake_validation["errors"])
        )
    if outcome != "proceed" and intake_validation["ok"]:
        raise ValueError("non-proceed record unexpectedly permits source intake")
    if template["fingerprint"] != SOURCE_MANIFEST.canonical_eligibility_fingerprint(template):
        raise ValueError("generated eligibility fingerprint is not canonical")
    return template, intake_validation


def _resolve_output(project: Path) -> Path:
    if not project.is_absolute():
        raise ValueError("project must be an absolute path")
    project = project.resolve(strict=False)
    if target_is_dangerously_broad(project):
        raise ValueError(f"refusing dangerously broad project target: {project}")
    if not project.is_dir():
        raise FileNotFoundError(f"project directory does not exist: {project}")
    control = project / "01_Control"
    if not control.is_dir():
        raise FileNotFoundError(f"project control directory does not exist: {control}")
    resolved_control = control.resolve(strict=True)
    if resolved_control != control or resolved_control.parent != project:
        raise ValueError(
            "refusing redirected project control directory; 01_Control must be "
            f"the literal child of the approved project: {control}"
        )
    return resolved_control / OUTPUT_RELATIVE_PATH.name


def atomic_create(path: Path, payload: bytes) -> None:
    resolved_parent = path.parent.resolve(strict=True)
    if resolved_parent != path.parent:
        raise ValueError(
            "refusing redirected output parent before create: " + str(path.parent)
        )
    if path.exists() or path.is_symlink():
        raise FileExistsError(f"refusing to overwrite existing eligibility record: {path}")
    descriptor, temporary_name = tempfile.mkstemp(
        prefix=".material-processing-eligibility.", suffix=".tmp", dir=path.parent
    )
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "wb") as handle:
            handle.write(payload)
            handle.flush()
            os.fsync(handle.fileno())
        os.link(temporary, path)
    finally:
        temporary.unlink(missing_ok=True)


def parser() -> argparse.ArgumentParser:
    result = argparse.ArgumentParser(description=__doc__)
    result.add_argument("--project", required=True, type=Path)
    result.add_argument("--eligibility-id", required=True)
    result.add_argument(
        "--environment-category",
        required=True,
        choices=("personal_or_unmanaged", "approved_institutional_exact_environment"),
    )
    result.add_argument(
        "--material-category",
        required=True,
        choices=tuple(sorted(SOURCE_MANIFEST.ALLOWED_MATERIAL_CATEGORIES)),
    )
    result.add_argument(
        "--ai-processing-authority-confirmed", required=True, type=parse_bool
    )
    result.add_argument(
        "--contains-institution-internal-or-restricted-material", required=True, type=parse_bool
    )
    result.add_argument("--contains-student-personal-data", required=True, type=parse_bool)
    result.add_argument(
        "--sensitivity-classification",
        required=True,
        choices=tuple(SOURCE_MANIFEST.ALLOWED_SENSITIVITY_CLASSIFICATIONS),
    )
    result.add_argument(
        "--assessment-security-classification",
        required=True,
        choices=tuple(SOURCE_MANIFEST.ALLOWED_ASSESSMENT_SECURITY_CLASSIFICATIONS),
    )
    result.add_argument(
        "--assessment-security-handling-authorised", required=True, type=parse_bool
    )
    result.add_argument("--exact-environment-reference")
    result.add_argument("--institutional-policy-reference")
    result.add_argument("--approved-scope")
    result.add_argument("--policy-expires-at")
    result.add_argument("--decision-reason", required=True)
    result.add_argument("--approved-processing-scope", required=True)
    result.add_argument("--lecturer-declaration-reference", required=True)
    result.add_argument("--recorded-at", required=True)
    result.add_argument("--apply", action="store_true")
    return result


def main() -> int:
    args = parser().parse_args()
    try:
        output = _resolve_output(args.project)
        record, validation = build_record(args)
        payload = (json.dumps(record, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
        report = {
            "ok": True,
            "mode": "apply" if args.apply else "preview",
            "output": str(output),
            "relative_output": OUTPUT_RELATIVE_PATH.as_posix(),
            "would_overwrite": output.exists() or output.is_symlink(),
            "source_intake_permitted": bool(validation["ok"]),
            "source_intake_validation_errors": validation["errors"],
            "record": record,
        }
        if report["would_overwrite"]:
            raise FileExistsError(f"refusing to overwrite existing eligibility record: {output}")
        if args.apply:
            atomic_create(output, payload)
            report["created"] = True
        else:
            report["created"] = False
        print(json.dumps(report, ensure_ascii=False, indent=2))
        return 0
    except Exception as exc:
        print(
            json.dumps(
                {
                    "ok": False,
                    "mode": "apply" if args.apply else "preview",
                    "error_type": type(exc).__name__,
                    "error": str(exc),
                },
                ensure_ascii=False,
                indent=2,
            )
        )
        return 1


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 45540d84de61e6300d4a98c301abd1bd3a27491ad65ced6a5daa4a3e6afa27ac