diff --git a/Makefile b/Makefile index 294d05c..cec684e 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ RAILIANCE_STATE_HUB_RELEASE ?= state-hub RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml -RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-0747b4d +RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015 RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests # Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable. diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index 5a5bc3d..cb6724a 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -16,6 +16,8 @@ from api.database import get_session from api.events import EventEnvelope, publish_event from api.models.managed_repo import ManagedRepo from api.models.workplan import Workplan +from api.models.task import Task +from api.services.historical_projection import frozen_cluster_binding, validate_frozen_cluster_rows from api.schemas.workplan import ( WorkplanBindingsSync, WorkplanCreate, @@ -435,12 +437,42 @@ async def sync_workplan_bindings( session: AsyncSession = Depends(get_session), ) -> dict[str, int]: """Upsert workstation workplan file bindings for remote API index fallback.""" + # Preflight the complete batch before assigning even an ordinary row. + try: + frozen = {entry.workplan_id: bound for entry in body.bindings + if (bound := frozen_cluster_binding(entry.model_dump())) is not None} + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + requested_ids = {entry.workplan_id for entry in body.bindings} + rows = await session.execute( + select(Workplan).where(Workplan.id.in_(requested_ids)) + .order_by(Workplan.id).with_for_update().execution_options(populate_existing=True) + ) + workplans = {workplan.id: workplan for workplan in rows.scalars().all()} + if frozen: + repo_rows = await session.execute( + select(ManagedRepo).where(ManagedRepo.slug == "railiance-cluster") + ) + repo = repo_rows.scalar_one_or_none() + task_rows = await session.execute( + select(Task).where(Task.workplan_id.in_(frozen)) + .order_by(Task.id).with_for_update().execution_options(populate_existing=True) + ) + tasks = task_rows.scalars().all() + try: + for workplan_id, bound in frozen.items(): + validate_frozen_cluster_rows( + bound, workplans.get(workplan_id), tasks, repo.id if repo else None + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc synced_at = datetime.now(timezone.utc) updated = 0 - requested_ids = {entry.workplan_id for entry in body.bindings} - rows = await session.execute(select(Workplan).where(Workplan.id.in_(requested_ids))) - workplans = {workplan.id: workplan for workplan in rows.scalars().all()} + preserved = 0 for entry in body.bindings: + if entry.workplan_id in frozen: + preserved += 1 + continue # No setters, including backing_synced_at/updated_at. wp = workplans.get(entry.workplan_id) if wp is None: continue @@ -449,9 +481,10 @@ async def sync_workplan_bindings( wp.backing_archived = entry.archived wp.backing_synced_at = synced_at updated += 1 - await session.commit() - _invalidate_workplan_index_cache() - return {"updated": updated, "received": len(body.bindings)} + if updated: + await session.commit() + _invalidate_workplan_index_cache() + return {"updated": updated, "received": len(body.bindings), "preserved": preserved} @router.post("/", status_code=status.HTTP_410_GONE) diff --git a/api/services/historical_projection.py b/api/services/historical_projection.py index 1a8a79c..97a50a3 100644 --- a/api/services/historical_projection.py +++ b/api/services/historical_projection.py @@ -193,3 +193,46 @@ def bound_sources(repo_root: Path, repo_slug: str): raise ValueError("Historical source binding changed: " + entry["path"]) result.append((entry, data.decode("utf-8"))) return result + + +# Only the three existing full-task Cluster bindings have immutable DB rows. +# Other historical views retain their existing compatibility behavior. +FROZEN_CLUSTER_BINDINGS = tuple( + entry for entry in BINDINGS + if entry["repo"] == "railiance-cluster" and "tasks" in entry +) + + +def frozen_cluster_binding(binding): + """Validate a claimed frozen binding; unrelated requests remain ordinary.""" + matches = [entry for entry in FROZEN_CLUSTER_BINDINGS + if str(binding["workplan_id"]) == entry["uuid"] + or (binding["repo_slug"] == "railiance-cluster" + and (binding["relative_path"] == entry["path"] + or binding["filename"] == Path(entry["path"]).name))] + if not matches: + return None + if len(matches) != 1: + raise ValueError("frozen historical binding is ambiguous") + entry = matches[0] + expected = { + "workplan_id": entry["uuid"], "repo_slug": entry["repo"], + "filename": Path(entry["path"]).name, "relative_path": entry["path"], + "archived": True, "status": "finished", + } + if any((str(binding[key]) if key == "workplan_id" else binding[key]) != value + for key, value in expected.items()): + raise ValueError("frozen historical binding metadata changed") + return entry + + +def validate_frozen_cluster_rows(entry, parent, tasks, repo_id): + """Receiving requires the exact existing terminal closure, never repair.""" + if (parent is None or str(parent.id) != entry["uuid"] + or repo_id is None or parent.repo_id != repo_id + or parent.status not in {"done", "finished", "archived", "completed"}): + raise ValueError("frozen historical parent missing, foreign or nonterminal") + local = {str(task.id): task for task in tasks if task.workplan_id == parent.id} + if any(task["uuid"] not in local or local[task["uuid"]].status not in {"done", "cancel"} + for task in entry["tasks"]): + raise ValueError("frozen historical task missing, foreign or nonterminal") diff --git a/deploy/railiance/apps/charts/state-hub/Chart.yaml b/deploy/railiance/apps/charts/state-hub/Chart.yaml index b20f207..1cb3040 100644 --- a/deploy/railiance/apps/charts/state-hub/Chart.yaml +++ b/deploy/railiance/apps/charts/state-hub/Chart.yaml @@ -3,4 +3,4 @@ name: state-hub description: State Hub API service for private Railiance operation type: application version: 0.1.0 -appVersion: "main-5dd3df9" +appVersion: "main-51bc70d" diff --git a/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml b/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml index a29137c..2eec280 100644 --- a/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml +++ b/deploy/railiance/apps/charts/state-hub/files/repo-classification.allowed.yaml @@ -13,7 +13,7 @@ # to match the standard version. CUST-WP-0050 T01. standard: "Repo Classification Standard" -version: "1.0" +version: "1.1" canon_id: "canon-repo-classification" # category — exactly 1 required (§5) @@ -24,6 +24,7 @@ categories: - tooling - product - business + - publication # domain / secondary_domains — primary exactly 1; secondaries 0..n (§6) domains: @@ -85,6 +86,8 @@ capability_families: - traceability - documentation - decision-support + - history + - participation platform_and_operations: - platform - deployment diff --git a/deploy/railiance/apps/helm/state-hub-values.yaml b/deploy/railiance/apps/helm/state-hub-values.yaml index 6bebcb0..02a4bd6 100644 --- a/deploy/railiance/apps/helm/state-hub-values.yaml +++ b/deploy/railiance/apps/helm/state-hub-values.yaml @@ -6,7 +6,7 @@ namespace: image: repository: forgejo.coulomb.social/coulomb/state-hub - tag: "main-0747b4d" + tag: "main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015" ingress: enabled: false diff --git a/docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json b/docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json new file mode 100644 index 0000000..c2f7f1c --- /dev/null +++ b/docs/evidence/2026-10-17-cluster-historical-receiving-rollout.json @@ -0,0 +1,116 @@ +{ + "schema": "statehub.cluster-historical-receiving-delivered.v1", + "status": "verified", + "proposal_id": "STATE-WP-0095-T06-cluster-frozen-receiving-r1", + "approved_revision": 4, + "approved_digest_sha256": "sha256:63a02e8296d126c0158b7eb2c4ce79c7eeb6f8d6f61298d473c1065c7557ba32", + "approval_decision_id": "e3798419-8aa9-5425-8c08-20df6bd82a19", + "source_revision": "51bc70d1ff8f25450a8650d8e50caf053093a02e", + "readiness_source_revision": "086d79a2736e34b916bd7de3c8a65e2ea38a3d64", + "helm_revision": 76, + "image_reference": "forgejo.coulomb.social/coulomb/state-hub:main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015", + "expected_schema": "c4d5e6f7a8b9", + "health": { + "status": "ok", + "instance_role": "primary", + "instance_label": "railiance01", + "schema": { + "status": "ok", + "applied": "c4d5e6f7a8b9", + "expected": "c4d5e6f7a8b9" + } + }, + "pods_ready": true, + "historical_module_sha256": "sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2", + "historical_preservation_verified": true, + "parents": 3, + "tasks": 15, + "full_historical_rows_equal": true, + "native_rollback_probes": { + "foreign-holder": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "missing-task": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "nonterminal-parent": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "nonterminal-task": { + "full_historical_rows_restored": true, + "historical_binding_refused": true, + "native_outcome_status": "refused", + "rolled_back": true, + "status": "passed" + }, + "preserve": { + "full_historical_rows_equal": true, + "full_historical_rows_restored": true, + "native_outcome_status": "applied", + "rolled_back": true, + "status": "passed" + } + }, + "native_probe_commits": 0, + "fresh_transaction_full_rows_equal": true, + "source_receiving_gate_delivered": true, + "normal_cluster_publication_verified": false, + "registrar_admission_or_cluster_publication_not_inferred": true, + "authority_limits": [ + "Rollback probe SQL effects rolled back; foreign-holder case is historical-binding refusal, not separate global holder branch.", + "Actual historical native receiving is verified; owner-native TLS/private admission remains unresolved.", + "Migration schema remains additive; Helm rollback does not downgrade schema or stop an active hook Job." + ], + "evidence": [ + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/approval-decision.json", + "sha256": "sha256:b51554c004daa882f3709f67bdae7f2e939d611120383e61be04ec6969878134" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/helm-return.json", + "sha256": "sha256:61727deacf22d37da2d28cbaf04f5a3ddee0f9ff7571c77f1e85dba97963b572" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/delivered-runtime.json", + "sha256": "sha256:89681066e8fe139456163ebfafa3efe77ffae8534ea167219230b0a74367f129" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/native-refusal-probes.json", + "sha256": "sha256:673ccef965c820704dc310a1bf7832b583c30c3edfeb814528cf892555199172" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/before-full-row-hashes.json", + "sha256": "sha256:75cdd87eef79059ff3f484ed24ca69e285899cdf8d851cc4e472653c417eb0ee" + }, + { + "path": "docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/after-full-row-hashes.json", + "sha256": "sha256:2156aad18f190384838c27e792706a156a7423391408afbc6107e6c28c5872d8" + } + ], + "later_ordinary_cluster_publication": { + "status": "failed_full_parent_row_preservation", + "changed_parent_hashes": 3, + "unchanged_task_hashes": 15, + "exact_changed_columns": "unknown_hash_only_baseline", + "original_parent_preimages": "unavailable_no_guessed_restoration", + "receipt": "the-custodian/docs/changes/netkingdom-target-2026-10-17/receiver-rollout-r4/post-publication-full-row-hashes-FAILED.json", + "T07": "wait", + "RMGR_WP_0019_T06": "wait", + "followup": [ + "STATE-WP-0095-T09", + "STATE-WP-0095-T10" + ] + } +} diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index ea4402d..30cc11b 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -120,7 +120,9 @@ from api.classification import ( # noqa: E402 resolve_topic_domain_slug, ) from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402 -from api.services.historical_projection import BINDINGS as _HISTORICAL_BINDINGS # noqa: E402 +from api.services.historical_projection import ( # noqa: E402 + BINDINGS as _HISTORICAL_BINDINGS, FROZEN_CLUSTER_BINDINGS, frozen_cluster_binding, +) from api.task_status import ( # noqa: E402 CANONICAL_TASK_STATUSES, OPEN_TASK_STATUSES, @@ -318,7 +320,7 @@ def iter_workplan_files(workplans_dir: Path, include_archived: bool = True) -> l # C-00 marks infrastructure/automation faults (API down, repo missing in DB). # All other FAIL severities are assessment findings for follow-up. -_AUTOMATION_ERROR_CHECKS: frozenset[str] = frozenset({"C-00"}) +_AUTOMATION_ERROR_CHECKS: frozenset[str] = frozenset({"C-00", "C-44"}) _NON_WORKPLAN_WORKPLAN_FILES: frozenset[str] = frozenset({"README.md"}) @@ -2484,6 +2486,8 @@ def check_repo( _check_workplan_id_collisions(api_base, repo_slug, repo_dir, workplan_infos, report) _check_scope_freshness(repo_dir, workplan_infos, report) + if _refuse_frozen_fix_batch(report, repo_slug): + return report _sync_workplan_bindings(api_base, repo_slug, workplan_infos, repo_dir, report) return report @@ -2496,23 +2500,44 @@ def _sync_workplan_bindings( repo_dir: Path, report: ConsistencyReport, ) -> None: + if repo_slug == "railiance-cluster": + scanned_paths = {workplan_display_path(repo_dir, path) for path, _, _ in workplan_infos} + missing = [entry["path"] for entry in FROZEN_CLUSTER_BINDINGS + if entry["path"] not in scanned_paths] + if missing: + report.add(severity="FAIL", check_id="C-44", + message="Frozen binding batch refused: bound source missing from scan", + fixable=False) + return bindings: list[dict[str, Any]] = [] for wp_file, meta, _ in workplan_infos: ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') - if not ws_id: - continue archived = wp_file.parent.name == "archived" file_status = normalise_workstream_status(str(meta.get("status", "")).strip()) - bindings.append( - { - "workplan_id": ws_id, - "filename": wp_file.name, - "relative_path": workplan_display_path(repo_dir, wp_file), - "repo_slug": repo_slug, - "archived": archived, - "status": file_status or None, - } - ) + binding = { + "workplan_id": ws_id, "filename": wp_file.name, + "relative_path": workplan_display_path(repo_dir, wp_file), + "repo_slug": repo_slug, "archived": archived, + "status": file_status or None, + } + try: + frozen = frozen_cluster_binding(binding) + if frozen is not None: + # Verify exact source before omitting the immutable binding. + # This is safe even against a receiver predating the API guard. + from hashlib import sha256 + if (wp_file.is_symlink() or not wp_file.is_file() + or sha256(wp_file.read_bytes()).hexdigest() != frozen["sha256"]): + raise ValueError("frozen historical source missing or changed") + continue + except ValueError as exc: + report.add(severity="FAIL", check_id="C-44", + message=f"Frozen binding batch refused: {exc}", + file_path=str(wp_file), fixable=False) + return # Never send a partial batch after a frozen-source refusal. + if not ws_id: + continue + bindings.append(binding) if not bindings: return result = _api_put(api_base, "/workplans/index/bindings", {"bindings": bindings}) @@ -3610,6 +3635,45 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo # Fix engine # --------------------------------------------------------------------------- +def _refuse_frozen_fix_batch(report: ConsistencyReport, repo_slug: str) -> bool: + """Refuse a complete repair batch touching the exact bound histories.""" + if any(issue.check_id == "C-44" and issue.severity == "FAIL" for issue in report.issues): + return True + protected_ids = {entry["uuid"] for entry in FROZEN_CLUSTER_BINDINGS} + protected_ids.update(task["uuid"] for entry in FROZEN_CLUSTER_BINDINGS + for task in entry["tasks"]) + paths = {entry["path"] for entry in FROZEN_CLUSTER_BINDINGS} + root = Path(report.repo_path).absolute() if report.repo_path else None + + def protected(value, key=""): + if isinstance(value, dict): + return any(protected(item, name) for name, item in value.items()) + if isinstance(value, (list, tuple)): + return any(protected(item, key) for item in value) + text = str(value) + if key in {"db_id", "ws_id", "task_id", "from_workstream_id", + "from_workplan_id", "from_task_id", "desired_ws_id", + "desired_task_id", "desired_record_id", "state_hub_workstream_id", "state_hub_task_id"}: + if text in protected_ids: + return True + if key in {"file_path", "wp_file", "md_path", "path"} and text: + candidate = Path(text) + if candidate.is_absolute(): + return root is not None and any(candidate.absolute() == root / path for path in paths) + return repo_slug == "railiance-cluster" and candidate.as_posix() in paths + return False + + for issue in report.issues: + if issue.fixable and (protected(issue.db_id, "db_id") + or protected(issue.file_path, "file_path") + or protected(issue._fix_context)): + report.add(severity="FAIL", check_id="C-44", + message="Frozen historical repair batch refused; owner review required", + fixable=False) + return True + return False + + def fix_repo( api_base: str, repo_slug: str, @@ -3624,7 +3688,9 @@ def fix_repo( repo_path_override, bootstrap_empty_projection=bootstrap_empty_projection, ) - if any(i.check_id in {"C-00", "C-36"} for i in report.failures): + if _refuse_frozen_fix_batch(report, repo_slug): + return report + if any(i.check_id in {"C-00", "C-36", "C-44"} for i in report.failures): return report # RMGR-WP-0002: optional repo-manager reconcile proxy (index rebuild) for pilots diff --git a/tests/test_frozen_binding_guard_isolated.py b/tests/test_frozen_binding_guard_isolated.py new file mode 100644 index 0000000..2512d2f --- /dev/null +++ b/tests/test_frozen_binding_guard_isolated.py @@ -0,0 +1,200 @@ +from pathlib import Path +import ast, asyncio, importlib.util, types, uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +import pytest + +ROOT = Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location('historical',ROOT/'api/services/historical_projection.py') +h=importlib.util.module_from_spec(spec);spec.loader.exec_module(h) +B=h.FROZEN_CLUSTER_BINDINGS[0] +def binding(_bound=B,**changes): + B=_bound + d=dict(workplan_id=uuid.UUID(B['uuid']),repo_slug=B['repo'],filename=Path(B['path']).name,relative_path=B['path'],archived=True,status='finished');d.update(changes);return d +class Obj: + def __init__(self,**kw):self.__dict__.update(kw);self.writes=[] + def __setattr__(self,k,v): + if 'writes' in self.__dict__:self.writes.append(k) + object.__setattr__(self,k,v) +class Entry(Obj): + def model_dump(self):return {k:v for k,v in self.__dict__.items() if k!='writes'} +class Col: + def in_(self,x):return self + def __eq__(self,x):return self +class Query: + def __init__(self):self.locked=False;self.refreshed=False;self.ordered=False + def where(self,*a):return self + def order_by(self,*a):self.ordered=True;return self + def with_for_update(self):self.locked=True;return self + def execution_options(self,**kw):self.refreshed=kw.get("populate_existing") is True;return self +class Model: id=Col();slug=Col();workplan_id=Col() +class Result: + def __init__(self,values):self.values=values + def scalars(self):return self + def all(self):return self.values + def scalar_one_or_none(self):return self.values[0] if self.values else None +class Session: + def __init__(self,rows):self.results=iter(rows);self.commits=0;self.queries=[] + async def execute(self,q):self.queries.append(q);return Result(next(self.results)) + async def commit(self):self.commits+=1 +class HTTPException(Exception): + def __init__(self,status_code,detail):self.status_code=status_code;super().__init__(detail) +def extracted(path,name,extra): + tree=ast.parse((ROOT/path).read_text());node=next(n for n in tree.body if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)) and n.name==name) + node.decorator_list=[];node.returns=None + for a in node.args.args + node.args.kwonlyargs:a.annotation=None + node.args.defaults=[] + ns=dict(Path=Path,datetime=datetime,timezone=timezone,**extra) + exec(compile(ast.Module(body=[node],type_ignores=[]),str(path),'exec'),ns);return ns[name] +def actual_report(): + tree=ast.parse((ROOT/'scripts/consistency_check.py').read_text()) + nodes=[n for n in tree.body if (isinstance(n,ast.ClassDef) and n.name in {'Issue','ConsistencyReport'}) or (isinstance(n,ast.AnnAssign) and isinstance(n.target,ast.Name) and n.target.id=='_AUTOMATION_ERROR_CHECKS')] + ns=dict(dataclass=dataclass,field=field,_AUTOMATION_ERROR_CHECKS=frozenset({'C-00','C-44'})) + exec(compile(ast.Module(body=nodes,type_ignores=[]),'actual_report','exec'),ns) + return ns['ConsistencyReport']('railiance-cluster','/unused') + +def closure(B=B): + repo=Obj(id=uuid.uuid4());parent=Obj(id=uuid.UUID(B['uuid']),repo_id=repo.id,status='finished') + tasks=[Obj(id=uuid.UUID(t['uuid']),workplan_id=parent.id,status='done') for t in B['tasks']] + return repo,parent,tasks +async def run_api(entries,repo,parent,tasks,ordinary=None): + rows=([parent] if parent else [])+([ordinary] if ordinary else []) + session=Session([rows,[repo] if repo else [],tasks]) + fn=extracted('api/routers/workstreams.py','sync_workplan_bindings',dict(frozen_cluster_binding=h.frozen_cluster_binding,validate_frozen_cluster_rows=h.validate_frozen_cluster_rows,HTTPException=HTTPException,select=lambda *_:Query(),Workplan=Model,Task=Model,ManagedRepo=Model,_invalidate_workplan_index_cache=lambda:None)) + result=await fn(Obj(bindings=[Entry(**e) for e in entries]),session) + return result,session +@pytest.mark.parametrize('change',[dict(repo_slug='foreign'),dict(workplan_id=uuid.uuid4()),dict(relative_path='workplans/other.md'),dict(filename='other.md'),dict(archived=False),dict(status='active')]) +def test_metadata_refuses(change): + with pytest.raises(ValueError):h.frozen_cluster_binding(binding(**change)) +def test_unrelated_binding_unchanged():assert h.frozen_cluster_binding(binding(workplan_id=uuid.uuid4(),filename='ordinary.md',relative_path='workplans/ordinary.md',repo_slug='other')) is None +@pytest.mark.parametrize('fault',['parent_missing','foreign_repo','parent_active','task_missing','task_active','task_foreign','repo_missing']) +@pytest.mark.parametrize("bound",h.FROZEN_CLUSTER_BINDINGS) +def test_mixed_batch_refusal_precedes_all_writes(fault,bound): + repo,parent,tasks=closure(bound);ordinary=Obj(id=uuid.uuid4()) + if fault=='repo_missing':repo=None + if fault=='parent_missing':parent=None + if fault=='foreign_repo':parent.repo_id=uuid.uuid4() + if fault=='parent_active':parent.status='active' + if fault=='task_missing':tasks.pop() + if fault=='task_active':tasks[0].status='progress' + if fault=='task_foreign':tasks[0].workplan_id=uuid.uuid4() + if parent:parent.writes.clear() + for t in tasks:t.writes.clear() + normal=binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md') + with pytest.raises(HTTPException):asyncio.run(run_api([normal,binding(_bound=bound)],repo,parent,tasks,ordinary)) + assert ordinary.writes==[] + if parent:assert parent.writes==[] + assert all(t.writes==[] for t in tasks) +@pytest.mark.parametrize("bound",h.FROZEN_CLUSTER_BINDINGS) +def test_exact_frozen_no_setters_or_commit(bound): + repo,parent,tasks=closure(bound);result,session=asyncio.run(run_api([binding(_bound=bound)],repo,parent,tasks)) + assert result==dict(updated=0,received=1,preserved=1);assert session.commits==0;assert parent.writes==[] + assert all(q.locked and q.refreshed and q.ordered for q in (session.queries[0],session.queries[2])) +@pytest.mark.parametrize("bound",h.FROZEN_CLUSTER_BINDINGS) +def test_mixed_valid_preserves_frozen_and_updates_ordinary(bound): + repo,parent,tasks=closure(bound);ordinary=Obj(id=uuid.uuid4()) + normal=binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md') + result,session=asyncio.run(run_api([normal,binding(_bound=bound)],repo,parent,tasks,ordinary)) + assert result==dict(updated=1,received=2,preserved=1);assert session.commits==1;assert parent.writes==[] + assert ordinary.writes==['backing_filename','backing_relative_path','backing_archived','backing_synced_at'] +@pytest.mark.parametrize('tamper',['metadata','bytes','symlink','missing_scan','missing_uuid','valid']) +@pytest.mark.parametrize("bound_index", range(3)) +def test_cli_exact_source_omission_or_whole_batch_refusal(tmp_path,tamper,bound_index): + bound=h.FROZEN_CLUSTER_BINDINGS[bound_index] + import shutil + infos=[] + for entry in h.FROZEN_CLUSTER_BINDINGS: + target=tmp_path/entry['path'];target.parent.mkdir(parents=True,exist_ok=True) + shutil.copyfile(Path('/home/worsch/railiance-cluster')/entry['path'],target) + infos.append((target,dict(state_hub_workstream_id=entry['uuid'],status='finished'),'')) + path=tmp_path/bound['path'] + meta=dict(state_hub_workstream_id=bound['uuid'],status='finished') + if tamper=='metadata':meta['status']='active' + if tamper=='missing_uuid':meta.pop('state_hub_workstream_id') + if tamper=='bytes':path.write_text(path.read_text()+'\n') + if tamper=='symlink':path.unlink();path.symlink_to(Path('/home/worsch/railiance-cluster')/bound['path']) + ordinary=tmp_path/'workplans/ordinary.md';ordinary.write_text('ordinary') + if tamper=='missing_scan':infos.pop(bound_index) + else:infos[bound_index]=(path,meta,'') + calls=[];report=actual_report() + fn=extracted('scripts/consistency_check.py','_sync_workplan_bindings',dict(FROZEN_CLUSTER_BINDINGS=h.FROZEN_CLUSTER_BINDINGS,frozen_cluster_binding=h.frozen_cluster_binding,normalise_workstream_status=lambda s:s,workplan_display_path=lambda root,p:p.relative_to(root).as_posix(),_api_put=lambda *a:calls.append(a) or dict(updated=1,received=1))) + fn('http://unused','railiance-cluster',[(ordinary,dict(state_hub_workstream_id=str(uuid.uuid4()),status='active'),''),*infos],tmp_path,report) + if tamper=='valid':assert len(calls)==1 and len(calls[0][2]['bindings'])==1 + else:assert calls==[] and report.failures[0].check_id=='C-44' and report.automation_errors + + +def test_api_tampered_mixed_batch_refuses_before_queries_or_assignments(): + repo,parent,tasks=closure();ordinary=Obj(id=uuid.uuid4()) + with pytest.raises(HTTPException): + asyncio.run(run_api([binding(workplan_id=ordinary.id,repo_slug='ordinary',filename='ordinary.md',relative_path='workplans/ordinary.md'),binding(archived=False)],repo,parent,tasks,ordinary)) + assert ordinary.writes==[] and parent.writes==[] + + +def test_foreign_repo_same_filename_path_is_ordinary(): + assert h.frozen_cluster_binding(binding(workplan_id=uuid.uuid4(),repo_slug='foreign')) is None + + +@pytest.mark.parametrize('entry',h.FROZEN_CLUSTER_BINDINGS) +def test_all_three_registry_bindings_are_guarded(entry): + good=dict(workplan_id=entry['uuid'],repo_slug=entry['repo'],filename=Path(entry['path']).name,relative_path=entry['path'],archived=True,status='finished') + assert h.frozen_cluster_binding(good) is entry + with pytest.raises(ValueError):h.frozen_cluster_binding(dict(good,status='active')) + + +def test_real_cli_exit_criterion_is_nonzero_including_remote_all(): + report=actual_report();report.add(severity='FAIL',check_id='C-44',message='refused') + fn=extracted('scripts/consistency_check.py','consistency_exit_code',{}) + assert fn([report])==1 and fn([report],remote_all=True)==1 + + +@pytest.mark.parametrize('check,key,protected_kind',[ + ('C-04','ws_id','parent'),('C-10','task_id','task'),('C-22','task_id','task'), + ('C-09','db_id','parent'),('C-23','wp_file','path'), +]) +@pytest.mark.parametrize('bound',h.FROZEN_CLUSTER_BINDINGS) +def test_complete_fix_batch_preflight_before_ordinary_writes(check,key,protected_kind,bound): + report=actual_report();report.repo_path='/tmp/fixture-cluster' + report.add(severity='WARN',check_id='C-05',message='ordinary first',fixable=True,_fix_context={'ws_id':str(uuid.uuid4()),'field':'title','value':'ordinary'}) + value=bound['uuid'] if protected_kind=='parent' else bound['tasks'][0]['uuid'] if protected_kind=='task' else str(Path(report.repo_path)/bound['path']) + report.add(severity='WARN',check_id=check,message='protected later',fixable=True,db_id=value if key=='db_id' else '',_fix_context={key:value}) + guard=extracted('scripts/consistency_check.py','_refuse_frozen_fix_batch',dict(FROZEN_CLUSTER_BINDINGS=h.FROZEN_CLUSTER_BINDINGS)) + assert guard(report,'railiance-cluster') and report.automation_errors + calls=[] + fn=extracted('scripts/consistency_check.py','fix_repo',dict(check_repo=lambda *a,**kw:report,_refuse_frozen_fix_batch=guard,_api_post=lambda *a:calls.append(a),_api_patch=lambda *a:calls.append(a))) + assert fn('http://unused','railiance-cluster',None,False,False) is report and calls==[] + +def test_unrelated_fix_context_not_refused(): + report=actual_report();report.add(severity='WARN',check_id='C-05',message='ordinary',fixable=True,_fix_context={'ws_id':str(uuid.uuid4())}) + guard=extracted('scripts/consistency_check.py','_refuse_frozen_fix_batch',dict(FROZEN_CLUSTER_BINDINGS=h.FROZEN_CLUSTER_BINDINGS)) + assert not guard(report,'other') + + +def test_check_repo_guard_precedes_binding_sync_and_does_not_duplicate(): + tree=ast.parse((ROOT/'scripts/consistency_check.py').read_text()) + node=next(n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='check_repo') + guard_lines=[n.lineno for n in ast.walk(node) if isinstance(n,ast.Call) and isinstance(n.func,ast.Name) and n.func.id=='_refuse_frozen_fix_batch'] + sync_lines=[n.lineno for n in ast.walk(node) if isinstance(n,ast.Call) and isinstance(n.func,ast.Name) and n.func.id=='_sync_workplan_bindings'] + assert len(guard_lines)==len(sync_lines)==1 and guard_lines[0]