diff --git a/system/developer/components/operation-pipeline.md b/system/developer/components/operation-pipeline.md index b54080c..443da02 100644 --- a/system/developer/components/operation-pipeline.md +++ b/system/developer/components/operation-pipeline.md @@ -47,6 +47,11 @@ Used by: `ai-delivery`, `cli-adapter`, `operation-handlers`, `plan-gate`. ## Invariants - Never dispatch an unadmitted write or bypass snapshot/replay checks. Pinning: `tests/test_ops_pipeline.py`. +- Declare `OperationEffects` per operation independently of invocation kind. Pinning: `tests/test_agent_run.py`. + Only `READ` permits the runner to schedule a read. `DERIVED_WRITE` includes + optional report, cache, staging and export output; `CANONICAL_WRITE` includes + canonical records and Git/session mutations. The default is `UNKNOWN`, a + write barrier. This scheduling metadata grants no write authority. Admission lives in `system/contracts/capabilities.yaml`; `app/admission.py` constants `GESTURE_ALLOWLIST` and `UI_REVIEWED_ALLOWLIST` enforce it. The UI mirrors diff --git a/system/procedures/agent-plans.md b/system/procedures/agent-plans.md index b9e2365..ef6e0ab 100644 --- a/system/procedures/agent-plans.md +++ b/system/procedures/agent-plans.md @@ -52,6 +52,15 @@ The default worker count is one. Opt in to two through four workers only for measured workloads; process startup can dominate small plans. Read waves stop at every write or unknown-effect operation. `deadline_s` stops admission and cancellable reads, while an admitted commit is allowed to finish. +Only operations explicitly declaring `read` effects in the executing Core are +reads. Derived writes, canonical writes (including `session-end`), and unknown effects +require a plan `session_id` and cannot run after an unresolved write, for both +`argv` and `use` steps. Describing an operation remains a read. +Every dispatched write starts with uncertain commit status, including porcelain. +A receipt establishes commitment; an explicit gateway refusal can establish no +commit. A porcelain error's generic `commit: none` cannot prove that it had no +side effects. Without reliable evidence, later writes are skipped even under +`on_error: continue`; independent reads can still run. `report_max_bytes` caps the entire stdout report. The `full_report` pointer leads to a private report outside canonical data, with the approved plan, sealed diff --git a/tests/test_agent_run.py b/tests/test_agent_run.py index 3c4ffca..71d2407 100644 --- a/tests/test_agent_run.py +++ b/tests/test_agent_run.py @@ -679,6 +679,190 @@ def unnecessary(*_): assert report['totals']['ok'] == 1, report +@pytest.mark.parametrize(("kind", "effects", "writer"), [ + (None, "unknown", True), ("unknown", "unknown", True), + ("porcelain", "unknown", True), ("command", "canonical-write", True), + ("query", "unknown", True), ("tool", "unknown", True), + ("tool", "derived-write", True), ("query", "read", False), + ("tool", "read", False), ("porcelain", "read", False), +]) +def test_write_classification_is_conservative_and_independent_of_task_resolution(mini_repo, monkeypatch, kind, effects, writer): + from dataclasses import replace + + from learning_os.app.registry import REGISTRY, OperationEffects + + executor = agent_run._cli_executor_factory(mini_repo.resolve()) + original = REGISTRY["project.inspect"] + if kind is None: + monkeypatch.delitem(REGISTRY, "project.inspect") + else: + monkeypatch.setitem(REGISTRY, "project.inspect", replace(original, kind=kind, effects=OperationEffects(effects))) + direct = agent_run.build_index(mini_repo, executor, resolve_tasks=False) + expanded = agent_run.build_index(mini_repo, executor, resolve_tasks=True) + assert set(direct["write_verbs"]) == set(expanded["write_verbs"]) + for index in (direct, expanded): + assert {"session-end", "generate", "ai-action-prepare", "ai-action-append-slices", + "ai-action-import-delivery"}.issubset(index["write_verbs"]) + assert ("inspect" in index["write_verbs"]) is writer + assert "bootstrap" not in index["write_verbs"] + assert "capabilities" not in index["write_verbs"] + + +def test_query_and_tool_effects_are_explicit_and_file_outputs_are_writers(mini_repo): + from learning_os.app.registry import REGISTRY, OperationEffects + + executor = agent_run._cli_executor_factory(mini_repo.resolve()) + assert not [op.name for op in REGISTRY.values() + if op.kind in {"query", "tool"} and op.effects == OperationEffects.UNKNOWN] + writers = set(agent_run.build_index(mini_repo, executor, resolve_tasks=False)["write_verbs"]) + assert {"validate", "note-analysis-prepare", "backup-manifest", "backup-verify", "search", "dossier"} <= writers + assert REGISTRY["ai-action.delivery.validate"].effects == OperationEffects.READ + assert "ability-context" not in writers # Explicit read despite porcelain invocation. + with pytest.raises(agent_run.AgentRunError) as error: + agent_run.run_plan({"steps": [{"id": "validation", "argv": ["validate"]}]}, mini_repo) + assert error.value.code == "SESSION_REQUIRED" + + +@pytest.mark.parametrize("form", ["argv", "prefixed", "use"]) +@pytest.mark.parametrize("mixed", [False, True]) +def test_session_end_commit_push_requires_plan_session_before_dispatch(mini_repo, monkeypatch, form, mixed): + from types import SimpleNamespace + + from learning_os.adapters import cli + + calls = [] + monkeypatch.setattr(cli, "invoke_outcome", lambda ns: ( + calls.append(ns.command) or SimpleNamespace(exit_code=0, stdout='{"ok":true}', stderr=""))) + args = ["--commit-message", "fixture commit", "--push"] + step = ({"id": "end", "use": "session.end", "args": args} if form == "use" else + {"id": "end", "argv": [*(["--lock-timeout", "1"] if form == "prefixed" else []), "session-end", *args]}) + step["write"] = False # A presentation hint cannot weaken registry effects. + steps = ([{"id": "read", "use": "inspect", "args": ["note-demo"]}] if mixed else []) + [step] + try: + report = agent_run.run_plan({"steps": steps}, mini_repo) + except agent_run.AgentRunError as exc: + assert exc.code == "SESSION_REQUIRED" + else: + assert report["steps"][-1]["error"]["code"] == "SESSION_REQUIRED", report + assert calls == [] + + +@pytest.mark.parametrize("use", [False, True]) +def test_uncertain_write_blocks_porcelain_but_allows_independent_read(mini_repo, monkeypatch, use): + from types import SimpleNamespace + + from learning_os.adapters import cli + + calls = [] + + def lost_write_response(argv, **kwargs): + assert any(str(arg).endswith("/seal_envelope.py") for arg in argv) + calls.append("seal") + return subprocess.CompletedProcess(argv, 1, "", "fixture lost write response") + + def dispatch(namespace): + calls.append(namespace.command) + return SimpleNamespace(exit_code=0, stdout='{"ok":true}', stderr="") + + monkeypatch.setattr(agent_run.subprocess, "run", lost_write_response) + monkeypatch.setattr(cli, "invoke_outcome", dispatch) + step = ({"id": "end", "use": "session-end", "args": ["--commit-message", "fixture", "--push"]} + if use else {"id": "end", "argv": ["session-end", "--commit-message", "fixture", "--push"]}) + report = agent_run.run_plan({"session_id": "classification-fixture", "on_error": "continue", "steps": [ + {"id": "write", "kind": "log", "text": "fixture", "key": "classification-fixture"}, + step, {"id": "read", "argv": ["inspect", "note-demo"]}, + ]}, mini_repo) + assert calls == ["seal", "inspect"], report + assert report["steps"][0]["commit_certainty"] == "uncertain" + assert report["steps"][1]["status"] == "skipped" + assert report["steps"][1]["error"]["code"] == "PLAN_STOPPED" + assert report["steps"][2]["status"] == "ok" + + +def test_describing_porcelain_requires_no_write_session(mini_repo): + report = agent_run.run_plan({"steps": [ + {"id": "describe", "use": "session-end", "describe": True}, + ]}, mini_repo) + assert report["totals"]["ok"] == 1, report + assert report["steps"][0]["commit_certainty"] == "none" + + +@pytest.mark.parametrize("use", [False, True]) +def test_session_commit_then_failed_push_blocks_later_writes(mini_repo, use): + from test_session_ledger import _git_init, _sealed_capture + + from learning_os.store import session_ledger as session_store + + session = "runner-partial-porcelain" + _git_init(mini_repo) + _sealed_capture(mini_repo, key="before-partial-porcelain", text="fixture capture", session_id=session) + execute = agent_run._cli_executor_factory(mini_repo.resolve(), session_id=session) + code, _, err = execute(["session-end", "--session-id", session], mini_repo) + assert code == 0, err + before = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=mini_repo, text=True) + receipts = set((mini_repo / "operations/transactions").glob("transaction-*.yaml")) + # The fixture has no remote: commit is real and push fails without network access. + args = ["--commit-message", "fixture partial completion", "--push"] + step = ({"id": "end", "use": "session-end", "args": args} if use else + {"id": "end", "argv": ["session-end", *args]}) + report = agent_run.run_plan({"session_id": session, "on_error": "continue", "steps": [ + step, {"id": "later", "kind": "log", "text": "must not run", "key": "after-partial-porcelain"}, + {"id": "read", "argv": ["inspect", "note-demo"]}, + ]}, mini_repo) + assert subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=mini_repo, text=True) != before + assert session_store.load_session_ledger(mini_repo, session)["state"] == "committed_unpushed" + assert set((mini_repo / "operations/transactions").glob("transaction-*.yaml")) == receipts + assert report["steps"][0]["status"] == "error", report + assert report["steps"][0]["commit_certainty"] == "uncertain" + assert report["steps"][1]["status"] == "skipped" + assert report["steps"][1]["error"]["code"] == "PLAN_STOPPED" + assert report["steps"][2]["status"] == "ok" + + +@pytest.mark.parametrize("response", ["exception", "error-with-none", "success-without-evidence"]) +def test_porcelain_without_commit_evidence_retains_uncertainty(mini_repo, monkeypatch, response): + from types import SimpleNamespace + + from learning_os.adapters import cli + + calls = [] + + def dispatch(namespace): + calls.append(namespace.command) + if namespace.command == "session-end": + if response == "exception": + raise RuntimeError("fixture transport failed after dispatch") + if response == "error-with-none": + return SimpleNamespace(exit_code=1, stdout='{"ok":false,"commit":"none"}', stderr="fixture failed") + return SimpleNamespace(exit_code=0, stdout='{"ok":true}', stderr="") + + monkeypatch.setattr(cli, "invoke_outcome", dispatch) + report = agent_run.run_plan({"session_id": "porcelain-evidence", "on_error": "continue", "steps": [ + {"id": "end", "argv": ["session-end"]}, + {"id": "later", "argv": ["generate"]}, + {"id": "read", "argv": ["inspect", "note-demo"]}, + ]}, mini_repo) + assert calls == ["session-end", "inspect"], report + assert report["steps"][0]["commit_certainty"] == "uncertain" + assert report["steps"][1]["status"] == "skipped" + assert report["steps"][2]["status"] == "ok" + + +@pytest.mark.parametrize("receipt", [False, True]) +def test_gateway_refusal_cannot_erase_commit_receipt(mini_repo, monkeypatch, receipt): + payload = {"ok": False, "commit": "none"} + if receipt: + payload.update(_receipt(mini_repo)) + payload["ok"] = False + monkeypatch.setattr(agent_run.subprocess, "run", lambda argv, **kwargs: + subprocess.CompletedProcess(argv, 1, json.dumps(payload), "fixture refusal")) + report = agent_run.run_plan({"session_id": "gateway-evidence", "steps": [ + {"id": "write", "kind": "log", "text": "fixture", "key": "gateway-evidence"}, + ]}, mini_repo) + assert report["steps"][0]["status"] == "error" + assert report["steps"][0]["commit_certainty"] == ("committed" if receipt else "none") + + def test_external_feed_reads_are_neither_memoized_nor_parallel(mini_repo, tmp_path): feed = tmp_path / 'external-feed.json' feed.write_text('{}') diff --git a/tests/test_projection_readiness.py b/tests/test_projection_readiness.py index d78512b..bed4c4d 100644 --- a/tests/test_projection_readiness.py +++ b/tests/test_projection_readiness.py @@ -6,6 +6,8 @@ import sys from pathlib import Path +import pytest + from learning_os.app.projection_readiness import ( dependency_ready, observe, @@ -64,3 +66,26 @@ def test_forged_receipt_is_not_completion(mini_repo): path.parent.mkdir(parents=True, exist_ok=True) path.write_text("status: committed\nsnapshot_after: sha256:fake\n") assert not receipt_completed(mini_repo, str(path.relative_to(mini_repo)), "sha256:fake") + + +@pytest.mark.parametrize("relative", [ + "knowledge/notes/fake.yaml", "operations/transactions/../../knowledge/fake.yaml", + "operations/transactions-other/fake.yaml", "../outside.yaml", +]) +def test_receipt_paths_are_rejected_before_authority_read(mini_repo, monkeypatch, relative): + from learning_os.app import projection_readiness + + def unexpected(*args): + pytest.fail("receipt outside the transaction boundary was opened") + + monkeypatch.setattr(projection_readiness, "_completed", unexpected) + assert not receipt_completed(mini_repo, relative, "sha256:fake") + + +def test_receipt_symlink_cannot_escape_transaction_boundary(mini_repo): + target = mini_repo / "knowledge/fake-receipt.yaml" + target.write_text("status: committed\nsnapshot_after: sha256:fake\n") + path = mini_repo / "operations/transactions/linked.yaml" + path.parent.mkdir(parents=True, exist_ok=True) + path.symlink_to(target) + assert not receipt_completed(mini_repo, str(path.relative_to(mini_repo)), "sha256:fake") diff --git a/tools/README.md b/tools/README.md index 537d011..8c7b572 100644 --- a/tools/README.md +++ b/tools/README.md @@ -51,7 +51,7 @@ runtime coverage; the exact roots and historical allowlist live in | `diagnostics_prune.py` | bounded maintenance | Prunes the disposable diagnostics trace store to its retention bound. | | `affected_tests.py` | supported test tool | Maps changed files to test groups for `make test-affected`; a rerun minimizer, never a gate. | | `seal_envelope.py` | bounded authoring tool | Seals one GatewayEnvelopeV2 from caller intent (WORKFLOWS §25c); reads the repo, writes nothing canonical. | -| `agent_run.py` | experimental operator runner | One call batches CLI steps over an explicitly selected data root. Log/gateway plans require a stable `session_id`; logs also require unique explicit retry keys. Presealed gateway files must bind the same session, and approved bytes are preserved. `on_error` defaults to `stop`, reporting skipped steps; `continue` keeps independent steps running. Dependent reads require a projection from a successful write at or after the latest required write. Missing/pending readiness and unknown snapshots refuse. Nonfinite output fails its step; summaries name `_missing_fields` under their UTF-8 byte budget. Optional typed preflight reuses the pinned validation cache and refuses every validation error. Cluster previews retain counts and a supported expansion. Bounded read sessions memoize eligible reads under manifest pins; `read_workers` (1-4, default 1) runs contiguous eligible read waves in isolated processes; `deadline_s` (default 120) stops admission; `report_max_bytes` bounds the whole report with a private full-report artifact on every run (`--report-dir` chooses its parent). `--expand-report PATH [--step ID]` verifies report and raw-output digests before expansion. No bulk learner decisions. | +| `agent_run.py` | experimental operator runner | One call batches CLI steps over an explicitly selected data root. Plans with derived, canonical or undeclared effects require a stable `session_id`; only operations explicitly declaring read effects count as reads, consistently for `argv` and `use`; logs also require unique explicit retry keys. Presealed gateway files must bind the same session, and approved bytes are preserved. `on_error` defaults to `stop`, reporting skipped steps; `continue` keeps independent steps running. Dependent reads require a projection from a successful write at or after the latest required write. Missing/pending readiness and unknown snapshots refuse. Nonfinite output fails its step; summaries name `_missing_fields` under their UTF-8 byte budget. Optional typed preflight reuses the pinned validation cache and refuses every validation error. Cluster previews retain counts and a supported expansion. Bounded read sessions memoize eligible reads under manifest pins; `read_workers` (1-4, default 1) runs contiguous eligible read waves in isolated processes; `deadline_s` (default 120) stops admission; `report_max_bytes` bounds the whole report with a private full-report artifact on every run (`--report-dir` chooses its parent). `--expand-report PATH [--step ID]` verifies report and raw-output digests before expansion. No bulk learner decisions. | | `capture_world.py` | supported contract tool | Captures one world (data, pins, observations, material inventory) for captured-world identity (SPEC §6). | | `compare_world.py` | supported gate | Compares two revisions against one captured world; exits 0 on identity, 1 on difference (SPEC §6). | diff --git a/tools/agent_run.py b/tools/agent_run.py index f29d0ed..04805cc 100644 --- a/tools/agent_run.py +++ b/tools/agent_run.py @@ -3,9 +3,11 @@ ``use`` accepts a task, operation id or verb; ``describe: true`` reads its contract. Log steps require an explicit, unique ``key`` stable across retries. -Log/gateway plans require a stable ``session_id``; presealed gateway envelopes +Write plans require a stable ``session_id``; presealed gateway envelopes must already bind that session. ``on_error`` defaults to ``stop``; explicitly choose ``continue`` to keep running independent steps after a failure. +Only operations explicitly declaring read effects are reads. Derived, canonical +and unknown effects are write barriers for both ``argv`` and ``use``. Dependencies mean completion of successful receipted writes, not persistence of their effects after later writes. Before a dependent read, the current projection must match a successful write at or after the latest required write @@ -118,6 +120,7 @@ def build_index(root: Path, executor, *, resolve_tasks: bool = True) -> dict: if str(TOOLS) not in sys.path: sys.path.insert(0, str(TOOLS)) from learning_os.adapters.cli_parser import build_parser + from learning_os.app.registry import REGISTRY, OperationEffects parser = getattr(executor, "parser", None) or build_parser() choices = parser._subparsers._group_actions[0].choices # noqa: SLF001 @@ -126,18 +129,15 @@ def build_index(root: Path, executor, *, resolve_tasks: bool = True) -> dict: operation = getattr(sub, "_defaults", {}).get("_operation") if operation: verb_to_operation[name] = operation + # The executing parser/registry owns effects for every plan shape. Only + # an explicitly declared read is a read. Neither invocation kind nor + # task discovery can weaken the default barrier for undeclared effects. + index = {"verbs": sorted(choices), "verb_to_operation": verb_to_operation, + "task_to_use": {}, "write_verbs": [name for name in choices + if (entry := REGISTRY.get(verb_to_operation.get(name))) is None + or entry.effects != OperationEffects.READ]} if not resolve_tasks: - from learning_os.app.registry import REGISTRY - - # Explicit argv and log steps need no task prose/catalogue expansion. - # Classification comes from the same runtime registry as dispatch; - # an unregistered legacy command is conservatively a write barrier. - return {"verbs": sorted(choices), "verb_to_operation": verb_to_operation, - "task_to_use": {}, "write_verbs": [name for name in choices - if (entry := REGISTRY.get(verb_to_operation.get(name))) is None - or entry.kind == "command"]} - # Full catalogue rows carry authoritative writer classification; the - # compact surface intentionally retains only verb names. + return index # Explicit argv/log steps need no task prose expansion. if hasattr(executor, "parser"): from learning_os.store.contracts.verb_catalogue import ( VerbCatalogueError, @@ -154,9 +154,6 @@ def build_index(root: Path, executor, *, resolve_tasks: bool = True) -> dict: if code: raise AgentRunError(f"capability discovery failed: {err.strip()}", DISCOVERY_FAILED) compact = json.loads(out) - for row in compact.get("verbs", []): - if isinstance(row, dict) and row["verb"] in choices: - verb_to_operation[row["verb"]] = row["operation"] task_to_use = {} operation_to_verb = {op: v for v, op in verb_to_operation.items()} for task in compact.get("tasks", []): @@ -168,10 +165,8 @@ def build_index(root: Path, executor, *, resolve_tasks: bool = True) -> dict: task_to_use[task["id"]] = operation_to_verb[tokens[1]] elif tokens[0] in verb_to_operation: task_to_use[task["id"]] = tokens[0] - return {"verbs": sorted(choices), "verb_to_operation": verb_to_operation, - "task_to_use": task_to_use, - "write_verbs": [row["verb"] for row in compact.get("verbs", []) - if isinstance(row, dict) and (row.get("writes") is True or row.get("kind") == "command")]} + index["task_to_use"] = task_to_use + return index def _field(payload, name): @@ -628,7 +623,7 @@ def run_plan(plan: dict, root: Path, *, executor=None, poll_s: float = 0.1, compiled = [] for step in steps: direct = (step.get("argv") or [step.get("use")])[0] - if session_id is None and direct in writers: + if session_id is None and direct in writers and not step.get("describe"): raise AgentRunError("write plans require an explicit session_id stable across retries", SESSION_REQUIRED) try: argv = _step_argv(step, index, session_id) @@ -642,7 +637,7 @@ def run_plan(plan: dict, root: Path, *, executor=None, poll_s: float = 0.1, actual.envelope_bytes[tuple(argv)] = Path(namespace.payload_file).read_bytes() writer = command in writers or step.get("kind") == "log" or step.get("write", False) if writer and session_id is None: - raise AgentRunError("write plans require an explicit session_id stable across retries") + raise AgentRunError("write plans require an explicit session_id stable across retries", SESSION_REQUIRED) operation = index.get("verb_to_operation", {}).get(command) entry = REGISTRY.get(operation) profile = entry.memo_profile if entry is not None else "none" diff --git a/tools/learning_os/app/agent_plans.py b/tools/learning_os/app/agent_plans.py index 77de0a3..faa197f 100644 --- a/tools/learning_os/app/agent_plans.py +++ b/tools/learning_os/app/agent_plans.py @@ -100,7 +100,7 @@ def consume(step: CompiledStep, raw: RawOutcome, begin: float, nonlocal processes processes += raw.processes row: dict[str, object] = {"id": step.id, "status": "ok", "exit": raw.code, - "ready_wait_ms": ready_ms, "commit_certainty": "uncertain" if step.submitting else "none", + "ready_wait_ms": ready_ms, "commit_certainty": "uncertain" if step.writer else "none", "raw_bytes": len(raw.stdout.encode("utf-8"))} try: try: @@ -118,8 +118,11 @@ def consume(step: CompiledStep, raw: RawOutcome, begin: float, detail = payload.get("error") if isinstance(payload, dict) else None row.update(status="error", error=detail if isinstance(detail, dict) else { "code": STEP_FAILED, "retryable": False, "stderr": raw.stderr[-400:]}) - # A complete gateway refusal explicitly states no commit. - if isinstance(payload, dict) and payload.get("commit") == "none": + # Only a gateway refusal proves no commit. Porcelain may + # have changed files or Git before returning a generic failure. + # A receipt remains stronger evidence than a refusal field. + if (step.submitting and isinstance(payload, dict) and payload.get("commit") == "none" + and row["commit_certainty"] != "committed"): row["commit_certainty"] = "none" elif step.writer or snapshot: writes[step.id] = payload @@ -256,7 +259,7 @@ def consume(step: CompiledStep, raw: RawOutcome, begin: float, except Exception as exc: row = fail_row(step, "error", getattr(exc, "code", STEP_FAILED), message=str(exc)) # Dispatch may have crossed the commit boundary before transport failed. - if step.submitting: + if step.writer: row["commit_certainty"] = "uncertain" retain(step, row) index += 1 diff --git a/tools/learning_os/app/handlers/admin.py b/tools/learning_os/app/handlers/admin.py index c145034..3f40051 100644 --- a/tools/learning_os/app/handlers/admin.py +++ b/tools/learning_os/app/handlers/admin.py @@ -14,7 +14,7 @@ from learning_os.app.gateway import read_content_bound_file as _read_content_bound_file from learning_os.app.handlers._shared import render_creation_check as _render_creation_check from learning_os.app.proposal import ResponsePayload, Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.semesters import semester_plan as _semester_plan from learning_os.domain.failures import NotFound, WriteRefused from learning_os.domain.render import parse_sections @@ -44,7 +44,7 @@ -@register("module.attempt.record", kind="command") +@register("module.attempt.record", kind="command", effects=OperationEffects.CANONICAL_WRITE) def module_attempt(ctx: OpContext, req: dict) -> Result: """Record one exam attempt fact in the owning module's module.yaml.""" if req["termin"] not in (1, 2, 3): @@ -148,7 +148,7 @@ def module_attempt(ctx: OpContext, req: dict) -> Result: ) -@register("module.sitting.update", kind="command") +@register("module.sitting.update", kind="command", effects=OperationEffects.CANONICAL_WRITE) def module_sitting(ctx: OpContext, req: dict) -> Result: """Record one sitting's withdrawal deadline in the owning module.yaml.""" if req["termin"] not in (1, 2, 3): @@ -221,7 +221,7 @@ def module_sitting(ctx: OpContext, req: dict) -> Result: ) -@register("coordination.section.revise", kind="command") +@register("coordination.section.revise", kind="command", effects=OperationEffects.CANONICAL_WRITE) def coordination_section_revise(ctx: OpContext, req: dict) -> Result: """Revise one reviewed coordination section, never a curriculum record.""" root = ctx.root @@ -347,7 +347,7 @@ def coordination_section_revise(ctx: OpContext, req: dict) -> Result: ) -@register("workspace.next-action.update", kind="command") +@register("workspace.next-action.update", kind="command", effects=OperationEffects.CANONICAL_WRITE) def workspace_next_action(ctx: OpContext, req: dict) -> Result: """Replace one active workspace's Next Action section.""" root = ctx.root @@ -441,7 +441,7 @@ def _semester_fact(row: dict | None) -> dict | None: @register("program.semester.update", kind="command", - render_check=_render_creation_check) + render_check=_render_creation_check, effects=OperationEffects.CANONICAL_WRITE) def program_semester_update(ctx: OpContext, req: dict) -> Result: """Turn one program's current semester, from a reviewed package. diff --git a/tools/learning_os/app/handlers/ai.py b/tools/learning_os/app/handlers/ai.py index 56467b1..38f344a 100644 --- a/tools/learning_os/app/handlers/ai.py +++ b/tools/learning_os/app/handlers/ai.py @@ -9,7 +9,7 @@ from learning_os.app.context import OpContext from learning_os.app.projection import publish as _publish from learning_os.app.proposal import DeliveryCompletion, ResponsePayload, Result -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.domain.ai_errors import ( AIActionError, ConceptNotFound, @@ -93,7 +93,7 @@ def _delivery_output(receipt: dict) -> dict: } -@register("ai-action.delivery.apply", kind="command") +@register("ai-action.delivery.apply", kind="command", effects=OperationEffects.CANONICAL_WRITE) def ai_action_apply_delivery(ctx: OpContext, req: dict) -> Result: """Apply an approved AI delivery in one receipted transaction.""" root = ctx.root @@ -145,21 +145,21 @@ def finish(confirmation): ) -@register("ai-action.list", kind="tool") +@register("ai-action.list", kind="tool", effects=OperationEffects.READ) def ai_action_list(ctx: OpContext, req: dict) -> Result: """List the provider-independent AI actions.""" actions = AIActionService(ctx.root).list_actions() return Result({"ok": True, "actions": actions}) -@register("ai-action.status", kind="tool") +@register("ai-action.status", kind="tool", effects=OperationEffects.READ) def ai_action_status(ctx: OpContext, req: dict) -> Result: """Show one prepared request status.""" request = AIActionService(ctx.root).request_status(req["request_id"]) return Result({"ok": True, "request": request}) -@register("ai-action.prepare", kind="porcelain") +@register("ai-action.prepare", kind="porcelain", effects=OperationEffects.CANONICAL_WRITE) def ai_action_prepare(ctx: OpContext, req: dict) -> Result: """Persist a bounded AI request bundle.""" root = ctx.root @@ -178,7 +178,7 @@ def ai_action_prepare(ctx: OpContext, req: dict) -> Result: }) -@register("ai-action.append-slices", kind="porcelain") +@register("ai-action.append-slices", kind="porcelain", effects=OperationEffects.CANONICAL_WRITE) def ai_action_append_slices(ctx: OpContext, req: dict) -> Result: """Attach one targeted follow-up slice pass to a prepared request.""" root = ctx.root @@ -201,14 +201,14 @@ def ai_action_append_slices(ctx: OpContext, req: dict) -> Result: return Result({"ok": True, "continuation": result}) -@register("ai-action.delivery.validate", kind="tool") +@register("ai-action.delivery.validate", kind="tool", effects=OperationEffects.READ) def ai_action_validate_delivery(ctx: OpContext, req: dict) -> Result: """Validate one imported delivery without applying it.""" result = AIActionService(ctx.root).validate_delivery(req["delivery_id"]) return Result(result) -@register("ai-action.delivery.import", kind="porcelain") +@register("ai-action.delivery.import", kind="porcelain", effects=OperationEffects.CANONICAL_WRITE) def ai_action_import_delivery(ctx: OpContext, req: dict) -> Result: """Import and validate an approved delivery directory.""" root = ctx.root diff --git a/tools/learning_os/app/handlers/atlas.py b/tools/learning_os/app/handlers/atlas.py index 850e8c3..f7e1224 100644 --- a/tools/learning_os/app/handlers/atlas.py +++ b/tools/learning_os/app/handlers/atlas.py @@ -13,7 +13,7 @@ from learning_os.app.context import OpContext from learning_os.app.proposal import Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive import abilities from learning_os.derive.learning_runtime import runtime_path from learning_os.domain.failures import NotFound, WriteRefused @@ -69,7 +69,7 @@ def _registries(root: Path) -> dict[Path, dict]: -@register("ability.context", kind="porcelain") +@register("ability.context", kind="porcelain", effects=OperationEffects.READ) def ability_context(ctx: OpContext, req: dict) -> Result: """Snapshot-bound ability horizon and focused expansion.""" root = ctx.root @@ -188,7 +188,7 @@ def ability_context(ctx: OpContext, req: dict) -> Result: ) from exc -@register("ability.candidate.append", kind="command") +@register("ability.candidate.append", kind="command", effects=OperationEffects.CANONICAL_WRITE) def ability_candidate_append(ctx: OpContext, req: dict) -> Result: """Capture a tentative cross-ability connection; it never grants credit.""" root = ctx.root @@ -275,7 +275,7 @@ def ability_candidate_append(ctx: OpContext, req: dict) -> Result: ) from exc -@register("atlas.question.save", kind="command") +@register("atlas.question.save", kind="command", effects=OperationEffects.CANONICAL_WRITE) def atlas_question_save(ctx: OpContext, req: dict) -> Result: """Save or resolve an explicitly authored personal question.""" root = ctx.root @@ -394,7 +394,7 @@ def atlas_question_save(ctx: OpContext, req: dict) -> Result: ) -@register("concept.relations.change", kind="command") +@register("concept.relations.change", kind="command", effects=OperationEffects.CANONICAL_WRITE) def concept_relations_change(ctx: OpContext, req: dict) -> Result: """Apply only explicitly authored relation operations.""" root = ctx.root @@ -477,7 +477,7 @@ def concept_relations_change(ctx: OpContext, req: dict) -> Result: ) -@register("atlas.context", kind="porcelain") +@register("atlas.context", kind="porcelain", effects=OperationEffects.READ) def atlas_context(ctx: OpContext, req: dict) -> Result: """Read connection editor guards and personal questions for one concept.""" root = ctx.root diff --git a/tools/learning_os/app/handlers/capture.py b/tools/learning_os/app/handlers/capture.py index cebfd7c..7583a1f 100644 --- a/tools/learning_os/app/handlers/capture.py +++ b/tools/learning_os/app/handlers/capture.py @@ -17,7 +17,7 @@ from learning_os.app.handlers._shared import require_unit_map_or_error from learning_os.app.handlers._shared import window_values as _window_values from learning_os.app.proposal import InboxCompletion, Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.domain.failures import StaleSnapshot, WriteRefused from learning_os.domain.unit_notes import unit_note_sections from learning_os.store import observations @@ -83,7 +83,7 @@ def _seed_content(text: str, title: str | None) -> str: return content if content.endswith("\n") else content + "\n" -@register("capture.create", kind="command", human=_human_capture) +@register("capture.create", kind="command", human=_human_capture, effects=OperationEffects.CANONICAL_WRITE) def capture(ctx: OpContext, req: dict) -> Result: """Judgment-free capture into work/inbox/ with a transaction receipt.""" root = ctx.root @@ -163,7 +163,7 @@ def capture(ctx: OpContext, req: dict) -> Result: ) -@register("garden.seed.create", kind="command", human=_human_garden_seed) +@register("garden.seed.create", kind="command", human=_human_garden_seed, effects=OperationEffects.CANONICAL_WRITE) def garden_seed_create(ctx: OpContext, req: dict) -> Result: """Create one free-form Markdown seed under knowledge/garden/.""" root = ctx.root @@ -229,7 +229,7 @@ def garden_seed_create(ctx: OpContext, req: dict) -> Result: ) -@register("inbox.list", kind="tool") +@register("inbox.list", kind="tool", effects=OperationEffects.READ) def inbox_list(ctx: OpContext, req: dict) -> Result: """List work/inbox files by name, without reading bytes.""" root = ctx.root @@ -274,7 +274,7 @@ def _inbox_folder_envelope(root: Path, name: str) -> dict: } -@register("inbox.content", kind="query") +@register("inbox.content", kind="query", effects=OperationEffects.READ) def inbox_read(ctx: OpContext, req: dict) -> Result: """Read a bounded segment of one work/inbox file by inbox-relative name. @@ -402,7 +402,7 @@ def _packet_note_lines(sections: list[dict], stage_titles: dict[str, str]) -> li return lines -@register("review.prepare", kind="command") +@register("review.prepare", kind="command", effects=OperationEffects.CANONICAL_WRITE) def shelving_prepare(ctx: OpContext, req: dict) -> Result: """Prepare a review packet; never applies canonical changes.""" root = ctx.root @@ -513,7 +513,7 @@ def _approved_destination(root: Path, item: dict) -> Path | None: return target -@register("review.apply", kind="command") +@register("review.apply", kind="command", effects=OperationEffects.CANONICAL_WRITE) def shelving_apply(ctx: OpContext, req: dict) -> Result: """Apply only explicitly approved proposal IDs.""" if not req.get("approve"): @@ -658,7 +658,7 @@ def _collect(inbox: Path, target: Path) -> dict[str, bytes]: return files -@register("inbox.resolve", kind="command") +@register("inbox.resolve", kind="command", effects=OperationEffects.CANONICAL_WRITE) def inbox_resolve(ctx: OpContext, req: dict) -> Result: """Resolve one routed inbox drop to the archive. diff --git a/tools/learning_os/app/handlers/goals.py b/tools/learning_os/app/handlers/goals.py index aa84316..6b76ef9 100644 --- a/tools/learning_os/app/handlers/goals.py +++ b/tools/learning_os/app/handlers/goals.py @@ -14,7 +14,7 @@ from learning_os.app.admission import gesture_allowed from learning_os.app.context import OpContext from learning_os.app.proposal import ResponsePayload, Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.semantics.claim_audits import ( AuditError, AuditRecord, @@ -78,7 +78,7 @@ def _render_goal_check(record: dict, payload: dict) -> dict: } -@register("goal.decide", kind="command", render_check=_render_goal_check) +@register("goal.decide", kind="command", render_check=_render_goal_check, effects=OperationEffects.CANONICAL_WRITE) def goal(ctx: OpContext, req: dict) -> Result: """Record Aram's explicit decision on a proposed goal.""" root = ctx.root diff --git a/tools/learning_os/app/handlers/learner.py b/tools/learning_os/app/handlers/learner.py index fc4de2d..db8ed07 100644 --- a/tools/learning_os/app/handlers/learner.py +++ b/tools/learning_os/app/handlers/learner.py @@ -17,7 +17,7 @@ require_unit_map_or_error, ) from learning_os.app.proposal import Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.learning_runtime import requirement_id_for from learning_os.derive.routes import route_with_identity from learning_os.domain.curriculum.stages import ( @@ -109,7 +109,7 @@ def _observe_offer(unit_id: str, stage: dict) -> dict: return offer -@register("stage.progress.update", kind="command") +@register("stage.progress.update", kind="command", effects=OperationEffects.CANONICAL_WRITE) def stage_progress(ctx: OpContext, req: dict) -> Result: """Move one stage's status and record progress against it.""" ctx.require_snapshot(req.get("expected_snapshot")) @@ -191,7 +191,7 @@ def stage_progress(ctx: OpContext, req: dict) -> Result: ) -@register("stage.note.write", kind="command") +@register("stage.note.write", kind="command", effects=OperationEffects.CANONICAL_WRITE) def stage_note(ctx: OpContext, req: dict) -> Result: """Append to (or replace) a stage's working note.""" root = ctx.root @@ -275,7 +275,7 @@ def _allocate_attachment_path(attachment_dir: Path, source_name: str) -> Path: counter += 1 -@register("stage.attachment.add", kind="command") +@register("stage.attachment.add", kind="command", effects=OperationEffects.CANONICAL_WRITE) def stage_attach(ctx: OpContext, req: dict) -> Result: """Attach a content-bound file beside a stage's working note.""" root = ctx.root @@ -327,7 +327,7 @@ def stage_attach(ctx: OpContext, req: dict) -> Result: ) -@register("path.note.write", kind="command") +@register("path.note.write", kind="command", effects=OperationEffects.CANONICAL_WRITE) def path_note(ctx: OpContext, req: dict) -> Result: """Append to (or replace) a learning-path stage note.""" root = ctx.root @@ -379,7 +379,7 @@ def path_note(ctx: OpContext, req: dict) -> Result: -@register("path.progress.update", kind="command") +@register("path.progress.update", kind="command", effects=OperationEffects.CANONICAL_WRITE) def path_progress(ctx: OpContext, req: dict) -> Result: """Move one learning-path stage's status.""" ctx.require_snapshot(req.get("expected_snapshot")) @@ -428,7 +428,7 @@ def path_progress(ctx: OpContext, req: dict) -> Result: -@register("path.attachment.add", kind="command") +@register("path.attachment.add", kind="command", effects=OperationEffects.CANONICAL_WRITE) def path_attach(ctx: OpContext, req: dict) -> Result: """Attach a content-bound file to a learning-path stage.""" root = ctx.root @@ -478,7 +478,7 @@ def path_attach(ctx: OpContext, req: dict) -> Result: ) -@register("detour.create", kind="command") +@register("detour.create", kind="command", effects=OperationEffects.CANONICAL_WRITE) def detour_create(ctx: OpContext, req: dict) -> Result: """Open a scoped detour off one stage.""" ctx.require_snapshot(req.get("expected_snapshot")) @@ -539,7 +539,7 @@ def _stage(data: dict, stage_id: str): if isinstance(row, dict) and row.get("id") == stage_id), None) -@register("detour.resolve", kind="command") +@register("detour.resolve", kind="command", effects=OperationEffects.CANONICAL_WRITE) def detour_resolve(ctx: OpContext, req: dict) -> Result: """Resolve a detour and reactivate its return stage.""" ctx.require_snapshot(req.get("expected_snapshot")) @@ -591,7 +591,7 @@ def detour_resolve(ctx: OpContext, req: dict) -> Result: ) -@register("source.feedback.record", kind="command") +@register("source.feedback.record", kind="command", effects=OperationEffects.CANONICAL_WRITE) def source_feedback(ctx: OpContext, req: dict) -> Result: """Record source feedback against an exact unit/stage/source.""" ctx.require_snapshot(req.get("expected_snapshot")) @@ -673,7 +673,7 @@ def _unit_note_target(root: Path, unit) -> tuple[Path, str]: return target, rel -@register("unit.note.append", kind="command") +@register("unit.note.append", kind="command", effects=OperationEffects.CANONICAL_WRITE) def unit_note(ctx: OpContext, req: dict) -> Result: """Append one session-level section to the unit working note.""" root = ctx.root @@ -835,7 +835,7 @@ def _selection_used_by_current_map(repo, unit, route: dict, source_id: str, return False -@register("unit.source-selection.set", kind="command") +@register("unit.source-selection.set", kind="command", effects=OperationEffects.CANONICAL_WRITE) def unit_source_selection(ctx: OpContext, req: dict) -> Result: """Persist one learner choice without changing the complete material menu.""" ctx.require_snapshot(req.get("expected_snapshot")) diff --git a/tools/learning_os/app/handlers/learner_observations.py b/tools/learning_os/app/handlers/learner_observations.py index a78fc6a..c569575 100644 --- a/tools/learning_os/app/handlers/learner_observations.py +++ b/tools/learning_os/app/handlers/learner_observations.py @@ -10,7 +10,7 @@ from learning_os.app.admission import gesture_allowed from learning_os.app.context import OpContext from learning_os.app.proposal import Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.abilities import ability_fingerprint, read_ability_observations from learning_os.derive.learning_runtime import ( RuntimeInputError, @@ -37,7 +37,7 @@ STAGE_RESULT_CAPABILITY = "learner.stage-result.append" -@register("learner.stage-result.append", kind="command") +@register("learner.stage-result.append", kind="command", effects=OperationEffects.CANONICAL_WRITE) def stage_result(ctx: OpContext, req: dict) -> Result: """Append an untargeted learner result against a unit/stage.""" root = ctx.root @@ -232,7 +232,7 @@ def _condition_notice(requirement: dict, obs: dict) -> dict: } -@register("learner.observation.append", kind="command") +@register("learner.observation.append", kind="command", effects=OperationEffects.CANONICAL_WRITE) def observation_append(ctx: OpContext, req: dict) -> Result: """Append one immutable learner observation against a requirement.""" root = ctx.root @@ -363,7 +363,7 @@ def covers(workspace) -> bool: return repo.workspaces[candidates[0]], "" -@register("learner.observe", kind="command") +@register("learner.observe", kind="command", effects=OperationEffects.CANONICAL_WRITE) def cli_observe(ctx: OpContext, req: dict) -> Result: """Record Aram's own evidence directly; the terminal session is the approval.""" root = ctx.root @@ -483,7 +483,7 @@ def _valid_work_ref(value: str) -> bool: return True -@register("learner.ability-observation.append", kind="command") +@register("learner.ability-observation.append", kind="command", effects=OperationEffects.CANONICAL_WRITE) def ability_observation_append(ctx: OpContext, req: dict) -> Result: """Record an exact learner-confirmed claim; the V2 approval binds payload.""" root = ctx.root diff --git a/tools/learning_os/app/handlers/maps.py b/tools/learning_os/app/handlers/maps.py index e90027e..74ab085 100644 --- a/tools/learning_os/app/handlers/maps.py +++ b/tools/learning_os/app/handlers/maps.py @@ -12,7 +12,7 @@ from learning_os.app.context import OpContext from learning_os.app.gateway import read_content_bound_file as _read_content_bound_file from learning_os.app.proposal import ResponsePayload, Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.learning_runtime import bind_runtime_reviews as _bind_runtime_reviews from learning_os.domain.failures import NotFound, WriteRefused from learning_os.domain.material_refs import MaterialReferenceError, expand_map @@ -44,7 +44,7 @@ def _human_plan_template(payload: dict) -> str: ).removesuffix("\n") -@register("plan.template", kind="query", human=_human_plan_template) +@register("plan.template", kind="query", human=_human_plan_template, effects=OperationEffects.READ) def plan_template(ctx: OpContext, req: dict) -> Result: """Read-only access to the single versioned study-map creation template.""" root = ctx.root @@ -280,7 +280,7 @@ def _plan_map_replacement( -@register("unit.map.import", kind="command") +@register("unit.map.import", kind="command", effects=OperationEffects.CANONICAL_WRITE) def unit_map_import(ctx: OpContext, req: dict) -> Result: """Create, or revise under preservation rules, the one current study map. diff --git a/tools/learning_os/app/handlers/materials.py b/tools/learning_os/app/handlers/materials.py index 7ad5279..90a0342 100644 --- a/tools/learning_os/app/handlers/materials.py +++ b/tools/learning_os/app/handlers/materials.py @@ -14,7 +14,7 @@ from learning_os.app.handlers._shared import map_for_unit as _map_for_unit from learning_os.app.handlers._shared import window_values as _window_values from learning_os.app.proposal import Result -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.learning_runtime import ( RuntimeInputError, read_stage_results, @@ -547,7 +547,7 @@ def _brief_payload(root, repo, unit, routes, study_map, artifacts, snapshot, } -@register("plan.edit-context", kind="query") +@register("plan.edit-context", kind="query", effects=OperationEffects.READ) def plan_edit_context(ctx: OpContext, req: dict) -> Result: """Assemble one unit's edit context: map, routes, selections, audit.""" root = ctx.root @@ -717,7 +717,7 @@ def _placement_route(ctx, repo, unit, source_map, routes, route, req): return selected, f" --stage {stage_id} --resource-index {index}" -@register("material.span", kind="query") +@register("material.span", kind="query", effects=OperationEffects.READ) def material_span(ctx: OpContext, req: dict) -> Result: """One common source-span description, with optional bounded local inspection.""" root = ctx.root @@ -1195,7 +1195,7 @@ def _anchor_expansion_command(req, limit, snapshot, observed) -> str: return expansion(*parts) -@register("material.context", kind="query") +@register("material.context", kind="query", effects=OperationEffects.READ) def material_context(ctx: OpContext, req: dict) -> Result: """Find saved explanations from an explanation need.""" root = ctx.root diff --git a/tools/learning_os/app/handlers/note_analysis.py b/tools/learning_os/app/handlers/note_analysis.py index 5ac2715..6760060 100644 --- a/tools/learning_os/app/handlers/note_analysis.py +++ b/tools/learning_os/app/handlers/note_analysis.py @@ -16,7 +16,7 @@ from learning_os.app.gateway import read_structured_file as _read_structured_file from learning_os.app.handlers._shared import NOTES_PREFIX from learning_os.app.proposal import ResponsePayload, Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.domain import ids from learning_os.domain.failures import WriteRefused from learning_os.store import observations @@ -46,7 +46,7 @@ from learning_os.store.transactions import parse_expected_revisions -@register("note.analysis.save", kind="command") +@register("note.analysis.save", kind="command", effects=OperationEffects.CANONICAL_WRITE) def note_analysis_save(ctx: OpContext, req: dict) -> Result: """Preserve one source analysis as a durable reference note.""" root = ctx.root @@ -131,7 +131,7 @@ def _read_batch_items(bundle: object) -> list[tuple[int, dict, dict, bytes]]: return prepared -@register("note.analysis.save_batch", kind="command") +@register("note.analysis.save_batch", kind="command", effects=OperationEffects.CANONICAL_WRITE) def note_analysis_save_batch(ctx: OpContext, req: dict) -> Result: """Preserve up to 20 source analyses atomically under one receipt.""" root = ctx.root @@ -588,7 +588,7 @@ def _prepare_out_dir(root: Path, value: object) -> Path: return resolved -@register("note.analysis.prepare", kind="tool") +@register("note.analysis.prepare", kind="tool", effects=OperationEffects.DERIVED_WRITE) def note_analysis_prepare(ctx: OpContext, req: dict) -> Result: """Drafts in, reviewable staging plus the exact envelope out. diff --git a/tools/learning_os/app/handlers/notes.py b/tools/learning_os/app/handlers/notes.py index 5794e18..b8f0a6d 100644 --- a/tools/learning_os/app/handlers/notes.py +++ b/tools/learning_os/app/handlers/notes.py @@ -18,7 +18,7 @@ from learning_os.app.handlers._shared import window_values as _window_values from learning_os.app.projection import fresh_manifest as _fresh_manifest from learning_os.app.proposal import Presentation, Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.garden import garden_id from learning_os.derive.search.index import NoteBlob, build_registry from learning_os.derive.search.model import POSTINGS_NODE_ID @@ -47,7 +47,7 @@ def _rewrite_frontmatter(meta: dict, original: bytes) -> bytes: return ("---\n" + _dump_yaml(meta).rstrip() + "\n---\n").encode("utf-8") + original[front.end():] -@register("note.evidence.add", kind="command") +@register("note.evidence.add", kind="command", effects=OperationEffects.CANONICAL_WRITE) def note_evidence(ctx: OpContext, req: dict) -> Result: """Append one evidence pointer to a note without touching its body. @@ -135,7 +135,7 @@ def note_evidence(ctx: OpContext, req: dict) -> Result: -@register("note.revise", kind="command") +@register("note.revise", kind="command", effects=OperationEffects.CANONICAL_WRITE) def note_revise(ctx: OpContext, req: dict) -> Result: """Replace one existing note after explicit, reviewable approval. @@ -504,7 +504,7 @@ def _resolve_note_create(root: Path, repo, note: dict, return note_id, path, _render_exact_note(meta, body), deprecations -@register("note.create", kind="command") +@register("note.create", kind="command", effects=OperationEffects.CANONICAL_WRITE) def note_create(ctx: OpContext, req: dict) -> Result: """Create one durable note from approved bytes. @@ -568,7 +568,7 @@ def _garden_notes(root): for note in repo.garden_notes] -@register("note.content", kind="query") +@register("note.content", kind="query", effects=OperationEffects.READ) def note_read(ctx: OpContext, req: dict) -> Result: """Read a bounded segment of a durable note or garden seed by stable ID.""" root = ctx.root @@ -1034,7 +1034,7 @@ def _content_search(ctx: OpContext, req: dict) -> Result: ) from exc -@register("note.search", kind="query") +@register("note.search", kind="query", effects=OperationEffects.DERIVED_WRITE) def search(ctx: OpContext, req: dict) -> Result: """Search records, garden seeds, and inbox filenames; empty query lists rows.""" if req["type"] is not None and req["type"] not in VALID_RECORD_TYPES: diff --git a/tools/learning_os/app/handlers/passthrough.py b/tools/learning_os/app/handlers/passthrough.py index b4a8e23..41ff098 100644 --- a/tools/learning_os/app/handlers/passthrough.py +++ b/tools/learning_os/app/handlers/passthrough.py @@ -2,7 +2,7 @@ from learning_os.app.context import OpContext from learning_os.app.projection import publish from learning_os.app.proposal import Result -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.app.validation import validation_run @@ -16,13 +16,13 @@ def generation_text(payload: dict) -> str: return "published generated/manifest.json (atomic contract snapshot)" -@register("repository.validate", kind="tool", human=validation_text) +@register("repository.validate", kind="tool", human=validation_text, effects=OperationEffects.DERIVED_WRITE) def validate_command(ctx: OpContext, req: dict) -> Result: payload = validation_run(ctx.root, online=bool(req.get("online"))) return Result(payload, diagnostic_failed=bool(payload["errors"])) -@register("projection.generate", kind="porcelain", human=generation_text) +@register("projection.generate", kind="porcelain", human=generation_text, effects=OperationEffects.DERIVED_WRITE) def generate_command(ctx: OpContext, req: dict) -> Result: publish(ctx.root) return Result({"ok": True, "published": "generated/manifest.json"}) diff --git a/tools/learning_os/app/handlers/plan.py b/tools/learning_os/app/handlers/plan.py index 16288f1..ba4c48c 100644 --- a/tools/learning_os/app/handlers/plan.py +++ b/tools/learning_os/app/handlers/plan.py @@ -40,7 +40,7 @@ from learning_os.app.handlers._shared import apply_reviewed_envelope from learning_os.app.handlers._shared import render_creation_check as _render_creation_check from learning_os.app.proposal import ResponsePayload, Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.learning_runtime import bind_runtime_reviews as _bind_runtime_reviews from learning_os.derive.masters_catalog import MastersPlanningError from learning_os.derive.material_synthesis import ( @@ -854,7 +854,7 @@ def _compact_plan_import(ctx, req, root, source, revision_bytes, revision) -> Re ) -@register("module.plan.import", kind="command") +@register("module.plan.import", kind="command", effects=OperationEffects.CANONICAL_WRITE) def module_plan_import(ctx: OpContext, req: dict) -> Result: """Apply one reviewable, module-scoped curriculum plan as a transaction. @@ -961,7 +961,7 @@ def _creation_destination_problems(root: Path, module_id: str) -> list[str]: @register("module.create", kind="command", - render_check=_render_creation_check) + render_check=_render_creation_check, effects=OperationEffects.CANONICAL_WRITE) def module_create(ctx: OpContext, req: dict) -> Result: """Create one module record and its source map as one transaction. @@ -1113,7 +1113,7 @@ def finish_module_create(confirmation): ) -@register("unit.plan.revise", kind="command") +@register("unit.plan.revise", kind="command", effects=OperationEffects.CANONICAL_WRITE) def unit_plan_revise(ctx: OpContext, req: dict) -> Result: """Revise one existing lecture: compact patch in, one governed transaction out. diff --git a/tools/learning_os/app/handlers/platform.py b/tools/learning_os/app/handlers/platform.py index ee93c17..9cdebec 100644 --- a/tools/learning_os/app/handlers/platform.py +++ b/tools/learning_os/app/handlers/platform.py @@ -28,7 +28,7 @@ from learning_os.app.projection import fresh_manifest as _fresh_manifest from learning_os.app.projection import fresh_manifest_and_repo as _fresh_manifest_and_repo from learning_os.app.proposal import Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive import masters_catalog from learning_os.derive.health import build_health_report from learning_os.domain.failures import NotFound, StaleSnapshot, WriteRefused @@ -339,7 +339,7 @@ def _inspect_batch(ctx: OpContext, req: dict) -> Result: ) from exc -@register("project.inspect", kind="query", parallel_safe=True, memo_profile="manifest", read_session_profile="manifest") +@register("project.inspect", kind="query", parallel_safe=True, memo_profile="manifest", read_session_profile="manifest", effects=OperationEffects.READ) def inspect(ctx: OpContext, req: dict) -> Result: """Inspect one record or structural id, or a batch of up to 20.""" if req.get("more_ids"): @@ -440,7 +440,7 @@ def _project_write(root: Path, data: dict, *, return {target: rendered}, project_id -@register("project.create", kind="command") +@register("project.create", kind="command", effects=OperationEffects.CANONICAL_WRITE) def project_create(ctx: OpContext, req: dict) -> Result: """Create one first-class project record.""" root = ctx.root @@ -465,7 +465,7 @@ def project_create(ctx: OpContext, req: dict) -> Result: ) -@register("project.update", kind="command") +@register("project.update", kind="command", effects=OperationEffects.CANONICAL_WRITE) def project_update(ctx: OpContext, req: dict) -> Result: """Update one first-class project record.""" root = ctx.root @@ -496,7 +496,7 @@ def project_update(ctx: OpContext, req: dict) -> Result: ) -@register("project.list", kind="query") +@register("project.list", kind="query", effects=OperationEffects.READ) def project_list(ctx: OpContext, req: dict) -> Result: """List first-class projects, optionally filtered by status.""" manifest = _fresh_manifest(ctx.root) @@ -512,7 +512,7 @@ def project_list(ctx: OpContext, req: dict) -> Result: # Moved with the units from commands.vnext (SPEC §12). -@register("backup.manifest", kind="query") +@register("backup.manifest", kind="query", effects=OperationEffects.DERIVED_WRITE) def backup_manifest(ctx: OpContext, req: dict) -> Result: """Build the backup manifest, or write it outside every backed-up root.""" root = ctx.root @@ -552,7 +552,7 @@ def backup_manifest(ctx: OpContext, req: dict) -> Result: f"--stdout") -@register("backup.verify", kind="tool") +@register("backup.verify", kind="tool", effects=OperationEffects.DERIVED_WRITE) def backup_verify(ctx: OpContext, req: dict) -> Result: """Verify a backup manifest against the restored trees.""" root = ctx.root @@ -568,7 +568,7 @@ def backup_verify(ctx: OpContext, req: dict) -> Result: return Result(result, diagnostic_failed=not result["ok"]) -@register("health.report", kind="query") +@register("health.report", kind="query", effects=OperationEffects.READ) def health_report(ctx: OpContext, req: dict) -> Result: """Build the repository health report.""" # Attention is report data, not a transport failure: the UI must be able to @@ -576,7 +576,7 @@ def health_report(ctx: OpContext, req: dict) -> Result: return Result(build_health_report(ctx.root)) -@register("legacy.archive.inspect", kind="query") +@register("legacy.archive.inspect", kind="query", effects=OperationEffects.READ) def legacy_archive_inspect(ctx: OpContext, req: dict) -> Result: """Inspect a legacy archive against its allowlist.""" root = ctx.root @@ -587,7 +587,7 @@ def legacy_archive_inspect(ctx: OpContext, req: dict) -> Result: return Result(lock) -@register("legacy.archive.status", kind="query") +@register("legacy.archive.status", kind="query", effects=OperationEffects.READ) def legacy_archive_status(ctx: OpContext, req: dict) -> Result: """Report the published legacy-archive lock, if any.""" root = ctx.root @@ -602,7 +602,7 @@ def legacy_archive_status(ctx: OpContext, req: dict) -> Result: return Result(value) -@register("masters-planning.dashboard", kind="query") +@register("masters-planning.dashboard", kind="query", effects=OperationEffects.READ) def masters_planning_dashboard(ctx: OpContext, req: dict) -> Result: """Render the Master's Planning dashboard.""" value = masters_planning.masters_planning_dashboard( @@ -980,7 +980,7 @@ def _describe_from_records(root: Path, request_id: str, } -@register("operations.read", kind="tool") +@register("operations.read", kind="tool", effects=OperationEffects.READ) def operations(ctx: OpContext, req: dict) -> Result: """List recent causal operations or explain one request id.""" root = ctx.root @@ -1015,7 +1015,7 @@ def operations(ctx: OpContext, req: dict) -> Result: -@register("legacy.archive.lock.publish", kind="command") +@register("legacy.archive.lock.publish", kind="command", effects=OperationEffects.CANONICAL_WRITE) def legacy_archive_lock_publish(ctx: OpContext, req: dict) -> Result: """Publish one reviewed safe Legacy disposition lock.""" root = ctx.root @@ -1051,7 +1051,7 @@ def legacy_archive_lock_publish(ctx: OpContext, req: dict) -> Result: ) -@register("masters-planning.catalog.update", kind="command") +@register("masters-planning.catalog.update", kind="command", effects=OperationEffects.CANONICAL_WRITE) def masters_planning_catalog_update(ctx: OpContext, req: dict) -> Result: """Update the reviewed academic-only Master Planning catalog.""" root = ctx.root @@ -1091,7 +1091,7 @@ def masters_planning_catalog_update(ctx: OpContext, req: dict) -> Result: ) -@register("masters-planning.comparison.publish", kind="command") +@register("masters-planning.comparison.publish", kind="command", effects=OperationEffects.CANONICAL_WRITE) def masters_planning_comparison_publish(ctx: OpContext, req: dict) -> Result: """Publish one prospective source comparison against its catalog.""" root = ctx.root @@ -1133,7 +1133,7 @@ def masters_planning_comparison_publish(ctx: OpContext, req: dict) -> Result: ) -@register("projection.observe", kind="query") +@register("projection.observe", kind="query", effects=OperationEffects.READ) def projection_observe(ctx: OpContext, req: dict) -> Result: """Verify Core facts for receipt completion and current projection settlement.""" from learning_os.app.projection_readiness import settlement_ready diff --git a/tools/learning_os/app/handlers/reads.py b/tools/learning_os/app/handlers/reads.py index 25b2a3e..51ec31c 100644 --- a/tools/learning_os/app/handlers/reads.py +++ b/tools/learning_os/app/handlers/reads.py @@ -13,7 +13,7 @@ from learning_os.app.projection import fresh_manifest as _fresh_manifest from learning_os.app.proposal import Result from learning_os.app.ranking import ranked_scan -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.app.validation_service import status_issues from learning_os.derive.learning_runtime import RuntimeInputError, collect_requirements from learning_os.derive.projection import adoption_counts, exam_spine @@ -356,7 +356,7 @@ def _brief_bootstrap(ctx: OpContext, req: dict) -> Result: ) from exc -@register("operator.bootstrap", kind="query", parallel_safe=True, memo_profile="manifest-brief", read_session_profile="manifest-brief") +@register("operator.bootstrap", kind="query", parallel_safe=True, memo_profile="manifest-brief", read_session_profile="manifest-brief", effects=OperationEffects.READ) def bootstrap(ctx: OpContext, req: dict) -> Result: """Serve the operator bootstrap: full, brief, or compact pages.""" if req.get("brief"): @@ -380,7 +380,7 @@ def bootstrap(ctx: OpContext, req: dict) -> Result: return Result(payload) -@register("program.list", kind="query") +@register("program.list", kind="query", effects=OperationEffects.READ) def program_list(ctx: OpContext, req: dict) -> Result: """List programs as discovery rows.""" manifest = _fresh_manifest(ctx.root) @@ -427,7 +427,7 @@ def _human_status(payload: dict) -> str: return "\n".join(lines) -@register("status.read", kind="tool", human=_human_status) +@register("status.read", kind="tool", human=_human_status, effects=OperationEffects.DERIVED_WRITE) def status(ctx: OpContext, req: dict) -> Result: """Serve the status screen: counts, exam spine, and validation.""" root = ctx.root @@ -535,7 +535,7 @@ def _coerce(raw: str) -> object: return raw -@register("semantic.query", kind="tool") +@register("semantic.query", kind="tool", effects=OperationEffects.READ) def semantic(ctx: OpContext, req: dict) -> Result: """Evaluate one predicate, list the registry, or serve a recipe.""" if req.get("recipe"): @@ -720,7 +720,7 @@ def _human_intelligence_scan(payload, view) -> str: return "\n".join(lines) -@register("intelligence.scan", kind="tool", human=_human_intelligence_scan, parallel_safe=True, memo_profile="manifest-brief", read_session_profile="manifest-brief") +@register("intelligence.scan", kind="tool", human=_human_intelligence_scan, parallel_safe=True, memo_profile="manifest-brief", read_session_profile="manifest-brief", effects=OperationEffects.READ) def intelligence_scan(ctx: OpContext, req: dict) -> Result: """Run one observation loop: observe, interpret, propose. @@ -893,7 +893,7 @@ def _human_capabilities(payload, view) -> str: " every successful canonical write has one receipt"]) -@register("capabilities.read", kind="tool", human=_human_capabilities, parallel_safe=True, memo_profile="catalogue", read_session_profile="catalogue") +@register("capabilities.read", kind="tool", human=_human_capabilities, parallel_safe=True, memo_profile="catalogue", read_session_profile="catalogue", effects=OperationEffects.READ) def capabilities(ctx: OpContext, req: dict) -> Result: """Serve the machine discovery/read catalogue, in full or in part. @@ -1167,7 +1167,7 @@ def link(rid, why): for _, _, _, rid in ranked] -@register("related.read", kind="query") +@register("related.read", kind="query", effects=OperationEffects.READ) def related(ctx: OpContext, req: dict) -> Result: """Ranked one-hop connections for one record id.""" root = ctx.root @@ -1189,7 +1189,7 @@ def related(ctx: OpContext, req: dict) -> Result: return Result(out) -@register("runtime.session", kind="porcelain") +@register("runtime.session", kind="porcelain", effects=OperationEffects.READ) def runtime_session(ctx: OpContext, req: dict) -> Result: """Propose (or repair) one runtime study session. Read-only.""" root = ctx.root @@ -1358,7 +1358,7 @@ def _unit_list_compact(ctx: OpContext, req: dict) -> Result: ) from exc -@register("unit.list", kind="query") +@register("unit.list", kind="query", effects=OperationEffects.READ) def unit_list(ctx: OpContext, req: dict) -> Result: """List units, optionally filtered; compact serves the summary page.""" root = ctx.root @@ -1376,7 +1376,7 @@ def unit_list(ctx: OpContext, req: dict) -> Result: return Result(_filtered_units(manifest, req, ctx.operation)) -@register("module.list", kind="query") +@register("module.list", kind="query", effects=OperationEffects.READ) def module_list(ctx: OpContext, req: dict) -> Result: """List modules with optional program/status filters.""" root = ctx.root diff --git a/tools/learning_os/app/handlers/resume.py b/tools/learning_os/app/handlers/resume.py index 2288ddb..d767ec8 100644 --- a/tools/learning_os/app/handlers/resume.py +++ b/tools/learning_os/app/handlers/resume.py @@ -11,7 +11,7 @@ from learning_os.app.context import OpContext from learning_os.app.proposal import Result -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.learning_runtime import ( RuntimeInputError, collect_requirements, @@ -127,7 +127,7 @@ def _evidence(repo, source_ids) -> dict[str, str]: return evidence -@register("dossier.read", kind="porcelain") +@register("dossier.read", kind="porcelain", effects=OperationEffects.DERIVED_WRITE) def dossier(ctx: OpContext, req: dict) -> Result: """Build, serve, or rebuild one unit's semantic dossier. Read-only.""" root = ctx.root @@ -899,7 +899,7 @@ def _human_resume(payload, view) -> str: return rendered -@register("resume.read", kind="tool", human=_human_resume) +@register("resume.read", kind="tool", human=_human_resume, effects=OperationEffects.READ) def resume(ctx: OpContext, req: dict) -> Result: """Compile and print the return-to-study screen. Read-only.""" root = ctx.root diff --git a/tools/learning_os/app/handlers/routes.py b/tools/learning_os/app/handlers/routes.py index 5aedfd6..49335be 100644 --- a/tools/learning_os/app/handlers/routes.py +++ b/tools/learning_os/app/handlers/routes.py @@ -27,7 +27,7 @@ from learning_os.app.handlers._shared import guard_rows as _guard_rows from learning_os.app.handlers._shared import map_for_unit as _map_for_unit from learning_os.app.proposal import ResponsePayload, Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.semantics.claim_status import claim_impacts, observe_claims from learning_os.derive.semantics.lineage import LEDGER_RELATIVE from learning_os.domain.failures import NotFound, ValidationFailed, WriteRefused @@ -389,7 +389,7 @@ def finish(confirmation): ) -@register("route.patch", kind="command", render_check=_render_plan_check) +@register("route.patch", kind="command", render_check=_render_plan_check, effects=OperationEffects.CANONICAL_WRITE) def route_patch(ctx: OpContext, req: dict) -> Result: """Patch one material route's declared fields, with its map mirrors.""" return _execute_material_plan(ctx, req, "route.patch", lambda repo: route_patch_plan( @@ -398,7 +398,7 @@ def route_patch(ctx: OpContext, req: dict) -> Result: @register("module.materials.compact", kind="command", - render_check=_render_plan_check) + render_check=_render_plan_check, effects=OperationEffects.CANONICAL_WRITE) def module_materials_compact(ctx: OpContext, req: dict) -> Result: """Share repeated stage material fields losslessly across one module's maps.""" return _execute_material_plan( diff --git a/tools/learning_os/app/handlers/session.py b/tools/learning_os/app/handlers/session.py index c91b0de..66a0cf1 100644 --- a/tools/learning_os/app/handlers/session.py +++ b/tools/learning_os/app/handlers/session.py @@ -8,7 +8,7 @@ from learning_os.app.context import OpContext from learning_os.app.proposal import Result -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.app.session import SessionChannelError as _SessionChannelError from learning_os.app.session import SessionCommittedError as _SessionCommittedError from learning_os.app.session import SessionConflictError as _SessionConflictError @@ -21,7 +21,7 @@ from learning_os.domain.failures import WriteRefused -@register("session.end", kind="porcelain") +@register("session.end", kind="porcelain", effects=OperationEffects.CANONICAL_WRITE) def session_end_command(ctx: OpContext, req: dict) -> Result: """Validate, show exact session-owned files, optionally commit/push.""" try: diff --git a/tools/learning_os/app/handlers/sources.py b/tools/learning_os/app/handlers/sources.py index c253bca..bc96c6c 100644 --- a/tools/learning_os/app/handlers/sources.py +++ b/tools/learning_os/app/handlers/sources.py @@ -21,7 +21,7 @@ from learning_os.app.handlers._shared import render_creation_check as _render_creation_check from learning_os.app.handlers._shared import require_gateway_v2 as _require_gateway_v2 from learning_os.app.proposal import ResponsePayload, Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.material_synthesis import COMPARED_BASIS_FIELDS as _COMPARED_BASIS_FIELDS from learning_os.derive.material_synthesis import ( MaterialSynthesisError, @@ -230,7 +230,7 @@ def _plan_collection_entry_revise(root: Path, repo, item: Any) -> dict: } -@register("collection.entry.revise", kind="command") +@register("collection.entry.revise", kind="command", effects=OperationEffects.CANONICAL_WRITE) def collection_entry_revise(ctx: OpContext, req: dict) -> Result: """Revise one collection entry's `why`, atomically.""" root = ctx.root @@ -319,7 +319,7 @@ def finish_collection_entry_revise(confirmation): @register("collection.create", kind="command", - render_check=_render_creation_check) + render_check=_render_creation_check, effects=OperationEffects.CANONICAL_WRITE) def collection_create(ctx: OpContext, req: dict) -> Result: """Create one source collection as one transaction. @@ -830,7 +830,7 @@ def _plan_source_intake(root: Path, repo, items: Any) -> dict: } -@register("source.intake.record", kind="command") +@register("source.intake.record", kind="command", effects=OperationEffects.CANONICAL_WRITE) def source_intake_record(ctx: OpContext, req: dict) -> Result: """Create metadata-only sources or correct intake-owned fields, atomically.""" root = ctx.root @@ -1457,7 +1457,7 @@ def _plan_source_record_revise(root: Path, repo, item: Any) -> dict: } -@register("source.record.revise", kind="command") +@register("source.record.revise", kind="command", effects=OperationEffects.CANONICAL_WRITE) def source_record_revise(ctx: OpContext, req: dict) -> Result: """Attach verified local material to one existing source, atomically.""" root = ctx.root diff --git a/tools/learning_os/app/handlers/synthesis.py b/tools/learning_os/app/handlers/synthesis.py index 3f2d1d5..78c9e24 100644 --- a/tools/learning_os/app/handlers/synthesis.py +++ b/tools/learning_os/app/handlers/synthesis.py @@ -14,7 +14,7 @@ from learning_os.app.handlers._shared import input_record as _input_record from learning_os.app.handlers._shared import require_gateway_v2 as _require_gateway_v2 from learning_os.app.proposal import ResponsePayload, Result, TransactionProposal -from learning_os.app.registry import register +from learning_os.app.registry import OperationEffects, register from learning_os.derive.material_synthesis import ( MaterialSynthesisUnitNotFound, current_unit_material_basis, @@ -61,7 +61,7 @@ def _validate_publication_synthesis(root, unit_id: str, value: dict) -> dict: ) from exc -@register("unit.material-synthesis.publish", kind="command") +@register("unit.material-synthesis.publish", kind="command", effects=OperationEffects.CANONICAL_WRITE) def unit_material_synthesis_publish(ctx: OpContext, req: dict) -> Result: """Publish one reviewed unit dossier, with lineage, atomically.""" root = ctx.root @@ -181,7 +181,7 @@ def _ledger_guards(root: Path) -> tuple[list[str], dict[str, int]]: -@register("lineage.restamp", kind="command") +@register("lineage.restamp", kind="command", effects=OperationEffects.CANONICAL_WRITE) def lineage_restamp(ctx: OpContext, req: dict) -> Result: """Narrow old coarse reads, never re-judge (#94).""" root = ctx.root diff --git a/tools/learning_os/app/projection_readiness.py b/tools/learning_os/app/projection_readiness.py index 4a85118..9caa676 100644 --- a/tools/learning_os/app/projection_readiness.py +++ b/tools/learning_os/app/projection_readiness.py @@ -22,6 +22,19 @@ def _stat(path: Path) -> tuple[int, ...]: return () +def _receipt_path(root: Path, receipt_path: str) -> Path | None: + relative = Path(receipt_path) + path = root / relative + boundary = root / "operations/transactions" + try: + safe = not relative.is_absolute() and not path.is_symlink() and path.resolve().is_relative_to(boundary) + except (OSError, RuntimeError): + return None + if not safe: + return None + return path + + @functools.lru_cache(maxsize=32) def _publication(root_text: str, manifest_stat: tuple[int, ...], identity_stat: tuple[int, ...]) -> tuple[dict[str, object], dict[str, object]] | None: @@ -66,9 +79,9 @@ def observe(root: Path, *, verify_current: bool = True) -> dict[str, object]: def _completed(root_text: str, receipt_path: str, snapshot_after: str, receipt_stat: tuple[int, ...], ledger_stat: tuple[int, ...]) -> bool: root = Path(root_text) - path = root / receipt_path + path = _receipt_path(root, receipt_path) try: - if path.is_symlink() or not path.resolve().is_relative_to(root): + if path is None: return False with path.open() as stream: data: object = yaml.load(stream, Loader=UniqueKeySafeLoader) @@ -90,10 +103,10 @@ def _completed(root_text: str, receipt_path: str, snapshot_after: str, def receipt_completed(root: Path, receipt_path: str | None, snapshot_after: str | None) -> bool: root = root.resolve() - if not receipt_path or not snapshot_after or Path(receipt_path).is_absolute(): + if not receipt_path or not snapshot_after: return False - path = root / receipt_path - if not path.resolve().is_relative_to(root): + path = _receipt_path(root, receipt_path) + if path is None: return False return _completed(str(root), receipt_path, snapshot_after, _stat(path), _stat(root / "operations/transactions/idempotency.yaml")) diff --git a/tools/learning_os/app/registry.py b/tools/learning_os/app/registry.py index f6dfc79..575aa5e 100644 --- a/tools/learning_os/app/registry.py +++ b/tools/learning_os/app/registry.py @@ -4,15 +4,23 @@ from collections.abc import Callable from dataclasses import dataclass +from enum import StrEnum from learning_os.app.context import OpContext from learning_os.app.proposal import Result +class OperationEffects(StrEnum): + READ = "read" + DERIVED_WRITE = "derived-write" + CANONICAL_WRITE = "canonical-write" + UNKNOWN = "unknown" + + @dataclass(frozen=True) class Operation: name: str - kind: str # query | command | porcelain + kind: str # query | command | porcelain | tool handler: Callable[[OpContext, dict], Result] # Renders the human default output; None = JSON default. One argument # (payload), or two (payload, human_view) when the Result sets one (SPEC (bu) 1). @@ -23,6 +31,9 @@ class Operation: parallel_safe: bool = False memo_profile: str = "none" read_session_profile: str = "none" + # Invocation kind controls dispatch/locking, not effects. A new operation + # is a write barrier until its owner explicitly declares a read. + effects: OperationEffects = OperationEffects.UNKNOWN REGISTRY: dict[str, Operation] = {} @@ -37,6 +48,7 @@ def register( parallel_safe: bool = False, memo_profile: str = "none", read_session_profile: str = "none", + effects: OperationEffects = OperationEffects.UNKNOWN, ): """Register ``fn`` under ``name``; returns ``fn`` unchanged (stackable).""" @@ -46,6 +58,7 @@ def decorator(fn): human=human, render_check=render_check, parallel_safe=parallel_safe, memo_profile=memo_profile, read_session_profile=read_session_profile, + effects=OperationEffects(effects), ) return fn diff --git a/work/complaints/2026-10-27-agent-run-write-classification.md b/work/complaints/2026-10-27-agent-run-write-classification.md new file mode 100644 index 0000000..83922f3 --- /dev/null +++ b/work/complaints/2026-10-27-agent-run-write-classification.md @@ -0,0 +1,83 @@ +# Agent runner allowed porcelain writes past an uncertain write + +Task: implement review findings 1–2 from PR #59, authorized by Aram on +2026-10-27. Tested base: Core `d3d3b35`, Python 3.14, macOS, isolated linked +checkout and synthetic `mini_repo` fixtures. No live learner data was changed. + +## Observed + +`tools/agent_run.py::build_index` classified only registry commands as writes +for argv/log plans, but used catalogue `writes`/`kind` for plans containing +`use`. Neither rule included `session-end`, `generate` or AI porcelain writers. +Missing registry entries also produced different classifications between paths. + +Before the repair, 13 new regression cases failed and one passed. In particular, +the real runner adapter (without an injected executor) dispatched a mocked +`session-end --commit-message fixture --push` after a simulated lost gateway +response under `on_error: continue`. The observed call sequence was +`seal, session-end, inspect`, rather than `seal, inspect`. Sessionless argv, +prefixed argv and operation-id plans were admitted as well. + +Reproduce with `tests/test_agent_run.py`, selecting +`classification_is_conservative or session_end_commit_push or +uncertain_write_blocks_porcelain or describing_porcelain` against the base +runner. Dispatch and the lost subprocess response are mocked to avoid any Git +commit or push. These experiments prove runner admission/scheduling behavior; +they do not claim an actual remote push occurred. + +## Repair and acceptance + +Use the executing parser/registry once for every plan shape. The existing +`query` and `tool` kinds declare repository reads; commands, porcelain, +unregistered operations and unknown kinds remain write barriers. The +catalogue resolves task names without overriding runtime operation bindings. +This preserves lightweight explicit reads while avoiding a second effect rule. + +Require a plan session before any dispatch, honor the uncertainty barrier even +with `write: false`, retain independent reads under `on_error: continue`, and +permit sessionless descriptions of effectful operations. Tests exercise the +production admission/scheduling route; removing legacy injected-test paths is +outside this repair. + +Acceptance: both discovery paths return identical writer sets; every tested +sessionless commit/push attempt is refused before any step runs; uncertain +writes block porcelain while an independent read still succeeds. Retain all +existing gateway, session, projection and report safeguards. + +Verification: 127 runner/read-session/readiness/consolidation/task tests, +82 architecture tests and 111 efficiency/catalogue/document-command tests +pass. Ruff, mypy, code reachability and the system map pass. Validation has +zero errors; the baseline remains 964 warnings across 32 managed signatures +(nine additional operational advisories are baseline-exempt in this checkout). + +GitHub issue: https://github.com/Aram164/LearningOS/issues/60 + +## Follow-up: partial porcelain completion + +Aram identified a second uncertainty gap before PR #61 merges. At `531b63f`, +the scheduler initializes only gateway submissions as uncertain, even though +porcelain now correctly classifies as a write. Generic porcelain failures can +therefore leave `commit_certainty: none` and permit later writes. + +New production-path tests initialize a synthetic Git repository, create a sealed +capture in a named session, review it, and run session-end commit/push with no +configured remote. The Git commit succeeds and push fails locally; the ledger +retains `committed_unpushed`. No remote or live checkout is touched. Both argv +and use forms must report uncertainty, skip a later log, retain the original +receipt set, and permit an independent inspect. + +Additional tests cover an escaped dispatch exception, generic porcelain +`commit: none`, successful output without commit evidence, a genuine gateway +refusal, and receipt evidence contradicting a refusal field. The repair starts +all writers as uncertain and permits only gateway refusals to establish no +commit; a returned receipt is never downgraded by `commit: none`. + +The effect audit also confirmed file output behind non-command kinds: +validation publishes its report, note-analysis preparation writes staging bodies +and an envelope, backup export writes its output, backup verification can rebuild +a restored projection, search can publish its index, and dossier building can +publish its cache. These operations now explicitly declare derived writes; +delivery validation and material-span inspection were checked and remain reads. +Every query/tool declares an effect, and an undeclared read-looking operation +still defaults to a write barrier. Receipt reads reject paths outside +`operations/transactions/` before opening them, including escapes and symlinks. diff --git a/work/complaints/2026-10-27-projection-receipt-boundary.md b/work/complaints/2026-10-27-projection-receipt-boundary.md new file mode 100644 index 0000000..4bc8e31 --- /dev/null +++ b/work/complaints/2026-10-27-projection-receipt-boundary.md @@ -0,0 +1,27 @@ +# Projection readiness opened YAML outside the receipt owner + +Task: improvement-list safety item 22, added to PR #61. +Verified source baseline: Core `531b63f` on macOS, Python 3.14. + +`receipt_completed` in `tools/learning_os/app/projection_readiness.py` previously +accepted any relative path resolving within the repository. `_completed` opened +that YAML before checking transaction authority. A non-receipt YAML would fail +authority, but it should never have been opened as a receipt in the first place. + +Expected: only files under `operations/transactions/` may enter receipt +verification. Observed source behavior: the boundary was the entire root. +This is a source-verified read-boundary defect; no claim is made that a forged +receipt succeeded or that private data was returned. + +Repair: a shared path guard confines both admission and the subsequent receipt +read to the transaction directory and rejects absolute paths, escapes and +symlinks. New fixture tests replace the authority reader with a failure sentinel +and prove out-of-area paths are refused before that reader is called; an +in-root symlink to a YAML file outside the transaction directory also refuses. +Existing real gateway receipts continue to pass their authority checks. + +Acceptance: bounded paths refuse before opening; authentic transaction receipts +still satisfy completion, dependency ordering and projection settlement. No +receipt, ledger, snapshot or publication safeguard is weakened. + +Issue: https://github.com/Aram164/LearningOS/issues/63 diff --git a/work/complaints/2026-10-27-scheduled-url-audit-failure.md b/work/complaints/2026-10-27-scheduled-url-audit-failure.md new file mode 100644 index 0000000..e0369d7 --- /dev/null +++ b/work/complaints/2026-10-27-scheduled-url-audit-failure.md @@ -0,0 +1,27 @@ +# Scheduled validation failed on three dead external URLs + +Task: implement the supplied improvement list, including investigation item 40. +Evidence: https://github.com/Aram164/LearningOS/actions/runs/40321186523 +Core tested by that schedule: `54b866f7c817a0899fb2707da2c98d3d76db4bce`. + +The schedule started 2026-10-26 12:15:49 UTC (13:15:49 Berlin). Its gate's +offline validation printed zero errors and 973 warnings. The online URL audit +then failed with three `URL-DEAD` errors and exited 1: + +- `https://github.com/dabeaz/generators` — HTTP 404. +- `https://scikit-learn.org/stable/_downloads/scikit-learn-docs.pdf` — HTTP 404. +- `https://scott.fortmann-roe.com/docs/BiasVariance.html` — HTTP 404. + +Expected: each authoritative source remains reachable, or has a reviewed +replacement/deferred disposition. Observed impact: the scheduled job is red, +so subsequent new failures are harder to distinguish from this known content +debt. This is the recorded run's observation, not a fresh claim about current +network responses. No source identity or source selection has been changed. + +Smallest investigation: locate each owning source/route, verify an equivalent +authoritative URL or preserved authorized local source, update through the +appropriate ownership contract, and rerun the online audit. Do not suppress +`URL-DEAD` or replace the work with a merely related source. Acceptance is a +passing scheduled gate with the source-completeness safeguards preserved. + +Issue: https://github.com/Aram164/LearningOS/issues/62