From 98d85ca934b5263d81834957c610fe40750197cf Mon Sep 17 00:00:00 2001 From: pcvantol Date: Fri, 18 Sep 2026 08:53:03 +0200 Subject: [PATCH 1/2] Fix same-operation reset revalidation --- .../EP_CENTRAL_OPERATIONAL_RESET_V1.md | 64 ++++-- .../EP_SERVER_PRODUCTION_RELEASE.md | 8 + package-lock.json | 4 +- package.json | 2 +- pyproject.toml | 2 +- .../ENGINEERING_PLATFORM_CONFIG.json | 2 +- .../ENGINEERING_PLATFORM_VERSION.json | 8 +- .../central_operational_reset.py | 186 +++++++++++++++++- src/engineering_platform/platform_version.py | 2 +- .../templates/workspace-config.json | 2 +- .../test_central_operational_reset.py | 99 ++++++++++ .../test_operational_reset_coordinator.py | 7 +- .../operational_reset_coordinator.py | 21 +- 13 files changed, 362 insertions(+), 45 deletions(-) diff --git a/docs/engineering/EP_CENTRAL_OPERATIONAL_RESET_V1.md b/docs/engineering/EP_CENTRAL_OPERATIONAL_RESET_V1.md index 2e2423a1..918051a8 100644 --- a/docs/engineering/EP_CENTRAL_OPERATIONAL_RESET_V1.md +++ b/docs/engineering/EP_CENTRAL_OPERATIONAL_RESET_V1.md @@ -2,7 +2,7 @@ **Owning product:** Engineering Platform. **Profile:** `EP_CENTRAL_OPERATIONAL_HISTORY_V1`. **Server schema:** 68. **Candidate -release:** 2.3.82. +release:** 2.3.83. This is a bounded local maintenance route for removing EP operational history while retaining installation identity, project and repository attachment, @@ -33,6 +33,17 @@ the installation lock, derives the real local actor as `uid::`, persists `PREPARING`, and thereby activates database triggers that reject normal INSERT/UPDATE/DELETE entry points after any process restart. +Plan version 2 separates that approved meaning from current availability. +General `preview` remains read-only and reports `MAINTENANCE_ALREADY_ACTIVE` +while any operation owns the writer fence; it cannot join that operation. +Only `revalidate` accepts an already prepared operation ID and original plan +digest. It read-only re-proves the real actor, exact active fence owner and +phase, target/database/schema, source-row contents, preserved project/ +consumer/security/configuration bindings, generation, verified backup and +product/implementation provenance, and returns a separate revalidation digest. +It creates no operation, authority, backup or domain write. Version-1 prepared +plans are rejected rather than reinterpreted under version 2. + The durable states are: ```text @@ -57,6 +68,12 @@ capability is present only on the maintenance service's connections. Preview requires the complete schema-derived normal-writer trigger set plus every maintenance-table guard before it can be allowed. +`apply` calls the same owning revalidation and then repeats actor, target, +schema/table set, active-operation ownership, meaningful source, preserved +bindings, schema objects, implementation provenance and generation checks +inside its actual database mutation transaction. A successful earlier +revalidation therefore cannot survive later fence loss or drift. + Before the first external effect, `abort` may move `PREPARING` or `AUTHORIZED` to terminal `ABORTED` and release the fence. It is rejected after artifact archiving or database apply starts. A failed-backup resume accepts only the @@ -336,19 +353,26 @@ engineering-platform-maintenance prepare \ --backup-root "/exact/protected/recovery/root" \ --allow-operational-fk "fk::::" -# 2. DESTRUCTIVE: archive active artifacts/Inbox and purge the approved DB set. +# 2. READ-ONLY: re-prove this exact prepared operation. A general preview still +# blocks new operations during maintenance and is not this gate. +engineering-platform-maintenance revalidate \ + --data-root "/exact/Engineering Platform Server/data" \ + --operation-id "central-clean-" \ + --plan-digest "$PREVIEW_PLAN_DIGEST" + +# 3. DESTRUCTIVE: archive active artifacts/Inbox and purge the approved DB set. engineering-platform-maintenance apply \ --data-root "/exact/Engineering Platform Server/data" \ --operation-id "central-clean-" \ --plan-digest "$PREVIEW_PLAN_DIGEST" -# 3. Prove physical/referential integrity, operational emptiness, backup and bindings. +# 4. Prove physical/referential integrity, operational emptiness, backup and bindings. engineering-platform-maintenance verify \ --data-root "/exact/Engineering Platform Server/data" \ --operation-id "central-clean-" \ --plan-digest "$PREVIEW_PLAN_DIGEST" -# 4. Only after the coordinated Forge+EP verification authorizes normal writers. +# 5. Only after the coordinated Forge+EP verification authorizes normal writers. engineering-platform-maintenance finish \ --data-root "/exact/Engineering Platform Server/data" \ --operation-id "central-clean-" \ @@ -388,6 +412,11 @@ as subprocesses. Its receipt directory is mode `0700`, its receipt and lock are mode `0600`, updates are atomic and fsynced, and symlinks or a concurrent coordinator process are rejected. +Coordinator contract v2 invokes each owning product's operation-bound +`revalidate` command; it no longer substitutes a second general preview or +normalizes product output. Existing v1 coordinator receipts are deliberately +refused and must not be reused as fresh reset authority. + Choose a new joint reference and two distinct product operation IDs. Bind the exact installed CLI files, target roots and protected EP backup root during the initial preview: @@ -458,11 +487,12 @@ in this delivery; do not run them against CENTRAL as part of installation. The joint receipt reaches `COMPLETE` only after both owning verifications, explicit resume authorization and both owning finishes. -Immediately before every individual `finish`, the harness calls both owning -`verify` commands again and requires each product to report `VERIFIED` or -`COMPLETED`. This is a two-phase pre-finish readiness check, not a distributed -transaction: the owning finishes remain sequential. If the first product has -already reached `COMPLETED` and the second finish fails, the receipt records +Immediately before every individual `finish`, the harness calls owning +`verify` for an active verified operation and read-only owning `status` for a +peer that is already terminal. It requires each product to report `VERIFIED` +or `COMPLETED`. This is a two-phase pre-finish readiness check, not a +distributed transaction: the owning finishes remain sequential. If the first +product has already reached `COMPLETED` and the second finish fails, the receipt records `RECONCILIATION_REQUIRED`; reconciliation truthfully returns the pair `COMPLETED`/`VERIFIED`, keeps the unfinished product fenced, and requires an explicit retry of that same second finish. It never rolls the first product @@ -501,11 +531,11 @@ decision. ## Delivery status -The installed 2.3.81 read-only preview proved schema 68, `quick_check`, foreign -keys and counts, but exposed a false `EXTERNAL_SYMLINK_UNSAFE` blocker by -descending into a preserved updater `candidate-venv`. Release 2.3.82 contains -the targeted opaque-boundary correction and synthetic positive/negative -qualification. Protected PR review, hosted full gates, release publication, -artifact-byte qualification and a new installed live preview remain separate -evidence. Installation may activate schema 68 and the chat relationship repair -but must not automatically prepare/apply a reset or delete historical rows. +Release 2.3.82 remains the completed opaque-updater-boundary correction. +Candidate 2.3.83 adds only the operation-bound revalidation contract and the +coordinator-v2 consumer required by this remediation. Candidate qualification +uses non-editable wheels, separate CLI processes, real SQLite backups and +owning mutations on synthetic roots. Protected review, hosted full gates, +release publication, artifact-byte qualification and installed repetition +remain separate evidence until completed. Installation must not automatically +prepare/apply a reset or delete historical rows. diff --git a/docs/engineering/EP_SERVER_PRODUCTION_RELEASE.md b/docs/engineering/EP_SERVER_PRODUCTION_RELEASE.md index 345c0d3a..260b4838 100644 --- a/docs/engineering/EP_SERVER_PRODUCTION_RELEASE.md +++ b/docs/engineering/EP_SERVER_PRODUCTION_RELEASE.md @@ -35,6 +35,14 @@ canonical staged wheel filename and digest are verified, with an explicit incomplete-state readback. It never turns an arbitrary operation sibling, malformed marker or linked boundary into known product data. +The 2.3.83 corrective patch keeps schema 68. It adds a read-only, +operation-bound revalidation command for an already `AUTHORIZED` reset and +makes the external coordinator use that owning command instead of a second +general preview. General preview still blocks new work during maintenance; +apply repeats the source, authority, fence, backup, target and implementation +checks at its mutation boundary. Publication and installation do not authorize +a production reset. + ## Release-operation lifecycle V1 After both the production-wheel and dashboard qualifications succeed, the diff --git a/package-lock.json b/package-lock.json index 120f6935..66ba36aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "engineering-platform-browser-validation", - "version": "2.3.82", + "version": "2.3.83", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "engineering-platform-browser-validation", - "version": "2.3.82", + "version": "2.3.83", "devDependencies": { "@playwright/test": "1.63.0" } diff --git a/package.json b/package.json index ac073ab3..a73c1538 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "engineering-platform-browser-validation", "private": true, - "version": "2.3.82", + "version": "2.3.83", "scripts": { "test:engineering-dashboard": "PYTHONPATH=src python3 -m engineering_platform.dashboard_browser_validation", "test:engineering-dashboard-logic": "node --test tests/engineering/dashboard_status_store.test.mjs tests/engineering/ui_localization_contract.test.mjs tests/engineering/dashboard_translation.test.mjs", diff --git a/pyproject.toml b/pyproject.toml index d61d6430..a1b092fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "engineering-platform" -version = "2.3.82" +version = "2.3.83" description = "Local-first Engineering Platform execution operations runtime" readme = "README.md" requires-python = ">=3.14,<3.15" diff --git a/src/engineering_platform/ENGINEERING_PLATFORM_CONFIG.json b/src/engineering_platform/ENGINEERING_PLATFORM_CONFIG.json index a8af789e..0aae03c7 100644 --- a/src/engineering_platform/ENGINEERING_PLATFORM_CONFIG.json +++ b/src/engineering_platform/ENGINEERING_PLATFORM_CONFIG.json @@ -3,7 +3,7 @@ "platform": { "id": "engineering-platform", "name": "Engineering Platform", - "version": "2.3.82", + "version": "2.3.83", "generation": 2, "documentation_namespace": "engineering-platform", "capability_registry_version": 1 diff --git a/src/engineering_platform/ENGINEERING_PLATFORM_VERSION.json b/src/engineering_platform/ENGINEERING_PLATFORM_VERSION.json index 07cf3642..d788e129 100644 --- a/src/engineering_platform/ENGINEERING_PLATFORM_VERSION.json +++ b/src/engineering_platform/ENGINEERING_PLATFORM_VERSION.json @@ -1,15 +1,15 @@ { "bootstrap_contract": "2026.12", "checkpoint_format": 1, - "dashboard_version": "2.3.82", + "dashboard_version": "2.3.83", "handoff_protocol": 1, "memory_format": 2, "minimum_codex_cli": "0.146.0", "inbox_protocol": 1, - "platform_version": "2.3.82", + "platform_version": "2.3.83", "report_format": 2, - "runner_version": "2.3.82", + "runner_version": "2.3.83", "status_model": 1, "storage_schema": 45, - "watcher_version": "2.3.82" + "watcher_version": "2.3.83" } diff --git a/src/engineering_platform/central_operational_reset.py b/src/engineering_platform/central_operational_reset.py index 57173487..80e38f13 100644 --- a/src/engineering_platform/central_operational_reset.py +++ b/src/engineering_platform/central_operational_reset.py @@ -21,11 +21,13 @@ import shutil import sqlite3 import stat +import subprocess import tempfile -from typing import Iterator +from typing import Iterator, Mapping from . import central_database from .platform_admin import require_installation_owner +from .platform_version import CURRENT_PLATFORM_VERSION from .operational_installation_lock import ( OperationalInstallationLock, OperationalInstallationLockError, ) @@ -33,7 +35,7 @@ PROFILE = "EP_CENTRAL_OPERATIONAL_HISTORY_V1" -PLAN_VERSION = 1 +PLAN_VERSION = 2 SCHEMA_VERSION = 68 _OPERATION = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{7,127}") _INSTANCE_ID = re.compile( @@ -212,6 +214,36 @@ def _file_digest(path: Path) -> str: return "sha256:" + digest.hexdigest() +def _implementation_digest() -> str: + return _digest({ + "module_digest": _file_digest(Path(__file__)), + "product_version": CURRENT_PLATFORM_VERSION, + }) + + +def _implementation_source_revision() -> str: + try: + module = Path(__file__).resolve() + root = Path(subprocess.check_output( + ("git", "-C", str(module.parent), "rev-parse", "--show-toplevel"), + text=True, stderr=subprocess.DEVNULL, + ).strip()).resolve() + relative = module.relative_to(root).as_posix() + subprocess.run( + ("git", "-C", str(root), "ls-files", "--error-unmatch", relative), + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + revision = subprocess.check_output( + ("git", "-C", str(root), "rev-parse", "HEAD"), + text=True, stderr=subprocess.DEVNULL, + ).strip() + if re.fullmatch(r"[0-9a-f]{40}", revision): + return revision + except (OSError, ValueError, subprocess.CalledProcessError): + pass + return "UNAVAILABLE_IN_INSTALLED_PACKAGE" + + def _quote(identifier: str) -> str: return '"' + identifier.replace('"', '""') + '"' @@ -1276,6 +1308,13 @@ def preview(data_root: Path) -> dict[str, object]: missing_writer_fences = sorted( _expected_writer_fences(tables) - installed_triggers ) + active_rows = [ + {"operation_id": str(row[0]), "state": str(row[1])} + for row in connection.execute( + "SELECT operation_id,state FROM ep_operational_reset_operations " + "WHERE state NOT IN ('COMPLETED','ABORTED') ORDER BY operation_id" + ) + ] if "ep_operational_reset_operations" in tables else [] except (OSError, sqlite3.DatabaseError, TypeError, ValueError) as error: raise OperationalResetError("CENTRAL_INSPECTION_FAILED") from error external_error: str | None = None @@ -1315,6 +1354,8 @@ def preview(data_root: Path) -> dict[str, object]: blockers.append("PRESERVATION_INTEGRITY_FAILED") if activity["active"]: blockers.append("TARGET_WRITER_ACTIVE") + if active_rows: + blockers.append("MAINTENANCE_ALREADY_ACTIVE") review = [str(item["id"]) for item in foreign_keys if item["classification"] == "OPERATIONAL_PURGE_SET"] target_digest = _digest(identity) effect_set = { @@ -1326,8 +1367,12 @@ def preview(data_root: Path) -> dict[str, object]: "external_roots": list(_EFFECT_DIRECTORIES), } plan_basis = { - "plan_version": PLAN_VERSION, "profile": PROFILE, "target": identity, + "plan_version": PLAN_VERSION, "operator_contract": "operational-reset-v1", + "profile": PROFILE, "product_version": CURRENT_PLATFORM_VERSION, + "implementation_source_revision": _implementation_source_revision(), + "implementation_digest": _implementation_digest(), "target": identity, "target_digest": target_digest, "source_revision": source_revision, + "meaningful_source_revision": source_revision, "preserved_bindings_digest": preserved_digest, "dataset_generation": generation, "classification_counts": counts, "foreign_key_findings": foreign_keys, "external_files": external, @@ -1347,6 +1392,7 @@ def preview(data_root: Path) -> dict[str, object]: return { **plan_basis, "plan_digest": _digest(plan_basis), "quick_check": quick, "runtime_activity": activity, "blocking_codes": sorted(set(blockers)), + "active_maintenance": active_rows, "review_required": review, "runtime_control_external": [ item for item in preserved_external @@ -1665,7 +1711,10 @@ def prepare( plan = preview(root) if plan["plan_digest"] != plan_digest: raise OperationalResetError("PLAN_DIGEST_MISMATCH") - if plan["blocking_codes"]: + non_maintenance_blockers = [ + code for code in plan["blocking_codes"] if code != "MAINTENANCE_ALREADY_ACTIVE" + ] + if non_maintenance_blockers: raise OperationalResetError("PLAN_BLOCKED", ",".join(plan["blocking_codes"])) expected_findings = tuple(sorted(str(item) for item in plan["review_required"])) allowed = tuple(sorted(set(allowed_fk_findings))) @@ -1685,6 +1734,8 @@ def prepare( if existing["request_digest"] != request_digest: raise OperationalResetError("OPERATION_REQUEST_CONFLICT") return _public_status(existing) + if plan["blocking_codes"]: + raise OperationalResetError("PLAN_BLOCKED", ",".join(plan["blocking_codes"])) generation = int(connection.execute( "SELECT generation FROM ep_operational_dataset_state WHERE singleton=1" ).fetchone()[0]) @@ -1768,6 +1819,68 @@ def maintenance_active(data_root: Path) -> bool: raise OperationalResetError("MAINTENANCE_STATE_UNAVAILABLE") from error +def revalidate(data_root: Path, *, operation_id: str, plan_digest: str) -> dict[str, object]: + """Read-only proof for one already prepared owning operation.""" + root = _trusted_directory(data_root, code="DATA_ROOT_UNSAFE") + actor = _actor(root) + database = root / central_database.DATABASE_FILENAME + with _central_connection(f"file:{database}?mode=ro", uri=True) as connection: + connection.execute("PRAGMA query_only=ON") + row = _operation(connection, operation_id) + if row is None or row["plan_digest"] != plan_digest: + raise OperationalResetError("AUTHORIZED_OPERATION_NOT_FOUND") + if row["state"] != "AUTHORIZED": + raise OperationalResetError("OPERATION_STATE_INVALID_FOR_REVALIDATION") + if row["actor"] != actor: + raise OperationalResetError("OPERATOR_AUTHORITY_CHANGED") + plan = json.loads(str(row["plan_json"])) + if int(plan.get("plan_version", -1)) != PLAN_VERSION: + raise OperationalResetError("PLAN_VERSION_UNSUPPORTED") + observed = preview(root) + if observed["active_maintenance"] != [{ + "operation_id": operation_id, "state": "AUTHORIZED", + }]: + raise OperationalResetError("WRITER_FENCE_OWNER_CHANGED") + if observed["blocking_codes"] != ["MAINTENANCE_ALREADY_ACTIVE"]: + raise OperationalResetError("REVALIDATION_BLOCKED") + for key in ( + "plan_digest", "target_digest", "source_revision", "preserved_bindings_digest", + "dataset_generation", "effect_set", "schema_objects", "foreign_key_findings", + "external_files", "preserved_external", + ): + expected = row["plan_digest"] if key == "plan_digest" else plan[key] + if observed[key] != expected: + raise OperationalResetError("REVALIDATION_CHANGED", key) + if int(observed["dataset_generation"]) != int(row["generation_before"]): + raise OperationalResetError("DATASET_GENERATION_CONFLICT") + backup_path = row.get("backup_path") + backup_digest = row.get("backup_manifest_digest") + if not isinstance(backup_path, str) or not isinstance(backup_digest, str): + raise OperationalResetError("BACKUP_BINDING_INVALID") + verify_backup( + Path(backup_path), operation_id=operation_id, plan_digest=plan_digest, + expected_manifest_digest=backup_digest, + ) + evidence = { + "operation_id": operation_id, "state": str(row["state"]), + "writer_fence_owner": operation_id, "actor": actor, + "target_digest": str(row["target_digest"]), + "plan_digest": str(row["plan_digest"]), + "request_digest": str(row["request_digest"]), + "source_revision": str(row["source_revision"]), + "implementation_source_revision": str(plan["implementation_source_revision"]), + "implementation_digest": str(plan["implementation_digest"]), + "product_version": str(plan["product_version"]), + "preserved_bindings_digest": str(plan["preserved_bindings_digest"]), + "backup_manifest_digest": backup_digest, + "dataset_generation": int(row["generation_before"]), + } + evidence["revalidation_digest"] = _digest(evidence) + result = _public_status(row) + result["revalidation"] = evidence + return result + + def _archive_effects(data_root: Path, operation_id: str, plan: dict[str, object]) -> None: archive = _secure_mkdirs( data_root, Path("operational-reset-archive") / operation_id, @@ -2082,12 +2195,14 @@ def _delete_operational(connection: sqlite3.Connection) -> None: def apply(data_root: Path, *, operation_id: str, plan_digest: str) -> dict[str, object]: root = _trusted_directory(data_root, code="DATA_ROOT_UNSAFE") with _operation_lock(root, operation_id): - _actor(root) + actor = _actor(root) database = root / central_database.DATABASE_FILENAME with _central_connection(database) as connection: row = _operation(connection, operation_id) if row is None or row["plan_digest"] != plan_digest: raise OperationalResetError("AUTHORIZED_OPERATION_NOT_FOUND") + if row["actor"] != actor: + raise OperationalResetError("OPERATOR_AUTHORITY_CHANGED") state = str(row["state"]) if state == "PREPARING": raise OperationalResetError("BACKUP_NOT_AUTHORIZED") @@ -2106,9 +2221,7 @@ def apply(data_root: Path, *, operation_id: str, plan_digest: str) -> dict[str, return _public_status(row) plan = json.loads(str(row["plan_json"])) if state == "AUTHORIZED": - current = preview(root) - if current["source_revision"] != row["source_revision"] or current["plan_digest"] != plan_digest: - raise OperationalResetError("SOURCE_REVISION_CHANGED") + revalidate(root, operation_id=operation_id, plan_digest=plan_digest) with _central_connection(database) as connection: connection.execute("BEGIN IMMEDIATE") _transition(connection, operation_id, "AUTHORIZED", "ARTIFACTS_ARCHIVING") @@ -2133,6 +2246,39 @@ def apply(data_root: Path, *, operation_id: str, plan_digest: str) -> dict[str, current = _operation(connection, operation_id) if current is None or current["state"] != "ARTIFACTS_ARCHIVED": raise OperationalResetError("OPERATION_STATE_TRANSITION_CONFLICT") + if _actor(root) != row["actor"]: + raise OperationalResetError("OPERATOR_AUTHORITY_CHANGED") + tables = _tables(connection) + if tables != MAPPED_TABLES: + raise OperationalResetError("TABLE_CLASSIFICATION_INCOMPLETE") + active = [ + (str(item[0]), str(item[1])) for item in connection.execute( + "SELECT operation_id,state FROM ep_operational_reset_operations " + "WHERE state NOT IN ('COMPLETED','ABORTED') ORDER BY operation_id" + ) + ] + if active != [(operation_id, "ARTIFACTS_ARCHIVED")]: + raise OperationalResetError("WRITER_FENCE_OWNER_CHANGED") + identity = _identity(root, connection) + if identity != plan["target"]: + raise OperationalResetError("TARGET_IDENTITY_CONFLICT") + if _logical_digest(connection, tables) != row["source_revision"]: + raise OperationalResetError("SOURCE_REVISION_CHANGED") + if _logical_digest(connection, tables, preserved_only=True) != plan["preserved_bindings_digest"]: + raise OperationalResetError("PRESERVED_BINDINGS_CHANGED") + if _schema_objects(connection) != plan["schema_objects"]: + raise OperationalResetError("SCHEMA_OBJECTS_CHANGED") + if ( + plan.get("product_version") != CURRENT_PLATFORM_VERSION + or plan.get("implementation_source_revision") != _implementation_source_revision() + or plan.get("implementation_digest") != _implementation_digest() + ): + raise OperationalResetError("IMPLEMENTATION_PROVENANCE_CHANGED") + generation_before = int(connection.execute( + "SELECT generation FROM ep_operational_dataset_state WHERE singleton=1" + ).fetchone()[0]) + if generation_before != int(row["generation_before"]): + raise OperationalResetError("DATASET_GENERATION_CONFLICT") # Immutable-evidence and maintenance-block triggers are removed and # recreated inside this one uncommitted transaction. Other writers # cannot observe an unfenced schema window. @@ -2467,6 +2613,7 @@ def resume(data_root: Path, *, operation_id: str, plan_digest: str, def contract_readback( data_root: Path, *, command: str, operation_id: str | None, + revalidation_result: Mapping[str, object] | None = None, ) -> dict[str, object]: """Project the shared operator contract from persisted, public state only. @@ -2505,6 +2652,21 @@ def contract_readback( } target = observed["target"] blockers = list(observed["blocking_codes"]) + revalidation_evidence: dict[str, object] | None = None + if command == "revalidate": + candidate = None if revalidation_result is None else revalidation_result.get("revalidation") + if not isinstance(candidate, Mapping): + raise OperationalResetError("REVALIDATION_EVIDENCE_INVALID") + allowed_keys = { + "operation_id", "state", "writer_fence_owner", "actor", "target_digest", + "plan_digest", "request_digest", "source_revision", "preserved_bindings_digest", + "backup_manifest_digest", "dataset_generation", "implementation_source_revision", + "implementation_digest", "product_version", "revalidation_digest", + } + if set(candidate) != allowed_keys: + raise OperationalResetError("REVALIDATION_EVIDENCE_INVALID") + revalidation_evidence = dict(candidate) + blockers = [] if operation.get("state") == "FAILED": blockers.append("OPERATION_FAILED_RECONCILIATION_REQUIRED") return { @@ -2529,6 +2691,7 @@ def contract_readback( "details": { "credentials_included_in_receipt": False, "projection": "PERSISTED_PUBLIC_MAINTENANCE_STATE", + **({"revalidation": revalidation_evidence} if revalidation_evidence is not None else {}), }, } @@ -2559,7 +2722,7 @@ def main(argv: list[str] | None = None) -> int: parser = _ReceiptArgumentParser(prog="engineering-platform-maintenance") parser.add_argument( "command", - choices=("preview", "prepare", "apply", "status", "resume", "verify", "finish", "abort"), + choices=("preview", "prepare", "revalidate", "apply", "status", "resume", "verify", "finish", "abort"), ) parser.add_argument("--data-root", type=Path, required=True) parser.add_argument("--operation-id") @@ -2588,6 +2751,10 @@ def main(argv: list[str] | None = None) -> int: allowed_fk_findings=tuple(args.allow_operational_fk)) elif args.command == "apply": result = apply(args.data_root, operation_id=args.operation_id, plan_digest=args.plan_digest) + elif args.command == "revalidate": + result = revalidate( + args.data_root, operation_id=args.operation_id, plan_digest=args.plan_digest, + ) elif args.command == "resume": result = resume(args.data_root, operation_id=args.operation_id, plan_digest=args.plan_digest, backup_root=args.backup_root) @@ -2601,6 +2768,7 @@ def main(argv: list[str] | None = None) -> int: # from the schema-owned public projection after the command completes. print(json.dumps(contract_readback( args.data_root, command=args.command, operation_id=args.operation_id, + revalidation_result=result if args.command == "revalidate" else None, ), sort_keys=True)) return 0 except OperationalResetError as error: diff --git a/src/engineering_platform/platform_version.py b/src/engineering_platform/platform_version.py index 7db9d044..623fa483 100644 --- a/src/engineering_platform/platform_version.py +++ b/src/engineering_platform/platform_version.py @@ -12,7 +12,7 @@ SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") CONTRACT = re.compile(r"^(\d{4})\.(0[1-9]|1[0-2])$") -CURRENT_PLATFORM_VERSION = "2.3.82" +CURRENT_PLATFORM_VERSION = "2.3.83" MANIFEST_FIELDS = frozenset( { "platform_version", diff --git a/src/engineering_platform/templates/workspace-config.json b/src/engineering_platform/templates/workspace-config.json index fc681316..5b8f2427 100644 --- a/src/engineering_platform/templates/workspace-config.json +++ b/src/engineering_platform/templates/workspace-config.json @@ -3,7 +3,7 @@ "platform": { "id": "engineering-platform", "name": "Engineering Platform", - "version": "2.3.82", + "version": "2.3.83", "generation": 2, "documentation_namespace": "engineering-platform", "capability_registry_version": 1 diff --git a/tests/engineering/test_central_operational_reset.py b/tests/engineering/test_central_operational_reset.py index 4f7fb34f..c3bd9427 100644 --- a/tests/engineering/test_central_operational_reset.py +++ b/tests/engineering/test_central_operational_reset.py @@ -256,6 +256,102 @@ def test_changed_source_plan_target_and_request_are_rejected(self) -> None: reset.prepare(other, operation_id="reset-other-0001", plan_digest=str(plan["plan_digest"]), backup_root=self.backups) + def test_prepared_operation_has_explicit_read_only_revalidation(self) -> None: + self._populate() + initial = reset.preview(self.root) + operation_id, digest = self._prepared(operation_id="reset-revalidate-0001") + before = reset.status(self.root, operation_id=operation_id) + + generic = reset.preview(self.root) + self.assertEqual(generic["plan_digest"], initial["plan_digest"]) + self.assertEqual(generic["blocking_codes"], ["MAINTENANCE_ALREADY_ACTIVE"]) + self.assertEqual( + generic["active_maintenance"], + [{"operation_id": operation_id, "state": "AUTHORIZED"}], + ) + with self.assertRaisesRegex(reset.OperationalResetError, "PLAN_BLOCKED"): + reset.prepare( + self.root, operation_id="reset-revalidate-foreign-0001", + plan_digest=digest, backup_root=self.backups, + ) + + revalidated = reset.revalidate( + self.root, operation_id=operation_id, plan_digest=digest, + ) + + self.assertEqual(revalidated["plan_digest"], digest) + self.assertEqual(revalidated["revalidation"]["writer_fence_owner"], operation_id) + self.assertRegex( + str(revalidated["revalidation"]["revalidation_digest"]), + r"^sha256:[0-9a-f]{64}$", + ) + self.assertEqual(before, reset.status(self.root, operation_id=operation_id)) + + def test_revalidation_rejects_same_count_security_drift_and_backup_tampering(self) -> None: + self._populate() + operation_id, digest = self._prepared(operation_id="reset-revalidate-drift-0001") + database = self.root / "epdata.sqlite" + with reset._central_connection(database) as connection: + triggers = [ + (str(name), str(sql)) for name, sql in connection.execute( + "SELECT name,sql FROM sqlite_master WHERE type='trigger' " + "AND tbl_name='ep_consumer_registrations'" + ) if sql is not None + ] + for name, _sql in triggers: + connection.execute(f'DROP TRIGGER "{name}"') + connection.execute( + "UPDATE ep_consumer_registrations SET status='REVOKED' WHERE consumer_id='consumer-a'" + ) + for _name, sql in triggers: + connection.execute(sql) + with self.assertRaisesRegex(reset.OperationalResetError, "REVALIDATION_CHANGED"): + reset.revalidate(self.root, operation_id=operation_id, plan_digest=digest) + + backup_root = Path(self.temporary.name) / "backup-tamper-data" + backup_destination = Path(self.temporary.name) / "backup-tamper-recovery" + server.initialize(backup_root) + plan = reset.preview(backup_root) + reset.prepare( + backup_root, operation_id="reset-revalidate-backup-0001", + plan_digest=str(plan["plan_digest"]), backup_root=backup_destination, + ) + status = reset.status( + backup_root, operation_id="reset-revalidate-backup-0001", + )["operation"] + manifest = Path(str(status["backup_path"])) / "manifest.json" + manifest_payload = json.loads(manifest.read_text(encoding="utf-8")) + manifest_payload["profile"] = "tampered" + manifest.write_text(json.dumps(manifest_payload), encoding="utf-8") + with self.assertRaisesRegex(reset.OperationalResetError, "BACKUP_MANIFEST_DIGEST_MISMATCH"): + reset.revalidate( + backup_root, operation_id="reset-revalidate-backup-0001", + plan_digest=str(plan["plan_digest"]), + ) + + def test_apply_rechecks_source_at_the_database_mutation_boundary(self) -> None: + self._populate() + operation_id, digest = self._prepared(operation_id="reset-apply-recheck-0001") + reset.revalidate(self.root, operation_id=operation_id, plan_digest=digest) + database = self.root / "epdata.sqlite" + with reset._central_connection(database) as connection: + triggers = [ + (str(name), str(sql)) for name, sql in connection.execute( + "SELECT name,sql FROM sqlite_master WHERE type='trigger' " + "AND tbl_name='ep_project_registrations'" + ) if sql is not None + ] + for name, _sql in triggers: + connection.execute(f'DROP TRIGGER "{name}"') + connection.execute( + "UPDATE ep_project_registrations SET attachment_contract='{\"changed\":true}' " + "WHERE project_id='project-a'" + ) + for _name, sql in triggers: + connection.execute(sql) + with self.assertRaisesRegex(reset.OperationalResetError, "REVALIDATION_CHANGED"): + reset.apply(self.root, operation_id=operation_id, plan_digest=digest) + def test_existing_installation_lock_blocks_prepare(self) -> None: plan = reset.preview(self.root) lock = OperationalInstallationLock(self.root) @@ -1355,6 +1451,9 @@ def invoke(*arguments: str) -> tuple[int, dict[str, object]]: ) self.assertEqual((code, prepared["state"]), (0, "AUTHORIZED")) self.assertTrue(prepared["backup"]["verified"]) + code, revalidated = invoke("revalidate", *common, *mutation) + self.assertEqual((code, revalidated["state"], revalidated["allowed"]), (0, "AUTHORIZED", True)) + self.assertEqual(revalidated["details"]["revalidation"]["writer_fence_owner"], operation_id) code, status = invoke("status", *common, "--operation-id", operation_id) self.assertEqual((code, status["state"]), (0, "AUTHORIZED")) self.assertEqual(invoke("apply", *common, *mutation)[1]["state"], "DB_APPLIED") diff --git a/tests/engineering/test_operational_reset_coordinator.py b/tests/engineering/test_operational_reset_coordinator.py index 106330f0..2d53012a 100644 --- a/tests/engineering/test_operational_reset_coordinator.py +++ b/tests/engineering/test_operational_reset_coordinator.py @@ -109,6 +109,8 @@ def test_both_owning_resets_succeed_and_receipt_is_private_and_complete(self) -> self.assertRegex(observed[field], r"^sha256:[0-9a-f]{64}$") self.assertEqual(0o700, stat.S_IMODE(self.store.directory.stat().st_mode)) self.assertEqual(0o600, stat.S_IMODE(self.store.receipt_path.stat().st_mode)) + forge_actions = [item["action"] for item in self.command_log(self.forge_root)] + self.assertEqual(forge_actions[-2:], ["finish", "status"]) def test_forge_succeeds_ep_fails_and_neither_product_is_finished(self) -> None: self.prepare_both() @@ -258,7 +260,7 @@ def test_delayed_old_callbacks_are_projected_historical_without_new_work(self) - def test_no_hidden_mission_provider_or_submission_command_is_invoked(self) -> None: self.complete_both(first="engineering-platform") - allowed = {"preview", "prepare", "apply", "verify", "finish", "status"} + allowed = {"preview", "prepare", "revalidate", "apply", "verify", "finish", "status"} for root in (self.forge_root, self.ep_root): log = self.command_log(root) self.assertTrue({item["action"] for item in log} <= allowed) @@ -315,7 +317,8 @@ def test_sequential_finish_failure_preserves_partial_state_for_explicit_reconcil (self.ep_root / "fail-finish").unlink() self.assertEqual("COMPLETE", self.coordinator.finish("engineering-platform")["state"]) forge_actions = [item["action"] for item in self.command_log(self.forge_root)] - self.assertGreaterEqual(forge_actions.count("verify"), 4) + self.assertEqual(forge_actions.count("verify"), 2) + self.assertGreaterEqual(forge_actions.count("status"), 3) def test_receipt_symlink_and_concurrent_lock_fail_closed(self) -> None: unsafe_root = self.root / "unsafe-receipts" diff --git a/tools/qualification/operational_reset_coordinator.py b/tools/qualification/operational_reset_coordinator.py index 02b9f9d7..da7c27f6 100644 --- a/tools/qualification/operational_reset_coordinator.py +++ b/tools/qualification/operational_reset_coordinator.py @@ -25,7 +25,7 @@ from typing import Callable, Iterator, Mapping, Sequence -CONTRACT_VERSION = "cross-product-operational-reset-coordinator-v1" +CONTRACT_VERSION = "cross-product-operational-reset-coordinator-v2" PRODUCT_CONTRACT = "operational-reset-v1" PRODUCTS = ("forge", "engineering-platform") STATES = frozenset({ @@ -499,7 +499,7 @@ def _command( command.extend((flag, finding)) if product == "engineering-platform": command.extend(("--backup-root", str(config.ep_backup_root))) - elif product == "forge" and action in {"apply", "verify", "resume"}: + elif product == "forge" and action in {"revalidate", "apply", "verify", "resume"}: request_digest = current.get("request_digest") if not isinstance(request_digest, str): raise CoordinatorError("Forge request digest is unavailable") @@ -729,15 +729,17 @@ def revalidate(self) -> dict[str, object]: config = self._config(receipt) try: for product in PRODUCTS: - envelope = self._invoke(config, receipt, product, "preview") + envelope = self._invoke(config, receipt, product, "revalidate") current = receipt["products"][product] if ( - _digest(envelope["target"]) != current["target_digest"] + envelope.get("allowed") is not True + or envelope.get("blockers") != [] + or _digest(envelope["target"]) != current["target_digest"] or envelope["plan_digest"] != current["plan_digest"] or envelope.get("relevant_revision_digest") != current["relevant_revision_digest"] or envelope.get("preserved_bindings_digest") != current["preserved_bindings_digest"] ): - raise ProductCommandError(product, "preview", "REVALIDATION_CHANGED") + raise ProductCommandError(product, "revalidate", "REVALIDATION_CHANGED") _event( receipt, str(receipt["state"]), f"{product} plan revalidated", product=product, evidence=envelope, @@ -844,7 +846,14 @@ def finish(self, product: str) -> dict[str, object]: # sequential finish. This is the two-phase readiness boundary; # a failed peer check cannot be followed by the requested finish. for candidate in PRODUCTS: - readiness = self._invoke(config, receipt, candidate, "verify") + candidate_stage = self._product_stage( + candidate, receipt["products"][candidate]["state"], + ) + # A terminal owning operation cannot be verified again. + # Its read-only status is the authoritative readiness + # proof while the still-active peer is re-verified. + readiness_action = "status" if candidate_stage == "finished" else "verify" + readiness = self._invoke(config, receipt, candidate, readiness_action) if self._product_stage(candidate, readiness["state"]) not in { "verified", "finished", }: From 75ef07b5eb5d193b490b246881d27c15f10c90ca Mon Sep 17 00:00:00 2001 From: pcvantol Date: Fri, 18 Sep 2026 08:59:30 +0200 Subject: [PATCH 2/2] Restore canonical reset policy wording --- docs/engineering/EP_CENTRAL_OPERATIONAL_RESET_V1.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/engineering/EP_CENTRAL_OPERATIONAL_RESET_V1.md b/docs/engineering/EP_CENTRAL_OPERATIONAL_RESET_V1.md index 918051a8..878a345d 100644 --- a/docs/engineering/EP_CENTRAL_OPERATIONAL_RESET_V1.md +++ b/docs/engineering/EP_CENTRAL_OPERATIONAL_RESET_V1.md @@ -537,5 +537,5 @@ coordinator-v2 consumer required by this remediation. Candidate qualification uses non-editable wheels, separate CLI processes, real SQLite backups and owning mutations on synthetic roots. Protected review, hosted full gates, release publication, artifact-byte qualification and installed repetition -remain separate evidence until completed. Installation must not automatically -prepare/apply a reset or delete historical rows. +remain separate evidence until completed. An installation must not automatically prepare/apply +a reset or delete historical rows.