diff --git a/04-backend/harness-core/src/error.rs b/04-backend/harness-core/src/error.rs index 0fe260f7..8a3a15fa 100644 --- a/04-backend/harness-core/src/error.rs +++ b/04-backend/harness-core/src/error.rs @@ -95,7 +95,7 @@ impl HarnessError { Self::NotFound(_) => 404, Self::TenantIsolation(_) | Self::SandboxDenied(_) => 403, Self::NoAdapter(_) => 503, - Self::InvalidInput(_) | Self::InvalidModelId(_) => 400, + Self::InvalidInput(_) | Self::InvalidModelId(_) | Self::LicenseViolation(_) => 400, Self::ModelNotWhitelisted(_) => 422, Self::SlaViolation { .. } => 504, _ => 500, diff --git a/04-backend/harness-core/src/knowledge_registry.rs b/04-backend/harness-core/src/knowledge_registry.rs index 7185d304..9b16c5d6 100644 --- a/04-backend/harness-core/src/knowledge_registry.rs +++ b/04-backend/harness-core/src/knowledge_registry.rs @@ -391,7 +391,6 @@ impl KnowledgeSourceRegistryService { } if let Some(source_url) = req.source_url { validate_required("source_url", &source_url)?; - validate_github_trending_policy(&source_url, &source.refresh_policy)?; source.source_url = source_url; } if let Some(license) = req.license { @@ -428,7 +427,6 @@ impl KnowledgeSourceRegistryService { source.owner = owner; } if let Some(refresh_policy) = req.refresh_policy { - validate_github_trending_policy(&source.source_url, &refresh_policy)?; source.refresh_policy = refresh_policy; } if let Some(permission_policy) = req.permission_policy { @@ -443,6 +441,7 @@ impl KnowledgeSourceRegistryService { if let Some(citation_policy) = req.citation_policy { source.citation_policy = citation_policy; } + validate_github_trending_policy(&source.source_url, &source.refresh_policy)?; if is_candidate_only(&source.license, source.vendor_id.as_deref()) { source.status = KnowledgeSourceStatus::CandidateOnly; source.production_enabled = false; @@ -751,4 +750,33 @@ mod tests { ); assert_eq!(source.default_route, "disabled"); } + + #[test] + fn github_trending_source_url_and_policy_can_be_updated_atomically() { + let registry = KnowledgeSourceRegistryService::new(); + registry + .create_source(create_request("standards-to-trending")) + .expect("source should create"); + + let source = registry + .update_source( + "standards-to-trending", + super::UpdateKnowledgeSourceRequest { + source_url: Some("https://github.com/trending".to_owned()), + refresh_policy: Some( + "scheduled network job required; no ranking is synthesized locally" + .to_owned(), + ), + ..Default::default() + }, + ) + .expect("source_url and refresh policy should validate together"); + + assert_eq!(source.source_url, "https://github.com/trending"); + assert!( + source + .refresh_policy + .contains("scheduled network job required") + ); + } } diff --git a/04-backend/harness-core/src/module_generation.rs b/04-backend/harness-core/src/module_generation.rs index b13e5d9c..1a6cfdb9 100644 --- a/04-backend/harness-core/src/module_generation.rs +++ b/04-backend/harness-core/src/module_generation.rs @@ -898,6 +898,7 @@ impl ModuleGenerationService { /// Returns [`HarnessError::NotFound`] when the job id is unknown and /// [`HarnessError::InvalidInput`] when review is not allowed. pub fn review_job(&self, job_id: Uuid, req: GenerationReviewRequest) -> Result { + let lifecycle = self.lifecycle.clone(); self.mutate_job(job_id, |job| { ensure_status(job, &[GenerationJobStatus::PendingReview])?; let review = GenerationReview { @@ -909,17 +910,35 @@ impl ModuleGenerationService { created_at: Utc::now(), }; job.reviews.push(review); + if req.decision == GenerationReviewDecision::Rejected { + reject_lifecycle( + &lifecycle, + job.lifecycle_transaction_id, + req.reviewer.clone(), + req.comment.clone(), + )?; + set_generated_artifact_status(job, ArtifactStatus::Rejected); + } job.status = match req.decision { GenerationReviewDecision::Approved => GenerationJobStatus::PendingApproval, GenerationReviewDecision::NeedsChanges => GenerationJobStatus::PendingReview, GenerationReviewDecision::Rejected => GenerationJobStatus::Rejected, }; - Ok(vec![AuditSpec::new( + let mut audits = vec![AuditSpec::new( AuditEventKind::GenerationJobReviewed, - req.reviewer, + req.reviewer.clone(), "generation job active review completed", json!({ "decision": req.decision, "comment": req.comment }), - )]) + )]; + if req.decision == GenerationReviewDecision::Rejected { + audits.push(AuditSpec::new( + AuditEventKind::GenerationJobRejected, + req.reviewer, + "generation job rejected during active review", + json!({ "status": job.status }), + )); + } + Ok(audits) }) } @@ -2020,6 +2039,60 @@ mod tests { ); } + #[test] + fn active_review_reject_rejects_lifecycle_and_generated_artifacts() { + let (service, _audit, lifecycle) = service_with_lifecycle(); + let job = service + .create_job(input(GenerationMode::ModelToLightweightScene)) + .expect("job should be created"); + let job = service + .plan_job( + job.id, + GenerationActionRequest { + actor: None, + comment: None, + }, + ) + .and_then(|planned| { + service.run_job( + planned.id, + GenerationActionRequest { + actor: None, + comment: None, + }, + ) + }) + .expect("job should run"); + + let rejected = service + .review_job( + job.id, + GenerationReviewRequest { + reviewer: "reviewer".to_owned(), + decision: GenerationReviewDecision::Rejected, + comment: Some("active review rejected".to_owned()), + }, + ) + .expect("active review rejection should complete"); + + assert_eq!(rejected.status, GenerationJobStatus::Rejected); + assert!( + rejected + .artifacts + .iter() + .filter(|artifact| artifact.artifact_metadata.source_job_id == Some(job.id)) + .all(|artifact| artifact.status == ArtifactStatus::Rejected) + ); + let transaction = lifecycle + .get_transaction( + rejected + .lifecycle_transaction_id + .expect("transaction should exist"), + ) + .expect("transaction should still exist"); + assert_eq!(transaction.status, ModuleTransactionStatus::Rejected); + } + #[test] fn pending_approval_reject_rejects_linked_lifecycle_transaction() { let (service, _audit, lifecycle) = service_with_lifecycle(); diff --git a/04-backend/harness-core/src/viewer_adapter.rs b/04-backend/harness-core/src/viewer_adapter.rs index 663d9b1d..da24cbfc 100644 --- a/04-backend/harness-core/src/viewer_adapter.rs +++ b/04-backend/harness-core/src/viewer_adapter.rs @@ -598,6 +598,113 @@ mod tests { assert_eq!(acked.acknowledged_by.as_deref(), Some("viewer")); } + #[test] + fn ack_rejects_queued_status_and_terminal_regression() { + let audit = Arc::new(ModuleAuditService::new()); + let (generation, _lifecycle) = generation_service(audit); + let artifact_id = generated_artifact_id(&generation); + let service = ViewerCommandService::new(Arc::new(ModuleAuditService::new()), generation); + let command = service + .create_command(ViewerCommandCreateRequest { + adapter: ViewerAdapterHint::ThreeJs, + command: ViewerCommandKind::ZoomTo, + module_id: None, + artifact_id: Some(artifact_id), + element_ids: None, + arguments: Some(json!({ "fit": true })), + actor: None, + }) + .expect("command creates"); + + assert!( + service + .ack_command( + command.id, + ViewerCommandAckRequest { + actor: "viewer".to_owned(), + status: ViewerCommandStatus::Queued, + comment: None, + result: None, + }, + ) + .is_err() + ); + + service + .ack_command( + command.id, + ViewerCommandAckRequest { + actor: "viewer".to_owned(), + status: ViewerCommandStatus::Executed, + comment: None, + result: None, + }, + ) + .expect("first terminal ack should work"); + + assert!( + service + .ack_command( + command.id, + ViewerCommandAckRequest { + actor: "viewer".to_owned(), + status: ViewerCommandStatus::Acknowledged, + comment: Some("regress".to_owned()), + result: None, + }, + ) + .is_err() + ); + } + + #[test] + fn required_viewer_command_kinds_are_accepted_as_contracts() { + let audit = Arc::new(ModuleAuditService::new()); + let (generation, _lifecycle) = generation_service(audit.clone()); + let artifact_id = generated_artifact_id(&generation); + let service = ViewerCommandService::new(audit.clone(), generation); + let command_kinds = [ + ViewerCommandKind::SetColor, + ViewerCommandKind::SetVisible, + ViewerCommandKind::SetOpacity, + ViewerCommandKind::Offset, + ViewerCommandKind::Rotate, + ViewerCommandKind::ZoomTo, + ViewerCommandKind::Snapshot, + ViewerCommandKind::ExportImage, + ViewerCommandKind::Dispose, + ]; + + for command in command_kinds { + let created = service + .create_command(ViewerCommandCreateRequest { + adapter: ViewerAdapterHint::ThreeJs, + command, + module_id: None, + artifact_id: Some(artifact_id), + element_ids: Some(vec!["architoken:demo:001".to_owned()]), + arguments: Some(json!({ "contractOnly": true })), + actor: Some("viewer-contract-test".to_owned()), + }) + .expect("viewer command contract should be accepted"); + assert_eq!(created.command, command); + assert_eq!(created.status, ViewerCommandStatus::Queued); + assert!(created.audit_event_id.is_some()); + } + + let events = audit + .list(&AuditEventQuery { + module_id: Some("digital_twin".to_owned()), + target_type: Some("viewer_command".to_owned()), + target_id: None, + actor: Some("viewer-contract-test".to_owned()), + limit: Some(20), + cursor: None, + }) + .expect("audit list should work"); + assert_eq!(events.items.len(), command_kinds.len()); + } + #[test] fn vendor_adapter_command_is_candidate_only() { let audit = Arc::new(ModuleAuditService::new()); diff --git a/04-backend/openapi.yaml b/04-backend/openapi.yaml index 9b42fcda..7d8aee2e 100644 --- a/04-backend/openapi.yaml +++ b/04-backend/openapi.yaml @@ -2856,6 +2856,30 @@ components: productionCaveats: type: array items: { type: string } + example: + activeModuleIds: [digital_twin, production_manufacturing] + generation: + modes: [model_to_lightweight_scene, ifc_to_3dtiles] + artifactKinds: [lightweight_scene, property_index, element_identity_map] + artifactStatuses: [preview, draft, approved, rejected] + geometryFormats: [ifc, glb, gltf, 3dtiles, pointcloud, spz, vendor_opt] + propertyIndexFormats: [json, sqlite, duckdb, parquet, vendor_db] + viewer: + adapterHints: [threejs, webgpu, 3dtiles, ifc, vendor_optrapid3d] + commandKinds: [load_artifact, set_color, zoom_to, snapshot, dispose] + candidateOnlyAdapterHints: [vendor_optrapid3d] + registry: + skills: true + mcpTools: true + knowledgeSources: true + storage: + providers: [memory] + persistsRealBytes: false + productionReady: false + localImplementationMode: in_memory_preview + productionCaveats: + - No real commercial model APIs are connected. + - Vendor formats remain candidate-only. RuntimeRegistryCapabilities: type: object @@ -3150,6 +3174,25 @@ components: minimum: 0 pageInfo: $ref: "#/components/schemas/PageInfo" + example: + commands: + - id: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f3301" + adapter: threejs + command: set_color + artifactId: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201" + elementIds: [architoken:wall:001] + arguments: { color: "#ff6600" } + status: executed + auditEventId: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f0301" + acknowledgedBy: viewer-dev + acknowledgedAt: "2026-04-30T00:01:00Z" + createdAt: "2026-04-30T00:00:00Z" + updatedAt: "2026-04-30T00:01:00Z" + total: 1 + pageInfo: + limit: 50 + nextCursor: null + hasMore: false ArtifactRef: type: object @@ -3385,6 +3428,53 @@ components: minimum: 0 pageInfo: $ref: "#/components/schemas/PageInfo" + example: + artifacts: + - id: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201" + kind: lightweight_scene + status: approved + objectUri: memory://generation/job/artifact + fileReference: generation://files/018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201 + schemaRef: artifact.lightweight_scene.schema.v1 + version: 1 + hash: memory-checksum-artifact + metadata: { storage: in_memory_stub, modelCalls: 0 } + reference: + artifactId: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201" + artifactKind: lightweight_scene + moduleId: digital_twin + status: approved + name: model_to_lightweight_scene_mock_skill artifact + storageBinding: + artifactRole: geometry_artifact + provider: memory + objectKey: generation/job/lightweight_scene + objectUri: memory://generation/job/lightweight_scene + moduleFileId: null + fileReference: generation://files/018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201 + artifactMetadata: + artifactRole: geometry_artifact + geometryFormat: gltf + propertyIndexFormat: null + elementIdNamespace: null + viewerAdapterHint: threejs + sourceModelId: source-model-001 + schemaRef: artifact.lightweight_scene.schema.v1 + checksum: memory-checksum-artifact + mimeType: application/vnd.architoken.lightweight-scene+json + sizeBytes: 42 + owner: planner + sourceJobId: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1001" + createdByJobId: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1001" + approvalStatus: approved + auditEventId: null + createdAt: "2026-04-30T00:00:00Z" + versions: [] + total: 1 + pageInfo: + limit: 50 + nextCursor: null + hasMore: false GenerationInput: type: object diff --git a/04-backend/scripts/smoke-registries.sh b/04-backend/scripts/smoke-registries.sh index 1439f612..bbd275aa 100755 --- a/04-backend/scripts/smoke-registries.sh +++ b/04-backend/scripts/smoke-registries.sh @@ -59,7 +59,7 @@ blocked_status="$( \"fixtures\": [] }" )" -test "${blocked_status}" = "500" +test "${blocked_status}" = "400" grep -q 'license violation' /tmp/architoken-smoke-blocked-skill.json tool_id="smoke-tool-$(date +%s)"