From 420df60c15fcf91bb43a2f18d32e953c61a97ea1 Mon Sep 17 00:00:00 2001 From: ExoHayvan <18trevor3695@gmail.com> Date: Wed, 22 Jul 2026 16:27:02 -0400 Subject: [PATCH 1/2] chore: complete phase-1 step-179 --- src/aethermesh_core/api.py | 12 +++ src/aethermesh_core/runtime_service.py | 126 +++++++++++++++++++++++++ tests/test_runtime_service_api_cli.py | 82 ++++++++++++++++ 3 files changed, 220 insertions(+) diff --git a/src/aethermesh_core/api.py b/src/aethermesh_core/api.py index a6f9f37..ca94cdc 100644 --- a/src/aethermesh_core/api.py +++ b/src/aethermesh_core/api.py @@ -64,6 +64,12 @@ def _runtime_error_code( "CONTRIBUTION_ATTRIBUTION_FAILURE", "Contribution attribution could not be read.", ) + if "trace" in diagnostic: + return ( + 400, + "TRACE_LINEAGE_FAILURE", + "Local attribution trace evidence is incomplete.", + ) if "lineage" in diagnostic: return 400, "LINEAGE_LOOKUP_FAILURE", "Lineage evidence could not be read." if "manifest" in diagnostic: @@ -250,6 +256,12 @@ def job_result(job_id: str) -> dict[str, Any]: return runtime_service.get_local_job_result(job_id) + @app.get("/api/jobs/{job_id}/trace") + def job_attribution_trace(job_id: str) -> dict[str, Any]: + """Read one validation-gated local attribution chain without mutation.""" + + return runtime_service.trace_local_job_attribution(job_id) + @app.get("/api/result-reports") def result_reports() -> dict[str, Any]: """List deterministic, metadata-only summaries of local result reports.""" diff --git a/src/aethermesh_core/runtime_service.py b/src/aethermesh_core/runtime_service.py index 5d8534e..54b5f3f 100644 --- a/src/aethermesh_core/runtime_service.py +++ b/src/aethermesh_core/runtime_service.py @@ -1987,6 +1987,132 @@ def list_local_validation_receipts(self) -> dict[str, Any]: ], } + def trace_local_job_attribution(self, job_id: str) -> dict[str, Any]: + """Return one validation-gated local job attribution chain. + + This is an inspection-only join over persisted evidence. Every link is + checked directly; attribution is never inferred from loose metadata. + """ + if not self._is_local_job_id(job_id): + raise RuntimeServiceError("job_id must be a local job ID") + manifest_ref = f"data/job-submissions/{job_id}.json" + manifest_path = self.paths.home / manifest_ref + if not manifest_path.exists(): + raise RuntimeServiceError("trace missing required local job manifest") + manifest = self._load_local_job_document( + manifest_path, "job submission manifest" + ) + manifest_job = manifest.get("job") + creator_node_id = manifest.get("creator_node_id") + if ( + not isinstance(manifest_job, dict) + or manifest_job.get("job_id") != job_id + or not isinstance(creator_node_id, str) + or not creator_node_id + ): + raise RuntimeServiceError("trace has invalid local job manifest linkage") + + result = self.get_local_job_result(job_id) + try: + receipt = self.get_local_validation_receipt(work_id=job_id) + except ValidationReceiptNotFoundError: + raise + except RuntimeServiceError as exc: + raise RuntimeServiceError( + "trace validation receipt is missing or has invalid lineage evidence" + ) from exc + status_path = self._job_status_path(job_id) + if not status_path.exists(): + raise RuntimeServiceError("trace missing required local job status") + status = self._load_local_job_document(status_path, "job status record") + contribution = status.get("contribution_attribution") + expected_receipt_id = self._receipt_id_for_job(job_id) + expected_result_id = f"local-result-{job_id}" + manifest_id = canonical_json_hash(manifest, prefix="sha256:") + contribution_id = f"local-contribution-{job_id}" + + if receipt["status"] != "accepted" or receipt["validation_status"] != "passed": + raise RuntimeServiceError( + "trace requires an accepted passed validation receipt" + ) + if ( + result.get("result_id") != expected_result_id + or result.get("job_id") != job_id + or result.get("validation_receipt_id") != expected_receipt_id + or result.get("references", {}).get("validation_receipt_ids") + != [expected_receipt_id] + or result.get("manifest_id") != manifest_id + or result.get("references", {}).get("manifest_hash") != manifest_id + or result.get("creator_node_id") != creator_node_id + ): + raise RuntimeServiceError( + "trace result does not match job manifest lineage" + ) + if ( + receipt["receipt_id"] != expected_receipt_id + or receipt["job_id"] != job_id + or receipt["manifest_ref"] != manifest_ref + or receipt["result_hash"] != result.get("result_hash") + or receipt["creator_node_id"] != creator_node_id + ): + raise RuntimeServiceError( + "trace validation receipt does not match result lineage" + ) + if ( + not isinstance(contribution, dict) + or contribution.get("job_id") != job_id + or contribution.get("creator_node_id") != creator_node_id + or contribution != receipt["contribution_attribution"] + ): + raise RuntimeServiceError( + "trace contribution does not match validation receipt" + ) + + return { + "schema_version": 1, + "trace_scope": "local-only-validation-gated-attribution", + "job_id": job_id, + "chain": [ + { + "record_type": "job", + "job_id": job_id, + "result_id": expected_result_id, + "manifest_ref": manifest_ref, + }, + { + "record_type": "result", + "result_id": expected_result_id, + "job_id": result["job_id"], + "validation_receipt_id": result["validation_receipt_id"], + "manifest_id": result["manifest_id"], + }, + { + "record_type": "validation_receipt", + "validation_receipt_id": receipt["receipt_id"], + "job_id": receipt["job_id"], + "result_id": expected_result_id, + "manifest_ref": receipt["manifest_ref"], + "contribution_id": contribution_id, + }, + { + "record_type": "contribution", + "contribution_id": contribution_id, + "job_id": contribution["job_id"], + "validation_receipt_id": expected_receipt_id, + "manifest_ref": manifest_ref, + "creator_node_id": contribution["creator_node_id"], + }, + { + "record_type": "manifest", + "manifest_id": manifest_id, + "manifest_ref": manifest_ref, + "job_id": manifest_job["job_id"], + "creator_node_id": creator_node_id, + }, + {"record_type": "creator_node", "creator_node_id": creator_node_id}, + ], + } + @staticmethod def _receipt_id_for_job(job_id: str) -> str: return f"{VALIDATION_RECEIPT_ID_PREFIX}{job_id}" diff --git a/tests/test_runtime_service_api_cli.py b/tests/test_runtime_service_api_cli.py index c75a69c..b960195 100644 --- a/tests/test_runtime_service_api_cli.py +++ b/tests/test_runtime_service_api_cli.py @@ -2535,6 +2535,88 @@ def test_local_job_result_read_path_returns_stored_report_and_preserves_identity ) self.assertEqual(report_path.read_bytes(), report_before_read) + def test_local_job_attribution_trace_is_validation_gated_and_fails_closed( + self, + ) -> None: + request, _expected_output = _valid_local_work_fixture() + request["creator_node_id"] = "creator-byte-preserved" + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + service = NodeRuntimeService.from_home(root) + submission = service.submit_local_job(request) + service.execute_submitted_local_job( + submission["job_id"], "worker-local-fixture" + ) + evidence_before = { + path: path.read_bytes() for path in (root / "data").rglob("*.json") + } + + trace = service.trace_local_job_attribution(submission["job_id"]) + + self.assertEqual( + trace["trace_scope"], "local-only-validation-gated-attribution" + ) + self.assertEqual( + [item["record_type"] for item in trace["chain"]], + [ + "job", + "result", + "validation_receipt", + "contribution", + "manifest", + "creator_node", + ], + ) + job, result, receipt, contribution, manifest, creator = trace["chain"] + self.assertEqual(job["result_id"], result["result_id"]) + self.assertEqual( + result["validation_receipt_id"], receipt["validation_receipt_id"] + ) + self.assertEqual( + receipt["contribution_id"], contribution["contribution_id"] + ) + self.assertEqual(contribution["manifest_ref"], manifest["manifest_ref"]) + self.assertEqual(creator["creator_node_id"], request["creator_node_id"]) + self.assertEqual( + evidence_before, + {path: path.read_bytes() for path in (root / "data").rglob("*.json")}, + ) + + api = create_app(service) + + async def fetch_trace() -> httpx.Response: + transport = httpx.ASGITransport(app=api) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + return await client.get(f"/api/jobs/{submission['job_id']}/trace") + + response = asyncio.run(fetch_trace()) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), trace) + + receipt_path = ( + root + / "data" + / "job-validation-receipts" + / f"{submission['job_id']}.json" + ) + stored_receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + stored_receipt["manifest_ref"] = "data/job-submissions/other.json" + receipt_path.write_text(json.dumps(stored_receipt), encoding="utf-8") + with self.assertRaisesRegex( + RuntimeServiceError, "trace validation receipt" + ): + service.trace_local_job_attribution(submission["job_id"]) + failed_response = asyncio.run(fetch_trace()) + self.assertEqual(failed_response.status_code, 400) + self.assertEqual( + failed_response.json()["error"]["code"], "TRACE_LINEAGE_FAILURE" + ) + receipt_path.unlink() + with self.assertRaisesRegex(RuntimeServiceError, "receipt not found"): + service.trace_local_job_attribution(submission["job_id"]) + def test_execution_writes_a_provenanced_result_report_only_after_runner_finishes( self, ) -> None: From baf232374537a77a1c600a6d1a6ce70b9abd8869 Mon Sep 17 00:00:00 2001 From: ExoHayvan <18trevor3695@gmail.com> Date: Wed, 22 Jul 2026 16:57:27 -0400 Subject: [PATCH 2/2] fix: address automated review feedback --- docs/phase-1-api-schema-contract.md | 5 ++++- docs/phase-1-local-api-boundary.md | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/phase-1-api-schema-contract.md b/docs/phase-1-api-schema-contract.md index 4e3f8d4..1b61784 100644 --- a/docs/phase-1-api-schema-contract.md +++ b/docs/phase-1-api-schema-contract.md @@ -23,7 +23,7 @@ Every HTTP failure from the local API uses this stable envelope. Successful resp `request_id` is a fresh local trace identifier for correlating a caller response with local process logs. `error.details` is deliberately an empty object in version 1: API failures must not return exception text, stack traces, creator node IDs, manifests, receipts, lineage records, or contribution-attribution data. Local logs retain the original diagnostic. -The current stable codes are `INVALID_INPUT` (400 or 405), `NOT_FOUND` (404 route lookup), `MISSING_MANIFEST` (404), `VALIDATION_FAILURE` (400 or 404 when evidence is absent), `LINEAGE_LOOKUP_FAILURE` (400), `CONTRIBUTION_ATTRIBUTION_FAILURE` (400), and `INTERNAL_ERROR` (500). Callers should test `error.code` rather than response messages or log text. +The current stable codes are `INVALID_INPUT` (400 or 405), `NOT_FOUND` (404 route lookup), `MISSING_MANIFEST` (404), `VALIDATION_FAILURE` (400 or 404 when evidence is absent), `LINEAGE_LOOKUP_FAILURE` (400), `CONTRIBUTION_ATTRIBUTION_FAILURE` (400), `TRACE_LINEAGE_FAILURE` (400), and `INTERNAL_ERROR` (500). Callers should test `error.code` rather than response messages or log text. ## Route index @@ -32,6 +32,7 @@ The current stable codes are `INVALID_INPUT` (400 or 405), `NOT_FOUND` (404 rout | `GET /health`, `/status`, `/api/status`, `/version`, `/node`, `/api/node`, `/peers`, `/api/peers`, `/api/jobs`, `/capabilities`, `/api/capabilities`, `/api/model-manifests`, `/api/package`, `/api/network`, `/logs`, `/api/logs`, `/api/events`, `/`, `/shutdown`, `/restart` | None, except local control signal posts | Current local status/control shape described in the API boundary; none writes provenance. | | `POST /api/jobs` | Local Job Submission v1 | Local Job Submission Acceptance v1 | | `GET /api/jobs/{job_id}` | Required path `job_id` | Local Job Status v1 artifact projection | +| `GET /api/jobs/{job_id}/trace` | Required path `job_id` | Local Job Attribution Trace v1 | | `GET /api/validation-receipts` | None to list receipt metadata; legacy detail lookup accepts exactly one of `receipt_id`, `work_id`, or `latest=true` | Local Validation Receipt List v1 or Local Validation Receipt v5 | | `GET /api/validation-receipts/{receipt_id}` | Required stable local receipt ID | Local Validation Receipt v5 | | `GET /api/contributions` | None | Local Contribution Lookup v1 | @@ -75,6 +76,8 @@ The only local job states are: `created` (a submission manifest and initial loca Queued jobs have `null` worker/result/validation evidence. Completed jobs preserve a validation receipt reference and validation-gated attribution; a receipt is local evidence, not consensus. A well-formed but unknown local job ID returns `schema_version`, `job_id`, `status: not_found`, and `error`; malformed path IDs are rejected as invalid input. +`GET /api/jobs/{job_id}/trace` is a read-only local evidence join. Local Job Attribution Trace v1 returns `schema_version: 1`, `trace_scope: local-only-validation-gated-attribution`, the requested `job_id`, and an ordered `chain` containing job, result, validation-receipt, contribution, manifest, and creator-node records. It succeeds only for an accepted, passed local validation receipt whose persisted result hash, manifest reference and hash, creator ID, and contribution attribution agree with the linked local artifacts. Missing or inconsistent links fail closed; the endpoint does not create contribution evidence or imply network consensus. + Example completed projection (dynamic IDs and timestamps omitted): ```json diff --git a/docs/phase-1-local-api-boundary.md b/docs/phase-1-local-api-boundary.md index a8d2d65..ab779c6 100644 --- a/docs/phase-1-local-api-boundary.md +++ b/docs/phase-1-local-api-boundary.md @@ -15,7 +15,7 @@ Phase 1 supports these operations only: | Create or load node | `start_local_node_runtime(runtime_dir, reset_creator_identity=False)` | `start-local-node --runtime-dir