diff --git a/docs/hd-generation-workflow.md b/docs/hd-generation-workflow.md index 1bbddcc8..c7fb2bd3 100644 --- a/docs/hd-generation-workflow.md +++ b/docs/hd-generation-workflow.md @@ -69,6 +69,43 @@ After interruption, recover the original yielded cell or saved result before any Exact sanitized service errors and UTC report times stay in ignored `data/hd/jobs/.imagegen-gate/events.jsonl`. Connection failures impose a 60–600-second cooldown and at most three failures per job in a thirty-minute retry batch. During denial or cooldown, prepare or review another job. Do not replace a failed remaster with scaled original art. +## Scripted cleanup of generated artwork + +Precise scripted cleanup is authorized alongside image generation. Keep the +generator-return PNG, original prompt and generation receipt unchanged. Save +cleaned pixels separately and label them **derived artwork**. Corrections may +restore Source alignment, transparency, animation backing and material boundaries; +they must retain meaningful generated detail. Enlarging Original artwork alone +does not count as an HD remaster. Review the actual cleaned PNG at scales 1–4 +and in the relevant animation or terrain context before registration. + +New cleaned candidates declare `artwork_derivation: {"path": "...", "sha256": "..."}`. +The coordinator review must bind that identical reference. The referenced JSON +uses `format_version: 1`, `kind: "deterministic_cleanup"`, a named `method`, and: + +- `parents`: each contains `raw_png`, `imagegen_receipt` and `prompt` references. +- `tools`: references to checked-in Python files under the project `tools/`. +- `recipe` and `execution_receipt`: references to the actual cleanup inputs and report. +- `sources`: the exact selected asset ID → original payload SHA-256 map. +- `outputs`: the exact selected runtime PNG path → SHA-256 map. + +Every reference contains only `path` and `sha256`. Except tools, paths are relative +to the HD root; tools are relative to the project. Absolute paths, traversal and +symlink escapes are rejected. Original receipts are copied byte-for-byte; paths +inside their historical text are not rewritten or executed. The execution report +contains an `execution` object with `status: "success"`, the same `method`, +`recipe_sha256`, `tool_sha256`, `source_sha256` map, sorted unique +`parent_raw_sha256` list, and `output_sha256` map keyed by asset ID. Registration +cross-checks these against the files, selected frame bindings and actual runtime +QA artwork hashes. It never executes recipe contents. + +Independent standalone material records for derived artwork use +`derived_artwork_sha256` and `artwork_derivation_sha256`, with no +`full_untouched_raw_sha256` claim. Source, native canvas/origin, complete families, +current library, all-scale palette and independent context requirements still +apply. Jobs without the optional derivation stay on their existing untouched +review path. Cleanup runs locally and consumes no image-generation slot. + ## Registering reviewed standalone terrain `hd_register_reviewed.py` accepts an explicit `complete_standalone_selection` diff --git a/tools/hd_art_derivation.py b/tools/hd_art_derivation.py new file mode 100644 index 00000000..0b081d80 --- /dev/null +++ b/tools/hd_art_derivation.py @@ -0,0 +1,91 @@ +"""Read-only lineage checks for explicitly cleaned imagegen artwork. + +This records how reviewed pixels were made; it does not grant artistic approval +or execute a recipe. Jobs without an artwork_derivation remain unchanged. +""" +import hashlib +import json +from pathlib import Path +import re + + +def checked_file(root, reference): + if (not isinstance(reference, dict) or set(reference) != {"path", "sha256"} + or not isinstance(reference["path"], str) + or not re.fullmatch(r"[0-9a-f]{64}", str(reference["sha256"]))): + raise ValueError("derivation needs an exact path/SHA-256 reference") + relative = Path(reference["path"]) + if relative.is_absolute() or ".." in relative.parts or not relative.parts: + raise ValueError("derivation reference must be a portable relative path") + root = root.resolve() + path = (root / relative).resolve() + if not path.is_relative_to(root) or not path.is_file(): + raise ValueError("derivation reference escapes its root or is not a file") + payload = path.read_bytes() + if hashlib.sha256(payload).hexdigest() != reference["sha256"]: + raise ValueError(f"derivation file changed: {relative}") + return payload + + +def checked_json(root, reference): + value = json.loads(checked_file(root, reference)) + if not isinstance(value, dict): + raise ValueError("derivation JSON must contain an object") + return value + + +def validate_derivation(root, project, candidate, review, rows, qa): + reference = candidate.get("artwork_derivation") + if reference is None: + if "artwork_derivation" in candidate or "artwork_derivation" in review: + raise ValueError("derivation cannot be null or present only in review") + return None + if review.get("artwork_derivation") != reference: + raise ValueError("artistic review does not bind this derivation") + lineage = checked_json(root, reference) + if (type(lineage.get("format_version")) is not int or lineage["format_version"] != 1 + or lineage.get("kind") != "deterministic_cleanup" + or not isinstance(lineage.get("method"), str) or not lineage["method"].strip()): + raise ValueError("unsupported or unnamed artwork derivation") + parents = lineage.get("parents") + if not isinstance(parents, list) or not parents: + raise ValueError("derivation needs original imagegen parents") + for parent in parents: + if not isinstance(parent, dict) or set(parent) != {"raw_png", "imagegen_receipt", "prompt"}: + raise ValueError("derivation parent needs raw PNG, imagegen receipt and prompt") + if not checked_file(root, parent["raw_png"]).startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("derivation parent is not a PNG") + # Preserve the actual receipt verbatim. Its historical format is not + # rewritten to pretend that cleanup was another generator invocation. + checked_json(root, parent["imagegen_receipt"]) + if not checked_file(root, parent["prompt"]).strip(): + raise ValueError("derivation parent prompt is empty") + tools = lineage.get("tools") + if not isinstance(tools, list) or not tools: + raise ValueError("derivation needs a checked-in cleanup tool") + for tool in tools: + checked_file(project, tool) + if Path(tool["path"]).parts[0] != "tools" or Path(tool["path"]).suffix != ".py": + raise ValueError("derivation tool must be a project tools Python file") + checked_json(root, lineage.get("recipe")) + execution = checked_json(root, lineage.get("execution_receipt")).get("execution", {}) + if not isinstance(execution, dict): + raise ValueError("derivation execution must contain an object") + if lineage.get("sources") != {asset: frame["source_sha256"] for asset, frame in rows.items()}: + raise ValueError("derivation Source inventory differs from selected artwork") + files = {frame["file"] for frame in rows.values()} + outputs = lineage.get("outputs") + if not isinstance(outputs, dict) or set(outputs) != files: + raise ValueError("derivation output inventory differs from selected artwork") + for path, sha in outputs.items(): + payload = checked_file(root, {"path": path, "sha256": sha}) + if not payload.startswith(b"\x89PNG\r\n\x1a\n") or qa.get("artwork_sha256", {}).get(path) != sha: + raise ValueError("derivation output differs from runtime-reviewed PNG") + if (execution.get("status") != "success" or execution.get("method") != lineage["method"] + or execution.get("recipe_sha256") != lineage["recipe"]["sha256"] + or execution.get("tool_sha256") not in {tool["sha256"] for tool in tools} + or execution.get("source_sha256") != lineage["sources"] + or execution.get("parent_raw_sha256") != sorted({p["raw_png"]["sha256"] for p in parents}) + or execution.get("output_sha256") != {asset: outputs[frame["file"]] for asset, frame in rows.items()}): + raise ValueError("derivation execution does not bind actual recipe, tool, inputs and outputs") + return reference diff --git a/tools/hd_job_queue.py b/tools/hd_job_queue.py index 625c0963..56faf07a 100644 --- a/tools/hd_job_queue.py +++ b/tools/hd_job_queue.py @@ -268,7 +268,7 @@ def contract(family): "submission_gate": "Prepare and inspect sources and save the prompt before acquiring. In one functions.exec orchestration, acquire one of four slots, start the reservation, submit built-in imagegen only when start.changed is true, then classify and report the actual result immediately after await before returning to the model or doing QA. See RustRaptor/docs/hd-generation-workflow.md. Started and legacy-unknown requests require explicit finish; only unstarted reservations expire. If denied or cooling down, prepare/review other jobs. Do not substitute scaling for a failed remaster.", "source_truth": "Keep every source orientation, silhouette, color identity, logical canvas, anchor and explicit source hash. Use each target's source_palette and source_palette_sha256; Apogee/Cygnus/Taiwan cards use their own palettes, not the gameplay palette. Inspect every frame before generating. Never invent poses or drop damaged/destroyed variants.", "ownership": "Only this job's target IDs may be proposed. Neighbor/context references and exact-source aliases are reference-only; never claim or overwrite another job's IDs.", - "raw_output": "Keep raw generated PNG bytes, prompts, source references and provenance. Do not retouch generated RGB/alpha. Atlas/square margin crops are allowed only if they preserve all meaningful visible pixels and original aspect.", + "raw_output": "Preserve original generated PNG bytes, prompts, source references and provenance unchanged. Separately saved deterministic pixel cleanup is allowed with an artwork_derivation lineage binding the untouched imagegen parent, checked-in tool, recipe, actual execution receipt and derived outputs. Never label cleaned pixels untouched generation. Atlas/square margin crops must preserve meaningful visible pixels and original aspect unless an explicit reviewed derivation documents the correction. See docs/hd-generation-workflow.md.", "full_family": "Review all frames together at logical resolution and indexed 4× with exact Rust Lanczos3 resident→presentation resampling. A good still does not complete an animation family. Record partial candidates and rejections truthfully.", "quality": "Check clipping, alpha halos, banking/animation centers, source bounds, pivots, tiny-projectile readability, palette fades/cycling, material continuity and intended timing. Near-black original body/backing pixels are occupied artwork, not empty background. For opaque pictures, mask overlap of 1 proves only coverage; compare source material, landmarks and internal geometry separately. Original ordinary pixels must not gain indices240..255; water/fire/individualblink source classes retain their semantics. Compare original and allscale dynamic-palette counts in runtime-qa.json. Numeric mask metrics support visual review, never replace it.", "terrain": "Generate each canonical tile from its own exact source; supplied 3×3 neighbors are separate continuity-review references, not scenery to copy into the target. Check all cardinal adjacency sets, repeated tile aliases and intact/destroyed state partners. Preserve coastline/topography crossings, native edge colors and exact reusable tile identity. Equal-grid atlas crops must preserve each tile's complete logical composition; reject displaced panel boundaries or foreign row strips. Review every required adjacent pair, including state changes; full opaque 32×32 terrain canvases are valid. Do not publish context-specific alternate art for one repeated ID. The validated renderer shares adjoining HD edge/corner colors only where original surface class/geometry agrees, and transitions HD/original boundaries towards the joined original layer over two logical pixels; see RustRaptor/docs/hd-terrain.md. Feathering cannot repair shifted coastlines or make a low-resolution grid acceptable; detailed interiors must remain newly authored HD art. Preserve source water indices 240..244, fire 245..249 and single-index glow/flash 250..255; ordinary terrain maps to 0..239." if terrain else None, @@ -352,7 +352,9 @@ def build(args): # A corrected proposal needs fresh review; an old approval or hold must # never silently apply to newly submitted artwork. coordinator_review = None - if coordinator_review and coordinator_review.get("status") == "approved" and proposal.get("complete_standalone_selection"): + if coordinator_review and coordinator_review.get("status") == "approved" and ( + proposal.get("complete_standalone_selection") or "artwork_derivation" in proposal + or "artwork_derivation" in coordinator_review): # Explicit Terrain selections must satisfy the same source, peer, # partition and runtime guard as registration. A fallback ID never # inherits the approval of the selected standalone definitions. @@ -361,7 +363,7 @@ def build(args): approved_job((args.data / "hd").resolve(), family["id"], Path(__file__).resolve().parents[1]) except (ValueError, KeyError, TypeError, OSError) as error: coordinator_review = None - candidate_error = f"standalone selection approval rejected: {error}" + candidate_error = f"standalone selection or derived artwork approval rejected: {error}" target_rows = [] for asset in family["assets"]: source = pictures[asset] diff --git a/tools/hd_register_reviewed.py b/tools/hd_register_reviewed.py index 1b655d29..35942ed4 100644 --- a/tools/hd_register_reviewed.py +++ b/tools/hd_register_reviewed.py @@ -17,6 +17,7 @@ from pathlib import Path from hd_candidate_review import binding_digest, proposal_frames from hd_art_audit import checked_palette, validate_hd_frame from hd_art_source import decode +from hd_art_derivation import validate_derivation from hd_frame_policy import validate_frame_policy, retained_review_version_ok, original_palette_indices @@ -374,10 +375,15 @@ def approved_material_evidence(root, selection, review, rows, qa): if type(index) is not int or not 0 <= index < len(records): raise ValueError(f"unsafe independent decision index: {asset}") record = records[index] + derivation = review.get("artwork_derivation") + artwork_matches = (record.get("derived_artwork_sha256") == raw_sha and + record.get("artwork_derivation_sha256") == derivation["sha256"] and + "full_untouched_raw_sha256" not in record) if derivation else ( + record.get("full_untouched_raw_sha256") == raw_sha) if (record.get("asset") != asset or record.get("standalone_verdict") not in ["APPROVE", "QUALIFIED_APPROVE"] or record.get("selected_frame_binding") != frame or record.get("source_payload_sha256") != frame["source_sha256"] or - record.get("full_untouched_raw_sha256") != raw_sha or record.get("actual_scales") != [1, 2, 3, 4]): + not artwork_matches or record.get("actual_scales") != [1, 2, 3, 4]): raise ValueError(f"independent per-ID decision does not approve exact artwork: {asset}") @@ -754,6 +760,7 @@ def approved_job(root, identity, project): artwork = contained(root, frame["file"]) if hashlib.sha256(artwork.read_bytes()).hexdigest() != qa["artwork_sha256"][frame["file"]]: raise ValueError(f"raw artwork changed since review: {asset}") + validate_derivation(root, project, candidate, review, rows, qa) if selection is not None: pictures, payloads = authentic_terrain_inventory(root, job) validate_carried_registered_identity(root, candidate, job, rows, review) @@ -826,6 +833,8 @@ def main(): "independent_review": review["independent_review"], "frame_bindings_sha256": review["frame_bindings_sha256"], }) + if candidate.get("artwork_derivation"): + additions[-1]["artwork_derivation"] = candidate["artwork_derivation"] if candidate.get("complete_standalone_selection"): additions[-1]["approval_scope"] = "complete_standalone_terrain_selection" additions[-1]["original_fallback_assets"] = candidate["original_fallback_assets"] diff --git a/tools/test_hd_art_derivation.py b/tools/test_hd_art_derivation.py new file mode 100644 index 00000000..2267f581 --- /dev/null +++ b/tools/test_hd_art_derivation.py @@ -0,0 +1,125 @@ +"""Synthetic lineage checks; these fixtures do not claim generated art approval.""" +import copy +import hashlib +import json +from pathlib import Path +import tempfile +import unittest + +from hd_art_derivation import validate_derivation + + +class DerivationTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.project = Path(self.temp.name) + self.root = self.project / "data/hd" + self.root.mkdir(parents=True) + self.method = "synthetic-fixture-not-art-acceptance" + raw = self.put("parent.png", b"\x89PNG\r\n\x1a\nfixture-parent") + tool = self.put("tools/fixture.py", b"# synthetic recipe tool\n", self.project) + recipe = self.put("recipe.json", b'{"scope":"synthetic"}') + output = self.put("derived.png", b"\x89PNG\r\n\x1a\nfixture-output") + self.rows = {"12345": {"file": output["path"], "source_sha256": "a" * 64}} + self.execution = {"execution": {"status": "success", "method": self.method, + "recipe_sha256": recipe["sha256"], "tool_sha256": tool["sha256"], + "source_sha256": {"12345": "a" * 64}, "parent_raw_sha256": [raw["sha256"]], + "output_sha256": {"12345": output["sha256"]}}} + self.lineage = {"format_version": 1, "kind": "deterministic_cleanup", "method": self.method, + "parents": [{"raw_png": raw, "prompt": self.put("prompt.txt", b"Synthetic prompt fixture"), + "imagegen_receipt": self.put("parent-receipt.json", b'{"scope":"synthetic"}')}], + "tools": [tool], "recipe": recipe, + "execution_receipt": self.put_json("execution.json", self.execution), + "sources": {"12345": "a" * 64}, "outputs": {output["path"]: output["sha256"]}} + self.candidate, self.review = {}, {} + self.qa = {"artwork_sha256": self.lineage["outputs"].copy()} + self.bind() + + def put(self, path, payload, root=None): + destination = (root or self.root) / path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(payload) + return {"path": path, "sha256": hashlib.sha256(payload).hexdigest()} + + def put_json(self, path, value): + return self.put(path, (json.dumps(value, sort_keys=True) + "\n").encode()) + + def bind(self): + ref = self.put_json("lineage.json", self.lineage) + self.candidate["artwork_derivation"] = ref + self.review["artwork_derivation"] = ref.copy() + + def validate(self): + return validate_derivation(self.root, self.project, self.candidate, self.review, self.rows, self.qa) + + def test_exact_lineage_is_read_only_and_legacy_is_optional(self): + before = {str(p): p.read_bytes() for p in self.project.rglob("*") if p.is_file()} + self.assertEqual(self.validate(), self.candidate["artwork_derivation"]) + self.assertEqual(before, {str(p): p.read_bytes() for p in self.project.rglob("*") if p.is_file()}) + self.assertIsNone(validate_derivation(self.root, self.project, {}, {}, {}, {})) + + def test_review_cannot_use_parent_or_another_derivation(self): + self.review["artwork_derivation"]["sha256"] = "0" * 64 + with self.assertRaises(ValueError): self.validate() + self.review.pop("artwork_derivation") + with self.assertRaises(ValueError): self.validate() + + def test_every_input_and_output_is_hash_bound(self): + refs = [self.lineage["parents"][0][k] for k in ["raw_png", "prompt", "imagegen_receipt"]] + refs += [self.lineage[k] for k in ["recipe", "execution_receipt"]] + refs += [{"path": "derived.png"}, {"path": "lineage.json"}] + for ref in refs: + path = self.root / ref["path"] + original = path.read_bytes() + with self.subTest(path=path.name): + path.write_bytes(original + b"changed") + with self.assertRaises(ValueError): self.validate() + path.write_bytes(original) + tool = self.project / self.lineage["tools"][0]["path"] + tool.write_bytes(b"changed") + with self.assertRaises(ValueError): self.validate() + + def test_rehashed_execution_cannot_change_recipe_tool_sources_parents_outputs_or_success(self): + for key in self.execution["execution"]: + altered = copy.deepcopy(self.execution) + altered["execution"][key] = "wrong" + with self.subTest(key=key): + self.lineage["execution_receipt"] = self.put_json("execution.json", altered) + self.bind() + with self.assertRaises(ValueError): self.validate() + + def test_source_output_inventory_and_stale_qa_reject(self): + original = copy.deepcopy(self.lineage) + for field in ["sources", "outputs"]: + for value in [{}, {"extra": "0" * 64}]: + self.lineage = copy.deepcopy(original) + self.lineage[field] = value + self.bind() + with self.subTest(field=field, value=value), self.assertRaises(ValueError): self.validate() + self.lineage = original + self.bind() + self.qa["artwork_sha256"]["derived.png"] = original["parents"][0]["raw_png"]["sha256"] + with self.assertRaises(ValueError): self.validate() + + def test_absolute_traversal_and_symlink_escape_reject(self): + outside = self.project / "outside.json" + outside.write_text('{"scope":"outside"}') + (self.root / "link.json").symlink_to(outside) + for path in [str(outside), "../../outside.json", "link.json"]: + self.lineage["recipe"] = {"path": path, "sha256": hashlib.sha256(outside.read_bytes()).hexdigest()} + self.bind() + with self.subTest(path=path), self.assertRaises(ValueError): self.validate() + + def test_malformed_and_non_object_receipts_reject_as_validation_errors(self): + for value in [None, [], 1, {"execution": []}]: + self.lineage["execution_receipt"] = self.put_json("execution.json", value) + self.bind() + with self.subTest(value=value), self.assertRaises(ValueError): self.validate() + self.candidate["artwork_derivation"] = self.put_json("lineage.json", []) + self.review["artwork_derivation"] = self.candidate["artwork_derivation"] + with self.assertRaises(ValueError): self.validate() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_hd_job_queue.py b/tools/test_hd_job_queue.py index 265c4e7d..2db614ae 100644 --- a/tools/test_hd_job_queue.py +++ b/tools/test_hd_job_queue.py @@ -202,6 +202,22 @@ class FamilyReviewLedgerTest(unittest.TestCase): self.assertEqual(ledger["family_reviews"][self.job_id]["candidate_sha256"], self.review["candidate_sha256"]) self.assertEqual(ledger["family_reviews"][self.job_id]["frame_bindings_sha256"], self.review["frame_bindings_sha256"]) + def test_derivation_removed_from_candidate_or_review_cannot_bypass_registration_guard(self): + for location in ["candidate", "review", "both"]: + candidate, review = copy.deepcopy(self.candidate), copy.deepcopy(self.review) + if location in ["candidate", "both"]: + candidate["artwork_derivation"] = {"path": "lineage.json", "sha256": "d" * 64} + if location in ["review", "both"]: + review["artwork_derivation"] = {"path": "lineage.json", "sha256": "d" * 64} + self.write(self.candidate_path, candidate) + self.write(self.review_path, review) + with self.subTest(location=location), patch("hd_register_reviewed.approved_job", side_effect=ValueError("bad lineage")) as guard: + ledger = self.build() + guard.assert_called_once() + self.assertEqual(ledger["assets"]["11000"]["status"], "candidate") + self.assertEqual(ledger["assets"]["11002"]["status"], "renderreachable") + self.assertEqual(ledger["family_reviews"], {}) + def test_missing_changed_receipt_and_conflicting_identities_fail_safely(self): legacy = self.build(legacy=True) normalized = queue.normalize_ledger(legacy, self.hd) diff --git a/tools/test_hd_register_reviewed.py b/tools/test_hd_register_reviewed.py index 33c23e6a..f6d51cfc 100644 --- a/tools/test_hd_register_reviewed.py +++ b/tools/test_hd_register_reviewed.py @@ -162,6 +162,71 @@ class StandaloneTerrainRegistrationTest(unittest.TestCase): def approve(self, number=0): return registrar.approved_job(self.hd, self.jobs[number]["id"], self.project) + def install_synthetic_derivation(self): + row = self.jobs[0] + directory = row["directory"] + frames = proposal_frames(row["candidate"]) + parent = directory / "synthetic-parent.png" + Image.new("RGBA", (8, 8), (50, 50, 50, 255)).save(parent) + self.write(directory / "synthetic-generation-receipt.json", {"scope": "synthetic contract only"}) + (directory / "synthetic-prompt.txt").write_text("Synthetic prompt, no artwork acceptance") + self.write(directory / "synthetic-recipe.json", {"scope": "synthetic contract only"}) + tool = self.project / "tools/synthetic-cleanup.py" + tool.parent.mkdir(exist_ok=True) + tool.write_text("# synthetic lineage fixture only\n") + tool_ref = {"path": "tools/synthetic-cleanup.py", "sha256": self.sha(tool.read_bytes())} + sources = {asset: frame["source_sha256"] for asset, frame in frames.items()} + outputs = {frame["file"]: row["qa"]["artwork_sha256"][frame["file"]] for frame in frames.values()} + execution = {"status": "success", "method": "synthetic-cleanup", "tool_sha256": tool_ref["sha256"], + "recipe_sha256": self.ref(directory / "synthetic-recipe.json")["sha256"], + "source_sha256": sources, "parent_raw_sha256": [self.sha(parent.read_bytes())], + "output_sha256": {asset: outputs[frame["file"]] for asset, frame in frames.items()}} + self.write(directory / "synthetic-execution.json", {"execution": execution}) + lineage = {"format_version": 1, "kind": "deterministic_cleanup", "method": "synthetic-cleanup", + "parents": [{"raw_png": self.ref(parent), + "imagegen_receipt": self.ref(directory / "synthetic-generation-receipt.json"), + "prompt": self.ref(directory / "synthetic-prompt.txt")}], + "tools": [tool_ref], "recipe": self.ref(directory / "synthetic-recipe.json"), + "execution_receipt": self.ref(directory / "synthetic-execution.json"), + "sources": sources, "outputs": outputs} + self.write(directory / "derivation.json", lineage) + reference = self.ref(directory / "derivation.json") + row["candidate"]["artwork_derivation"] = reference + row["review"]["artwork_derivation"] = reference + self.write(directory / "candidate.json", row["candidate"]) + candidate_sha = self.sha((directory / "candidate.json").read_bytes()) + row["review"]["candidate_sha256"] = row["qa"]["candidate_sha256"] = candidate_sha + peer_path = directory / "peer.json" + peer = json.loads(peer_path.read_text()) + for record in peer["records"]: + if record["asset"] in frames: + record["derived_artwork_sha256"] = record.pop("full_untouched_raw_sha256") + record["artwork_derivation_sha256"] = reference["sha256"] + self.write(peer_path, peer) + for approval in row["review"]["standalone_selection"]["material_approvals"].values(): + approval["independent_review"] = self.ref(peer_path) + self.write(directory / "coordinator-review.json", row["review"]) + self.write(directory / "runtime-qa.json", row["qa"]) + + def test_derived_selection_passes_same_source_material_context_and_runtime_gates(self): + self.install_synthetic_derivation() + candidate, review, frames = self.approve() + self.assertEqual(candidate["artwork_derivation"], review["artwork_derivation"]) + self.assertEqual(set(frames), set(self.SELECTED[0])) + + def test_derived_art_cannot_be_relabelled_untouched_in_independent_review(self): + self.install_synthetic_derivation() + peer_path = self.jobs[0]["directory"] / "peer.json" + peer = json.loads(peer_path.read_text()) + peer["records"][0]["full_untouched_raw_sha256"] = peer["records"][0]["derived_artwork_sha256"] + self.write(peer_path, peer) + review = self.jobs[0]["review"] + for approval in review["standalone_selection"]["material_approvals"].values(): + approval["independent_review"] = self.ref(peer_path) + self.write(self.jobs[0]["directory"] / "coordinator-review.json", review) + with self.assertRaisesRegex(ValueError, "exact artwork"): + self.approve() + def save(self, field, value, number=0): row = self.jobs[number] self.write(row["directory"] / {"candidate": "candidate.json", "job": "job.json", "review": "coordinator-review.json", "qa": "runtime-qa.json"}[field], value)