From 7eb8b894528ba2b7ce4a7953f465a8a78dbce100 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 22:13:14 -0400 Subject: [PATCH 1/2] Represent build and deployment evidence in task work products --- docs/design_index.md | 1 + docs/engineering/deployment_evidence.md | 138 +++++++ .../task_responsibility_continuation.md | 7 + src/backend/server/routes/task_routes.py | 59 +++ .../services/deployment_evidence_service.py | 385 ++++++++++++++++++ .../services/task_work_product_service.py | 15 + .../static/js/components/taskPanel.js | 25 +- .../web/von_interface/static/styles.css | 9 + .../test_deployment_evidence_service.py | 319 +++++++++++++++ tests/browser/taskDeploymentEvidence.cjs | 60 +++ tests/frontend/taskPanelWorkProduct.test.js | 33 +- 11 files changed, 1045 insertions(+), 6 deletions(-) create mode 100644 docs/engineering/deployment_evidence.md create mode 100644 src/backend/services/deployment_evidence_service.py create mode 100644 tests/backend/test_deployment_evidence_service.py create mode 100644 tests/browser/taskDeploymentEvidence.cjs diff --git a/docs/design_index.md b/docs/design_index.md index 955b514ee..0dbd67dbb 100644 --- a/docs/design_index.md +++ b/docs/design_index.md @@ -80,6 +80,7 @@ live. |---|---| | Every task | [`AGENTS.md`](../AGENTS.md) | | Substantial, architecture-sensitive, or older-doc-dependent work | This index, then the applicable sections below | +| Build/deployment receipts and task work products | [Build and deployment evidence](engineering/deployment_evidence.md) | | Task product selection and continuing a responsibility from Tasks | [Task responsibility continuation](engineering/task_responsibility_continuation.md) | | Subscription-backed coding task pickup on the DGX | [Codex DGX worker pilot](engineering/codex_dgx_worker.md) | | Coding-agent progress and completion messages in Von | [Interactive coding-agent messages](engineering/codex_dgx_worker.md#interactive-coding-agent-messages); operator-specific delivery configuration in personal `AGENTS.md` | diff --git a/docs/engineering/deployment_evidence.md b/docs/engineering/deployment_evidence.md new file mode 100644 index 000000000..3ea0d4551 --- /dev/null +++ b/docs/engineering/deployment_evidence.md @@ -0,0 +1,138 @@ +# Build and deployment evidence + +- **Kind:** Capability and producer guide +- **Lifecycle:** Active +- **Authority:** API contract and repository release input; repository presence is not live activation +- **Owner:** Von maintainers +- **Last reviewed:** 12 September 2026 +- **Review trigger:** Receipt schema, task work products or deployment-controller changes + +A source revision identifies code. `#V#software_build` identifies an immutable +artefact (use its content digest or immutable CI artefact ID). A +`#V#software_deployment` identifies one attempt to serve that build in `local`, +`staging` or `production`. `#V#deployment_observation` preserves an attributed +status receipt. These identities are distinct even when the deployment serves a +source checkout directly: give the installation its own immutable build ID and +record the exact source revision separately. + +The release input is `deployment_evidence_service.VOCABULARY`. An authenticated +producer explicitly calls `POST /api/tasks/deployment-vocabulary` to create any +missing vocabulary in its normal creation scope. Existing concepts are reused; +this operation neither overwrites definitions nor publishes them globally. Use +canonical ontology publication authority separately when vocabulary or receipts +need broader visibility. A private vocabulary owned by a different actor must +be made available through that route before that actor can ingest receipts. +There is no startup migration, database write script or automatic publication. + +## Producer procedure + +Use the existing authenticated browser/API session and its active window scope. +The server derives actor and organisation; the payload cannot choose them. +Codex/DGX controllers or CI must use an already authorised session/API adapter; +this feature does not provision credentials, bypass login or grant deployment +permission. The coding worker must return evidence to its controller when its +execution instructions prohibit live writes. + +After vocabulary activation, POST this object to `/api/tasks/deployments`: + +```json +{ + "build_id": "sha256:", + "source_revision": "", + "deployment_id": "dgx-production-", + "environment": "production", + "target": "", + "receipt_id": "", + "status": "deployed", + "deployed_at": "2026-09-12T21:59:00Z", + "observed_at": "2026-09-12T22:00:00Z", + "evidence": "", + "implements_tasks": ["#V#task_..."], + "verifies_tasks": [] +} +``` + +Retain the returned concept IDs and canonical read-back. A repeated identical +receipt reuses the same build, deployment, observation and links. Conflicting +content under an existing immutable ID is rejected. After an interrupted or +failed relationship write, retry the same receipt: completed steps are reused. +Identity reuse is scoped to the trusted actor and organisation; two independent +producers do not silently take ownership of each other's records. Controller/CI +retries therefore need a stable producer identity and organisation. + +Each status change has a **new receipt ID**, observation time and evidence. It +reuses the deployment ID, build ID, source revision, environment, target and +superseded-attempt reference. Supported statuses are `planned`, `built`, +`deployed`, `verified`, `failed`, `rolled_back` and `superseded`. Planned/built +receipts need not include a deployment time or target. When the target becomes +known it is part of a new deployment identity; do not rewrite a planned +attempt's immutable core. Deployment, verification, rollback and supersession +receipts require a deployment time. Include explicit evidence even for planned +or failed states, so consumers can distinguish a producer report from absence. + +`verified` is the producer's evidence-backed observation, not a server-side +health probe or certification. Record the actual served revision and relevant +service/PWA checks, including limitations. A successful code push or process +start alone is insufficient. Ingestion never calls a model, deploys code, +completes a task, selects a product or asserts all linked tasks passed. Per-receipt +task links preserve which work that observation concerned. + +History is append-only through this API. Current status is the latest observed +time, so late ingestion of an older receipt does not regress it. Contradictory +statuses/deployment times at the same latest time produce `ambiguous`; submit a +new explicit observation to resolve them. The normal concept store owns access, +creation timestamps and mutation provenance. This ingestion contract is not an +additional immutable-storage boundary against separately authorised generic +concept edits. + +## Reads and task work products + +- `GET /api/tasks//deployments`: all accessible deployments implementing + or verifying the task, including multiple builds and attempts. +- `GET /api/tasks/deployments/`: build identity, source, + environment, target, status, history and accessible task links. +- `GET /api/tasks/builds//deployments`: attempts for that build, + with their task links (build-to-task traversal). + +`#V#deploys_build`, `#V#implements_task`, `#V#verifies_task`, +`#V#observes_deployment` and `#V#supersedes_deployment` are canonical graph links. +Reverse reads query those same forward edges; no independently updated reverse +list is maintained. Exact structured receipt fields live in the concept's +`attributes.deployment_evidence`; they are not free-text task notes or copied +current-product content. These are actor-visible operational assertions, not +global domain truths. + +Task details display builds, attempts, status and receipt history. A verified +attempt offers **Use as current work product**, using the existing +`#V#hascurrentworkproduct` selection route. Opening that product reads its current +structured deployment evidence. A later failure or rollback remains visible; +`ready` on the product response means readable, not successfully deployed. +Other concept products retain their existing text-content behaviour. Links +neither widen visibility nor transfer deployment authority. + +## Rollback, redeployment and acceptance + +A rollback or redeployment is a **new deployment ID**, even if it serves an +already known build. Supply `supersedes` with the prior deployment concept ID; +it must refer to a different attempt at the same environment and target. Record +the old attempt's rolled-back/superseded observation separately. This preserves +partial outcomes and does not invent a cross-record atomic traffic switch. +A replacement does not automatically prove the old deployment stopped serving. +Unverified deployments remain inspectable and are not offered as verified +product selections. + +Candidate tests cover ingestion, reuse, conflicts, traversal, lifecycle, +partial retry, access-filtered reads and the product API. The browser fixture +`tests/browser/taskDeploymentEvidence.cjs` exercises the actual task panel and +CSS at desktop/mobile widths with isolated receipts. It does not prove live +ontology activation or a public deployment. + +For representative live acceptance, bind an isolated authenticated candidate +profile to the exact candidate checkout/revision, with a disposable database +and no model generation. Ingest an operator-provided real deployment receipt, +read it through all three traversal APIs, select the verified attempt on an +accessible fixture task, and reopen it after a new unverified/rollback receipt. +Record the candidate SHA, receipt IDs, observed served revision and browser +read-back. Keep deployment credentials outside the checkout. Public Cloudflare +access redirects and browser profiles bound to other checkouts are not evidence +for this candidate. No public deployment is requested by this task. diff --git a/docs/engineering/task_responsibility_continuation.md b/docs/engineering/task_responsibility_continuation.md index 3d4ca46cf..395261085 100644 --- a/docs/engineering/task_responsibility_continuation.md +++ b/docs/engineering/task_responsibility_continuation.md @@ -23,6 +23,13 @@ choosing a neighbouring product or guessing a revision from timestamps. The concept inspector remains available for accessible products needing revision resolution. Product reads do not change task status or start work. + +A selected software deployment is a structured work product: its current receipt +history and deployment status are read directly, without requiring a copied +`hasContent` assertion. Task details also show all linked deployment attempts and +allow selecting a verified attempt. See [build and deployment evidence](deployment_evidence.md) +for the distinct build, source revision, deployment and verification identities. + **Discuss** opens the existing task-focused conversation path with the accessible product reference alongside the task. This also works for human review tasks; it does not transfer their assignment to Von. For an eligible task assigned to diff --git a/src/backend/server/routes/task_routes.py b/src/backend/server/routes/task_routes.py index d149cf282..1cc1fc223 100644 --- a/src/backend/server/routes/task_routes.py +++ b/src/backend/server/routes/task_routes.py @@ -398,6 +398,62 @@ def get_task_taxonomy_route() -> ResponseReturnValue: return jsonify({"error": "Internal server error"}), 500 +@task_bp.route("/deployment-vocabulary", methods=["POST"]) +@task_bp.route("/deployments", methods=["POST"]) +@task_bp.route("/deployments/", methods=["GET"]) +@task_bp.route("/builds//deployments", methods=["GET"]) +@task_bp.route("//deployments", methods=["GET"]) +def deployment_evidence_route(deployment_id=None, build_id=None, task_concept_id=None): + """Actor-scoped receipt ingestion and represented evidence traversal.""" + from ...services import deployment_evidence_service as evidence + + actor = _get_current_user_concept_id() + if not actor: + return jsonify({"error": "Authentication required"}), 401 + try: + if request.method == "POST": + if request.path.endswith("/deployment-vocabulary"): + evidence.bootstrap_vocabulary( + actor=actor, organisation=_get_current_org_concept_id() + ) + return jsonify({"concept_ids": list(evidence.VOCABULARY)}), 200 + return ( + jsonify( + evidence.ingest_deployment( + request.get_json(silent=True), + actor=actor, + organisation=_get_current_org_concept_id(), + ) + ), + 200, + ) + if task_concept_id: + return ( + jsonify( + {"deployments": evidence.list_task_deployments(task_concept_id)} + ), + 200, + ) + if build_id: + return ( + jsonify({"deployments": evidence.list_build_deployments(build_id)}), + 200, + ) + return jsonify(evidence.get_deployment(deployment_id)), 200 + except TaskNotFoundError: + return jsonify({"error": "Task unavailable"}), 404 + except evidence.DeploymentEvidenceError as exc: + return jsonify({"error": str(exc)}), 409 if request.method == "POST" else 404 + except Exception: + logger.exception("Deployment evidence operation failed") + return ( + jsonify( + {"error": "Evidence operation unavailable; retry the same receipt"} + ), + 503, + ) + + @task_bp.route("//work-product", methods=["GET"]) def get_task_work_product_route(task_concept_id: str) -> ResponseReturnValue: """Read the selected product through the same actor-visible task boundary.""" @@ -416,6 +472,9 @@ def get_task_route(task_concept_id: str) -> ResponseReturnValue: """Get a task by concept_id.""" try: result = dict(get_task(task_concept_id)) + from ...services.deployment_evidence_service import list_task_deployments + + result["deployments"] = list_task_deployments(task_concept_id) actions = list_task_external_resource_actions(result) result["external_resource_actions"] = [ { diff --git a/src/backend/services/deployment_evidence_service.py b/src/backend/services/deployment_evidence_service.py new file mode 100644 index 000000000..a7e751cd7 --- /dev/null +++ b/src/backend/services/deployment_evidence_service.py @@ -0,0 +1,385 @@ +"""Represented build identities, deployment attempts and immutable observations. + +Attributes hold exact receipt fields; typed relationships carry domain links. +An observation reports evidence, not authority to deploy or complete a task. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone + +from ..db.repositories.concepts_repository import ConceptsRepository +from ..security.access_control import can_access_concept +from . import concept_service +from .relationship_write_service import add_relationship + +BUILD = "#V#software_build" +DEPLOYMENT = "#V#software_deployment" +OBSERVATION = "#V#deployment_observation" +BUILD_LINK = "#V#deploys_build" +IMPLEMENTS = "#V#implements_task" +VERIFIES = "#V#verifies_task" +OBSERVES = "#V#observes_deployment" +SUPERSEDES = "#V#supersedes_deployment" +VOCABULARY = { + BUILD: ( + "Software build", + "An immutable artefact identity, distinct from its source revision and deployment attempts.", + ), + DEPLOYMENT: ( + "Software deployment", + "One planned or actual attempt to serve a build in an environment.", + ), + OBSERVATION: ( + "Deployment observation", + "An immutable attributed status receipt; verification describes observed evidence, not task completion.", + ), + BUILD_LINK: ( + "deploys build", + "Links a deployment attempt to its immutable build identity.", + ), + IMPLEMENTS: ( + "implements task", + "Links a deployment to work it delivers; does not assert completion.", + ), + VERIFIES: ( + "verifies task", + "Links a deployment to a task whose outcome its evidence checks.", + ), + OBSERVES: ( + "observes deployment", + "Links a status receipt to its deployment attempt.", + ), + SUPERSEDES: ( + "supersedes deployment", + "Links a replacement or rollback attempt to the prior deployment in the same environment and target.", + ), +} +STATUSES = { + "planned", + "built", + "deployed", + "verified", + "failed", + "rolled_back", + "superseded", +} + + +class DeploymentEvidenceError(ValueError): + pass + + +def _required(data, key, maximum=2048): + value = data.get(key) + if not isinstance(value, str) or not value.strip() or len(value) > maximum: + raise DeploymentEvidenceError( + f"{key} must be a non-empty string (maximum {maximum})" + ) + return value.strip() + + +def _date(data, key): + value = _required(data, key, 80) + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError() + return parsed.astimezone(timezone.utc).isoformat() + except ValueError: + raise DeploymentEvidenceError( + f"{key} must include an ISO 8601 timezone" + ) from None + + +def _identity(kind, *parts): + digest = hashlib.sha256(json.dumps(parts, ensure_ascii=False).encode()).hexdigest() + return f"#V#{kind}_{digest}" + + +def _payload(doc): + return (doc.get("attributes") or {}).get("deployment_evidence") or {} + + +def _read(concept_id, kind=None): + if not can_access_concept(concept_id): + raise DeploymentEvidenceError("Record is unavailable") + doc = ConceptsRepository.find_one({"concept_id": concept_id}) + if not doc or ( + kind + and kind not in (doc.get("relationships") or {}).get("is_an_instance_of", []) + ): + raise DeploymentEvidenceError("Record is unavailable") + return doc + + +def _ensure(concept_id, kind, payload, actor, organisation, name=None): + doc = ConceptsRepository.find_one({"concept_id": concept_id}) + if not doc: + try: + concept_service.create_concept( + name=name + or payload.get("deployment_id") + or payload.get("build_id") + or "Deployment evidence", + concept_id=concept_id, + parent_concept_ids=[kind], + attributes={"deployment_evidence": payload}, + created_by_concept_id=actor, + organisation_concept_id=organisation, + maintain_relationship_inverses=False, + ) + except concept_service.ConceptServiceError: + # A concurrent identical ingestion may have won the unique-ID insert. + if not ConceptsRepository.find_one({"concept_id": concept_id}): + raise + doc = _read(concept_id, kind) + if _payload(doc) != payload: + raise DeploymentEvidenceError( + "Immutable identifier already has different evidence" + ) + return concept_id + + +def _link(source, predicate, target): + result = add_relationship(source, predicate, target, maintain_inverse=False) + if not result.get("success"): + raise DeploymentEvidenceError( + "Relationship write failed; retry the same receipt" + ) + if target not in (_read(source).get("relationships") or {}).get(predicate, []): + raise DeploymentEvidenceError( + "Relationship read-back failed; retry the same receipt" + ) + + +def bootstrap_vocabulary(*, actor, organisation=None): + """Explicit scoped release input, never run as a side effect of a read. + + Use the canonical publication route separately if shared vocabulary needs + wider visibility. Operational administration alone does not grant that scope. + """ + for cid, (name, description) in VOCABULARY.items(): + if ConceptsRepository.find_one({"concept_id": cid}): + continue + concept_service.create_concept( + name=name, + concept_id=cid, + description=description, + parent_concept_ids=[ + ( + "#V#thing" + if cid in {BUILD, DEPLOYMENT, OBSERVATION} + else "#V#binary_predicate" + ) + ], + create_as_instance=cid not in {BUILD, DEPLOYMENT, OBSERVATION}, + created_by_concept_id=actor, + organisation_concept_id=organisation, + maintain_relationship_inverses=False, + ) + + +def ingest_deployment(data, *, actor, organisation=None): + """Ingest an exact receipt under trusted actor scope; safe to retry after partial writes.""" + from .task_management_service import _get_task_doc + + if not actor or not isinstance(data, dict): + raise DeploymentEvidenceError("Authenticated actor and object receipt required") + allowed = { + "build_id", + "source_revision", + "deployment_id", + "environment", + "target", + "status", + "observed_at", + "deployed_at", + "evidence", + "implements_tasks", + "verifies_tasks", + "supersedes", + "receipt_id", + } + if set(data) - allowed: + raise DeploymentEvidenceError("Unknown receipt fields") + build = {key: _required(data, key) for key in ("build_id", "source_revision")} + deployment = {key: _required(data, key) for key in ("deployment_id", "environment")} + if deployment["environment"] not in {"local", "staging", "production"}: + raise DeploymentEvidenceError( + "environment must be local, staging or production" + ) + deployment["target"] = ( + _required(data, "target") if data.get("target") is not None else None + ) + status = _required(data, "status", 40) + if status not in STATUSES: + raise DeploymentEvidenceError("Unknown deployment status") + observation = { + "receipt_id": _required(data, "receipt_id"), + "status": status, + "observed_at": _date(data, "observed_at"), + "evidence": _required(data, "evidence", 16000), + } + observation["deployed_at"] = ( + _date(data, "deployed_at") if data.get("deployed_at") else None + ) + if ( + status in {"deployed", "verified", "rolled_back", "superseded"} + and not observation["deployed_at"] + ): + raise DeploymentEvidenceError("deployed_at required for this status") + if ( + observation["deployed_at"] + and observation["deployed_at"] > observation["observed_at"] + ): + raise DeploymentEvidenceError("deployed_at cannot follow observed_at") + task_links = {} + for field, predicate in ( + ("implements_tasks", IMPLEMENTS), + ("verifies_tasks", VERIFIES), + ): + ids = data.get(field, []) + if ( + not isinstance(ids, list) + or len(ids) > 100 + or any(not isinstance(cid, str) for cid in ids) + ): + raise DeploymentEvidenceError( + f"{field} must be a list of at most 100 task IDs" + ) + task_links[predicate] = sorted(set(_get_task_doc(cid)[0] for cid in ids)) + build_id = _identity("build", actor, organisation, build["build_id"]) + deployment_id = _identity( + "deployment", actor, organisation, deployment["deployment_id"] + ) + deployment.update( + build_concept_id=build_id, recorded_by=actor, organisation=organisation + ) + supersedes = data.get("supersedes") + if supersedes is not None: + previous = _payload(_read(_required(data, "supersedes"), DEPLOYMENT)) + if supersedes == deployment_id or any( + previous.get(key) != deployment[key] for key in ("environment", "target") + ): + raise DeploymentEvidenceError( + "Replacement must refer to a different attempt at the same environment and target" + ) + deployment["supersedes"] = supersedes + observation.update( + recorded_by=actor, deployment_concept_id=deployment_id, task_links=task_links + ) + observation_id = _identity( + "deployment_observation", actor, organisation, observation["receipt_id"] + ) + # Validate immutable IDs before any writes, then read back after each create. + for cid, payload in ( + (build_id, build), + (deployment_id, deployment), + (observation_id, observation), + ): + existing = ConceptsRepository.find_one({"concept_id": cid}) + if existing and _payload(existing) != payload: + raise DeploymentEvidenceError( + "Immutable identifier already has different evidence" + ) + for cid in VOCABULARY: + _read(cid) + _ensure(build_id, BUILD, build, actor, organisation) + _ensure(deployment_id, DEPLOYMENT, deployment, actor, organisation) + _link(deployment_id, BUILD_LINK, build_id) + if supersedes: + _link(deployment_id, SUPERSEDES, supersedes) + for predicate, ids in task_links.items(): + for cid in ids: + _link(deployment_id, predicate, cid) + _ensure(observation_id, OBSERVATION, observation, actor, organisation) + _link(observation_id, OBSERVES, deployment_id) + result = get_deployment(deployment_id) + if observation_id not in {row["concept_id"] for row in result["observations"]}: + raise DeploymentEvidenceError( + "Receipt read-back failed; retry the same receipt" + ) + return result + + +def get_deployment(concept_id): + doc = _read(concept_id, DEPLOYMENT) + payload = _payload(doc) + build = _read(payload["build_concept_id"], BUILD) + observations = [ + {**_payload(row), "concept_id": row["concept_id"]} + for row in ConceptsRepository.find({f"relationships.{OBSERVES}": concept_id}) + if OBSERVATION in (row.get("relationships") or {}).get("is_an_instance_of", []) + ] + observations.sort(key=lambda row: (row["observed_at"], row["concept_id"])) + latest = observations[-1] if observations else None + tied = [ + row + for row in observations + if latest and row["observed_at"] == latest["observed_at"] + ] + status = latest["status"] if latest else "unreported" + if len({(row["status"], row["deployed_at"]) for row in tied}) > 1: + status = "ambiguous" + tasks = {} + for predicate, field in ( + (IMPLEMENTS, "implements_tasks"), + (VERIFIES, "verifies_tasks"), + ): + tasks[field] = [ + cid + for cid in (doc.get("relationships") or {}).get(predicate, []) + if can_access_concept(cid) + ] + # Do not leak subsequently hidden task references through raw receipts. + for row in observations: + row["task_links"] = { + pred: [cid for cid in ids if can_access_concept(cid)] + for pred, ids in row.get("task_links", {}).items() + } + return { + **payload, + "concept_id": concept_id, + "build": {**_payload(build), "concept_id": build["concept_id"]}, + "status": status, + "deployed_at": latest["deployed_at"] if latest else None, + "observations": observations, + **tasks, + } + + +def list_task_deployments(task_id): + from .task_management_service import _get_task_doc + + task_id, _ = _get_task_doc(task_id) + docs = ConceptsRepository.find( + { + "$or": [ + {f"relationships.{IMPLEMENTS}": task_id}, + {f"relationships.{VERIFIES}": task_id}, + ] + } + ) + result = [] + for doc in docs: + if DEPLOYMENT not in (doc.get("relationships") or {}).get( + "is_an_instance_of", [] + ): + continue + try: + result.append(get_deployment(doc["concept_id"])) + except DeploymentEvidenceError: + # A link does not grant access to the build or keep it alive. + continue + return result + + +def list_build_deployments(build_id): + _read(build_id, BUILD) + return [ + get_deployment(doc["concept_id"]) + for doc in ConceptsRepository.find({f"relationships.{BUILD_LINK}": build_id}) + ] diff --git a/src/backend/services/task_work_product_service.py b/src/backend/services/task_work_product_service.py index e0bf53c6b..73b8904f0 100644 --- a/src/backend/services/task_work_product_service.py +++ b/src/backend/services/task_work_product_service.py @@ -40,6 +40,21 @@ def resolve_task_work_product( if not doc: return {"status": "unavailable"} reference = {"concept_id": product_id} + from .deployment_evidence_service import DEPLOYMENT, get_deployment + + if DEPLOYMENT in (doc.get("relationships") or {}).get("is_an_instance_of", []): + deployment = get_deployment(product_id) + result = { + **reference, + "status": "ready", + "kind": "deployment", + "deployment": deployment, + } + if include_content: + import json + + result["content"] = json.dumps(deployment, indent=2, ensure_ascii=False) + return result rows = get_texts_for_concept( product_id, predicate="hasContent", diff --git a/src/frontend/web/von_interface/static/js/components/taskPanel.js b/src/frontend/web/von_interface/static/js/components/taskPanel.js index 0a04a639e..4ca9bd7d4 100644 --- a/src/frontend/web/von_interface/static/js/components/taskPanel.js +++ b/src/frontend/web/von_interface/static/js/components/taskPanel.js @@ -2749,6 +2749,21 @@ function renderTaskOrganisationField(task) { `; } +function renderTaskDeployments(task) { + const deployments = task.deployments || []; + if (!deployments.length) return ''; + return `

Builds and deployments

+ ${deployments.map(deployment => `
+ +

${escapeHtml(deployment.environment)} · ${escapeHtml(deployment.status)} · ${escapeHtml(deployment.target || 'Target not recorded')}

+

Build: ${escapeHtml(deployment.build?.build_id || '')}
Source revision: ${escapeHtml(deployment.build?.source_revision || '')}
Deployed: ${escapeHtml(deployment.deployed_at || 'Not recorded')}

+ ${deployment.status !== 'verified' ? '

This deployment is not currently verified.

' : ''} +
Deployment evidence and history
${escapeHtml(JSON.stringify(deployment.observations || [], null, 2))}
+ ${deployment.status === 'verified' ? `` : ''} +
`).join('')} +
`; +} + function renderTaskWorkProduct(task, detailState) { const product = detailState?.product || task.current_work_product; const messages = { @@ -2762,8 +2777,9 @@ function renderTaskWorkProduct(task, detailState) { const productId = product?.concept_id || ''; const execute = canExecuteTaskWithVon(task); const active = isTaskExecutionLaunchSuppressed(taskId); - return `
+ return `${renderTaskDeployments(task)}

Current work product

+ ${product?.kind === 'deployment' ? `

Deployment: ${escapeHtml(product.deployment?.status || 'unreported')} (${escapeHtml(product.deployment?.environment || '')})

` : ''}

${product?.status === 'ready' ? escapeHtml(productId) : escapeHtml(messages[product?.status] || 'Open task details to check its current product.')}

${product?.status === 'ready' ? `` : ''} ${productId ? `` : ''} @@ -3950,14 +3966,15 @@ function attachTaskEventListeners() { void openTaskWorkProduct(button.dataset.taskId); }); }); - root.querySelectorAll('.task-save-product-btn').forEach(button => { + root.querySelectorAll('.task-save-product-btn, .task-select-deployment-btn').forEach(button => { button.addEventListener('click', async event => { event.stopPropagation(); const taskId = button.dataset.taskId; - const input = button.closest('.task-work-product').querySelector('.task-current-product-input'); + const input = button.closest('.task-work-product')?.querySelector('.task-current-product-input'); + const productId = button.classList.contains('task-select-deployment-btn') ? button.dataset.conceptId : (input.value.trim() || null); button.disabled = true; try { - await patchJson(`/api/tasks/${encodeURIComponent(taskId)}`, { current_work_product_concept_id: input.value.trim() || null }); + await patchJson(`/api/tasks/${encodeURIComponent(taskId)}`, { current_work_product_concept_id: productId }); const detail = getTaskDetailState(taskId); detail.product = null; detail.productHtml = ''; diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 7dfa2ecb2..fcc2b5917 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18583,3 +18583,12 @@ body.settings-body { .unified-message-content .send-message-btn { padding: 0 12px; font-size: 14px; } .unified-message-content .message-reply-destination { margin: 0 0 4px; font-size: 12px; } } + +.task-deployment { + border: 1px solid var(--border-color, #ccc); + border-radius: 6px; + padding: 10px; + margin-block: 10px; + overflow-wrap: anywhere; +} +.task-deployment pre { white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/tests/backend/test_deployment_evidence_service.py b/tests/backend/test_deployment_evidence_service.py new file mode 100644 index 000000000..88631ec20 --- /dev/null +++ b/tests/backend/test_deployment_evidence_service.py @@ -0,0 +1,319 @@ +"""Receipt lifecycle against an isolated canonical-service fixture; no live writes.""" + +import copy + +import pytest +from flask import Flask + +from src.backend.services import deployment_evidence_service as service +from src.backend.services import task_management_service as tasks +from src.backend.services import task_work_product_service as products +from src.backend.server.routes import task_routes as routes + + +@pytest.fixture +def records(monkeypatch): + docs = {cid: {"concept_id": cid} for cid in service.VOCABULARY} + hidden = set() + + def matches(doc, query): + if "$or" in query: + return any(matches(doc, part) for part in query["$or"]) + for key, value in query.items(): + current = doc + for part in key.split("."): + current = current.get(part, {}) if isinstance(current, dict) else None + if not ( + value in current if isinstance(current, list) else value == current + ): + return False + return True + + def find(query, **_): + return [ + copy.deepcopy(doc) + for cid, doc in docs.items() + if cid not in hidden and matches(doc, query) + ] + + def create(**kwargs): + cid = kwargs["concept_id"] + assert cid not in docs + docs[cid] = { + "concept_id": cid, + "attributes": copy.deepcopy(kwargs.get("attributes", {})), + "relationships": { + ( + "is_an_instance_of" + if kwargs.get("create_as_instance", True) + else "is_a_type_of" + ): kwargs["parent_concept_ids"] + }, + } + return copy.deepcopy(docs[cid]) + + def link(source, predicate, target, **_): + values = docs[source]["relationships"].setdefault(predicate, []) + if target not in values: + values.append(target) + return {"success": True} + + def task(cid): + if cid in hidden or not cid.startswith("#V#task_"): + raise tasks.TaskNotFoundError("Unavailable") + return cid, {"concept_id": cid} + + monkeypatch.setattr(service.ConceptsRepository, "find", find) + monkeypatch.setattr( + service.ConceptsRepository, + "find_one", + lambda query, **kwargs: next(iter(find(query)), None), + ) + monkeypatch.setattr(service.concept_service, "create_concept", create) + monkeypatch.setattr(service, "add_relationship", link) + monkeypatch.setattr(service, "can_access_concept", lambda cid: cid not in hidden) + monkeypatch.setattr(products, "can_access_concept", lambda cid: cid not in hidden) + monkeypatch.setattr(tasks, "_get_task_doc", task) + return docs, hidden + + +def receipt(**changes): + return { + "build_id": "sha256:artefact-a", + "source_revision": "ab10a303e", + "deployment_id": "dgx-20260912-1", + "environment": "production", + "target": "Von service and PWA", + "status": "deployed", + "observed_at": "2026-09-12T22:00:00Z", + "deployed_at": "2026-09-12T21:59:00Z", + "evidence": "Fixture: health revision and PWA asset manifest observed", + "receipt_id": "ci-run-1-deployed", + "implements_tasks": ["#V#task_a"], + "verifies_tasks": ["#V#task_b"], + **changes, + } + + +def ingest(**changes): + return service.ingest_deployment( + receipt(**changes), actor="#V#alice", organisation="#V#lab" + ) + + +def test_ingestion_retry_bidirectional_reads_and_multiple_builds(records): + first = ingest() + count = len(records[0]) + assert ingest() == first + assert len(records[0]) == count + assert first["implements_tasks"] == ["#V#task_a"] + assert first["verifies_tasks"] == ["#V#task_b"] + assert service.list_build_deployments(first["build"]["concept_id"]) == [first] + second = ingest( + build_id="sha256:artefact-b", deployment_id="dgx-2", receipt_id="ci-2" + ) + assert {d["concept_id"] for d in service.list_task_deployments("#V#task_a")} == { + first["concept_id"], + second["concept_id"], + } + assert len(service.list_task_deployments("#V#task_b")) == 2 + + +def test_status_changes_retain_provenance_and_late_receipts(records): + first = ingest() + verified = ingest( + status="verified", receipt_id="verify-1", observed_at="2026-09-12T22:05:00Z" + ) + assert verified["status"] == "verified" + assert len(verified["observations"]) == 2 + assert verified["observations"][-1]["recorded_by"] == "#V#alice" + assert ingest() == verified + assert first["build"] == verified["build"] + result = ingest( + status="failed", receipt_id="conflict", observed_at="2026-09-12T22:05:00Z" + ) + assert result["status"] == "ambiguous" + + +def test_rollback_reuses_build_but_has_new_attempt_and_environment_boundary(records): + first = ingest() + rollback = ingest( + deployment_id="rollback-1", + receipt_id="rollback-1", + supersedes=first["concept_id"], + ) + assert rollback["build"] == first["build"] + assert rollback["concept_id"] != first["concept_id"] + assert rollback["supersedes"] == first["concept_id"] + with pytest.raises(service.DeploymentEvidenceError, match="same environment"): + ingest( + deployment_id="staging-1", + receipt_id="staging-1", + environment="staging", + supersedes=first["concept_id"], + ) + + +@pytest.mark.parametrize("environment", ["local", "staging", "production"]) +def test_planned_and_built_do_not_assert_deployment(records, environment): + result = ingest(environment=environment, status="planned", deployed_at=None) + assert result["deployed_at"] is None + assert ( + ingest( + environment=environment, + status="built", + deployed_at=None, + receipt_id="built", + observed_at="2026-09-12T22:01:00Z", + )["status"] + == "built" + ) + + +@pytest.mark.parametrize( + "changes", + [{"source_revision": "other"}, {"target": "other"}, {"status": "verified"}], +) +def test_immutable_id_conflict_precedes_writes(records, changes): + ingest() + before = copy.deepcopy(records[0]) + with pytest.raises(service.DeploymentEvidenceError, match="Immutable"): + ingest(**changes) + assert records[0] == before + + +@pytest.mark.parametrize( + "changes", + [ + {"environment": "unknown"}, + {"deployed_at": None}, + {"observed_at": "2026-09-12"}, + {"implements_tasks": "#V#task_a"}, + {"evidence": ""}, + {"actor": "forged"}, + ], +) +def test_invalid_receipts_cannot_write(records, changes): + before = copy.deepcopy(records[0]) + with pytest.raises(service.DeploymentEvidenceError): + ingest(**changes) + assert records[0] == before + + +def test_hidden_tasks_and_products_are_not_exposed(records): + first = ingest() + records[1].add("#V#task_b") + result = service.get_deployment(first["concept_id"]) + assert result["verifies_tasks"] == [] + assert result["observations"][0]["task_links"][service.VERIFIES] == [] + with pytest.raises(tasks.TaskNotFoundError): + service.list_task_deployments("#V#task_b") + records[1].add(first["concept_id"]) + with pytest.raises(service.DeploymentEvidenceError): + service.get_deployment(first["concept_id"]) + + +def test_selected_deployment_work_product_exposes_unverified_state(records): + first = ingest() + result = products.resolve_task_work_product( + { + "relationships": { + products.PREDICATE_HAS_CURRENT_WORK_PRODUCT: [first["concept_id"]] + } + }, + include_content=True, + ) + assert result["kind"] == "deployment" + assert result["deployment"]["status"] == "deployed" + assert '"status": "deployed"' in result["content"] + + +def test_routes_bind_actor_and_read_back(records, monkeypatch): + app = Flask(__name__) + app.register_blueprint(routes.task_bp, url_prefix="/api/tasks") + monkeypatch.setattr(routes, "_get_current_user_concept_id", lambda: "#V#alice") + monkeypatch.setattr(routes, "_get_current_org_concept_id", lambda: "#V#lab") + client = app.test_client() + result = client.post("/api/tasks/deployments", json=receipt()) + assert result.status_code == 200 + cid = result.json["concept_id"].replace("#", "%23") + assert client.get(f"/api/tasks/deployments/{cid}").json == result.json + assert client.get("/api/tasks/%23V%23task_a/deployments").json["deployments"] == [ + result.json + ] + bid = result.json["build"]["concept_id"].replace("#", "%23") + assert client.get(f"/api/tasks/builds/{bid}/deployments").json["deployments"] == [ + result.json + ] + monkeypatch.setattr(routes, "_get_current_user_concept_id", lambda: None) + assert client.post("/api/tasks/deployments", json=receipt()).status_code == 401 + assert client.get(f"/api/tasks/deployments/{cid}").status_code == 401 + + +def test_partial_ingestion_can_be_retried_without_duplicate_records( + records, monkeypatch +): + link = service.add_relationship + failed = False + + def fail_once(source, predicate, target, **kwargs): + nonlocal failed + if predicate == service.OBSERVES and not failed: + failed = True + return {"success": False} + return link(source, predicate, target, **kwargs) + + monkeypatch.setattr(service, "add_relationship", fail_once) + with pytest.raises(service.DeploymentEvidenceError, match="retry"): + ingest() + count = len(records[0]) + result = ingest() + assert len(records[0]) == count + assert result["status"] == "deployed" + assert len(result["observations"]) == 1 + + +def test_missing_vocabulary_fails_before_creating_receipt(records): + records[0].pop(service.BUILD) + before = copy.deepcopy(records[0]) + with pytest.raises(service.DeploymentEvidenceError, match="unavailable"): + ingest() + assert records[0] == before + + +def test_explicit_vocabulary_creation_is_scoped_idempotent_release_input( + records, monkeypatch +): + records[0].clear() + create = service.concept_service.create_concept + calls = [] + + def tracked(**kwargs): + calls.append(kwargs) + return create(**kwargs) + + monkeypatch.setattr(service.concept_service, "create_concept", tracked) + service.bootstrap_vocabulary(actor="#V#alice", organisation="#V#lab") + service.bootstrap_vocabulary(actor="#V#alice", organisation="#V#lab") + assert set(records[0]) == set(service.VOCABULARY) + assert len(calls) == len(service.VOCABULARY) + assert all(call["created_by_concept_id"] == "#V#alice" for call in calls) + assert all(call["organisation_concept_id"] == "#V#lab" for call in calls) + assert all(call["maintain_relationship_inverses"] is False for call in calls) + assert all("visibility_scope_mode" not in call for call in calls) + + from src.backend.vontology.utils_vontology import is_predicate + + for cid, doc in records[0].items(): + assert is_predicate(doc) == ( + cid not in {service.BUILD, service.DEPLOYMENT, service.OBSERVATION} + ) + + +def test_hidden_build_does_not_block_other_task_evidence(records): + first = ingest() + second = ingest( + build_id="build-b", deployment_id="attempt-b", receipt_id="receipt-b" + ) + records[1].add(first["build"]["concept_id"]) + assert service.list_task_deployments("#V#task_a") == [second] diff --git a/tests/browser/taskDeploymentEvidence.cjs b/tests/browser/taskDeploymentEvidence.cjs new file mode 100644 index 000000000..0c0b84d98 --- /dev/null +++ b/tests/browser/taskDeploymentEvidence.cjs @@ -0,0 +1,60 @@ +/** Candidate task UI and styles with isolated deployment receipts; no live state or models. */ +const {chromium} = require('playwright'); +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); + +(async () => { + const output = path.resolve(process.argv[2] || '.run/deployment-evidence'); + fs.mkdirSync(output, {recursive: true}); + const root = path.resolve('src/frontend/web/von_interface/static'); + const deployments = ['deployed', 'verified', 'rolled_back'].map((status, index) => ({ + concept_id: `#V#deployment_${index}`, deployment_id: `dgx-attempt-${index}`, + environment: 'production', target: 'Von service / PWA', status, + deployed_at: '2026-09-12T21:59:00Z', + build: {build_id: `sha256:${'a'.repeat(64)}`, source_revision: 'ab10a303e'}, + observations: [{status, evidence: 'Representative fixture receipt; not a live deployment verification'}], + })); + const task = {task_concept_id:'#V#task_fixture', title:'Represent deployed Von builds', status:'in_progress', priority:'medium', deployments}; + const modules = { + '/js/apiService.js': `let task=${JSON.stringify(task)};export const WINDOW_SESSION_HEADER='X-Von-Window-Session';export const ensureUniqueWindowSessionId=async()=> 'fixture';export const getUserContext=()=>({user_id:'#V#fixture',org_id:'#V#fixture_org'});export const getJson=async(url)=>{if(url.startsWith('/api/tasks/?'))return {tasks:[{...task,deployments:undefined}]};if(url==='/api/tasks/%23V%23task_fixture')return task;return {};};export const postJson=async()=>({});export const deleteJson=async()=>({});export const patchJson=async(url,body)=>{window.lastProductPatch={url,body};task.current_work_product={status:'ready',kind:'deployment',concept_id:body.current_work_product_concept_id,deployment:task.deployments[1]};return task;};`, + '/js/tabNavigation.js': 'export const activateTab=()=>{};', + '/js/utils/toast.js': 'export const showToast=()=>{};', + '/js/markdownUtils.js': 'export const renderMarkdownViaServer=async()=>"";', + '/js/utils/nameSelection.js': 'export const selectBestNameForContext=()=>null;export const selectShortestNameForContext=()=>null;', + '/js/components/taskRunActivity.js': 'export const openTaskRunActivity=()=>{};', + }; + const server = http.createServer((req,res) => { + if(req.url==='/') {res.setHeader('Content-Type','text/html');res.end('
');} + else if(modules[req.url]) {res.setHeader('Content-Type','text/javascript');res.end(modules[req.url]);} + else if(['/styles.css','/js/components/taskPanel.js'].includes(req.url)) {res.setHeader('Content-Type',req.url.endsWith('.css')?'text/css':'text/javascript');res.end(fs.readFileSync(path.join(root,req.url)));} + else {res.statusCode=404;res.end();} + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + let browser; + try { + browser=await chromium.launch({headless:true,args:['--no-sandbox']}); + const page=await browser.newPage(); + const errors=[];page.on('pageerror',error=>errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.locator('.task-item').click(); + const inspector=page.locator('#globalTaskInspector'); + await inspector.locator('.task-deployment').first().waitFor(); + for(const width of [1440,390]) { + await page.setViewportSize({width,height:1000}); + assert.equal(await inspector.locator('.task-deployment').count(),3); + assert.equal(await inspector.locator('.task-select-deployment-btn').count(),1); + await inspector.locator('summary').first().click(); + assert.match(await inspector.innerText(),/not a live deployment verification/); + await inspector.locator('.task-select-deployment-btn').click(); + await page.waitForFunction(()=>window.lastProductPatch?.body.current_work_product_concept_id==='#V#deployment_1'); + assert.match(await inspector.innerText(),/Deployment: verified \(production\)/); + assert.equal(await inspector.locator('.task-deployment').evaluateAll(nodes=>nodes.every(el=>el.scrollWidth<=el.clientWidth+1)),true); + await page.screenshot({path:path.join(output,`deployments-${width}.png`),fullPage:true}); + } + assert.deepEqual(errors,[]); + fs.writeFileSync(path.join(output,'receipt.json'),JSON.stringify({passed:true,widths:[1440,390],checks:['three deployment attempts','unverified and rollback states','receipt history','verified product selection','no card overflow','no JavaScript errors'],scope:'candidate module and CSS, isolated API fixture; no live ingestion'},null,2)); + console.log('Deployment evidence browser fixture passed'); + } finally {if(browser)await browser.close();await new Promise(resolve=>server.close(resolve));} +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/tests/frontend/taskPanelWorkProduct.test.js b/tests/frontend/taskPanelWorkProduct.test.js index b46a4b467..e601cde5f 100644 --- a/tests/frontend/taskPanelWorkProduct.test.js +++ b/tests/frontend/taskPanelWorkProduct.test.js @@ -19,13 +19,13 @@ const selected = { originating_conversation_id: '#V#conversation_a', conversation_session_id: 'session-a', task_type_ids: [], current_work_product: {status: 'ready', concept_id: '#V#brief_a'}, }; -async function setup({product = {status: 'ready', concept_id: '#V#brief_a', content: 'Current A'}, activity = []} = {}) { +async function setup({product = {status: 'ready', concept_id: '#V#brief_a', content: 'Current A'}, activity = [], deployments = []} = {}) { const api = require(apiPath); api.getJson.mockImplementation(async url => { if (url === '/api/tasks/taxonomy') return {task_types: [], task_sources: [], defaults: {}}; if (url === '/von/api/chat_prompt_queue') return {items: activity}; if (url.startsWith('/api/tasks/?')) return {tasks: [selected], count: 1}; - if (url === '/api/tasks/%23V%23task_a') return selected; + if (url === '/api/tasks/%23V%23task_a') return {...selected, deployments}; if (url === '/api/tasks/%23V%23task_a/work-product') return product; return {}; }); @@ -111,3 +111,32 @@ test('reopening Tasks during an auth/scope reload cannot leave the workspace per await flush(); expect(document.querySelector('#globalTaskInspector').textContent).toContain('Maintain brief A'); }); + + +test('shows multiple deployment attempts and selects only the verified attempt as product', async () => { + const deployments = ['deployed', 'verified'].map((status, index) => ({ + concept_id: `#V#deployment_${index}`, deployment_id: `dgx-${index}`, + environment: 'production', target: '', status, + build: {build_id: `sha256:build-${index}`, source_revision: 'ab10a303e'}, + observations: [{status, evidence: 'Health revision checked'}], + })); + const api = await setup({deployments}); + document.querySelector('.task-item').click(); + await flush(); + const inspector = document.querySelector('#globalTaskInspector'); + expect(inspector.querySelectorAll('.task-deployment')).toHaveLength(2); + expect(inspector.textContent).toContain('This deployment is not currently verified.'); + expect(inspector.textContent).toContain('sha256:build-0'); + expect(inspector.querySelector('.task-deployment img')).toBeNull(); + expect(inspector.querySelectorAll('.task-select-deployment-btn')).toHaveLength(1); + inspector.querySelector('.task-select-deployment-btn').click(); + await flush(); + expect(api.patchJson).toHaveBeenCalledWith('/api/tasks/%23V%23task_a', {current_work_product_concept_id: '#V#deployment_1'}); +}); + +test('a fresh deployment product read preserves its unverified status', async () => { + await setup({product: {status: 'ready', kind: 'deployment', concept_id: '#V#deployment_1', deployment: {status: 'rolled_back', environment: 'production'}, content: 'Rollback evidence'}}); + document.querySelector('#globalTaskInspector .task-open-product-btn').click(); + await flush(); + expect(document.querySelector('#globalTaskInspector').textContent).toContain('Deployment: rolled_back (production)'); +}); From 806c3a32092b59ff5a37cab7bb48a8f5d08af6a8 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 12:03:03 -0400 Subject: [PATCH 2/2] Clarify deployment authority after representative acceptance --- docs/engineering/deployment_evidence.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engineering/deployment_evidence.md b/docs/engineering/deployment_evidence.md index 3ea0d4551..d6cfc0caf 100644 --- a/docs/engineering/deployment_evidence.md +++ b/docs/engineering/deployment_evidence.md @@ -135,4 +135,4 @@ accessible fixture task, and reopen it after a new unverified/rollback receipt. Record the candidate SHA, receipt IDs, observed served revision and browser read-back. Keep deployment credentials outside the checkout. Public Cloudflare access redirects and browser profiles bound to other checkouts are not evidence -for this candidate. No public deployment is requested by this task. +for this candidate. Public deployment and scoped vocabulary activation require explicit task authority; passing this isolated check does not itself activate either.