diff --git a/README.md b/README.md index e2c88c5..ad2b19f 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,14 @@ demonstrates. `inherited` may reuse an Evidence artifact that covers the clause. Contract without `evidence_mode` keeps the original all-direct behavior, so a framework update never weakens an existing Contract silently. +A Contract's `change_id` identifies its originating Change, not its applicability. +Snapshots retain Contracts across Changes; action Context selects the applicable +clauses by `applies_to` or an explicit clause reference in `verification_scope`. +Revalidation therefore includes the selected clause's text, authority reference, +evidence mode, and source digests even when another Change created the Contract. +When upgrading from Change-filtered Snapshots, an existing Impact Assessment may +need to be refreshed because its Contract index now includes those Contracts. + **They grow.** A question answered once does not come back - the next change that touches deletion finds the clause and resolves against it instead of asking again. An incident becomes a diff --git a/docs/concepts.ja.md b/docs/concepts.ja.md index e50b250..a9e4368 100644 --- a/docs/concepts.ja.md +++ b/docs/concepts.ja.md @@ -99,6 +99,9 @@ Contractまたは条項の`evidence_mode`で、検証に掛ける費用を選べ `adf next`は、各作業に必要な文脈だけを組み立てます。影響評価には、リポジトリ、Contract、Decisionの索引と、過去の影響評価を最大3件まで渡します。評価が確定した後の実装には、その評価結果と、対象に合う規範やコードだけを渡します。後続の作業がリポジトリ全体を調べ直す必要はありません。 +Contractの`change_id`は作成元のChangeを表し、適用範囲を限定しません。Snapshotには別Changeで作成したContractも保持し、担当へ渡す条項は`applies_to`や`verification_scope`の明示指定で選びます。再検証では、別Changeの条項も本文、権威参照、証拠方式、参照元のダイジェストとともに渡します。 +従来のChangeで絞り込む版から更新すると、Contract索引が変わるため、既存の影響評価の更新を求められる場合があります。 + 影響評価が済んでいない間、`adf next`と`adf explain`は、リポジトリ全体のContract検証状態を計算する前に影響評価を次の作業として選びます。この最初の段階では、無関係なChangeのResultとEvidenceを読み込みません。 Contract検証状態が必要な場合は、Evidenceと検証Resultの永続索引を`.adf/cache/runtime/`に作ります。変更のない記録はGitのBlob ID、変更中または未追跡の記録は内容ハッシュで識別します。元の記録が変わった場合だけJSONを読み直し、条項ごとに全Resultを繰り返し検索しません。索引が壊れている場合は正本から作り直します。Gitがキャッシュ先を無視すると確認できた場合だけ、索引をファイルへ保存します。 diff --git a/skill-src/adf-builder/SKILL.md b/skill-src/adf-builder/SKILL.md index 4dbc591..709541c 100644 --- a/skill-src/adf-builder/SKILL.md +++ b/skill-src/adf-builder/SKILL.md @@ -46,6 +46,11 @@ This is the most common way the control plane gets bypassed. Resist it. ## `record-evidence` +For `contract-clause-revalidated`, use the corresponding `contract_clauses` entry +whose `selected_for` contains the requirement's instance key. Contracts created by +another Change remain applicable; `change_id` identifies their origin. Use the +issued clause text, scope, evidence mode, authority reference, and source digests. + Show that each requirement instance in the action actually holds. For each one, call `adf_add_evidence` with what you observed, then submit an `outcomes` entry: diff --git a/src/contract_health.rs b/src/contract_health.rs index d803b7f..d2eecc6 100644 --- a/src/contract_health.rs +++ b/src/contract_health.rs @@ -678,6 +678,91 @@ mod tests { ); } + #[test] + fn explicit_cross_change_verification_distributes_context_and_accepts_evidence() { + use crate::application::InMemoryApplication; + use crate::submission::ResultSubmission; + + let schemas = registry(); + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/fixtures/db-sqs"); + let rules = + serde_yaml::from_str(&std::fs::read_to_string(fixture.join("rules.yaml")).unwrap()) + .unwrap(); + let lock = serde_yaml::from_str( + &std::fs::read_to_string(fixture.join("framework-lock.yaml")).unwrap(), + ) + .unwrap(); + for status in ["unverified", "stale", "failed"] { + let mut project = project_with_evidence( + if status == "failed" { + "failed" + } else { + "passed" + }, + if status == "failed" { + "unsatisfied" + } else { + "satisfied" + }, + ); + project["contracts"][0]["change_id"] = json!("change.test"); + // Explicit selection must work even without a subject overlap. + project["contracts"][0]["applies_to"] = json!([]); + let digest = digest_value(&project["contracts"][0]).unwrap(); + project["results"][0]["input_refs"]["contract.test"] = json!(digest); + project["results"][0]["freshness_refs"]["contract.test"] = json!(digest); + project["results"][0]["payload"]["outcomes"][0]["input_refs"]["contract.test"] = + json!(digest); + project["results"][0]["payload"]["outcomes"][0]["freshness_refs"]["contract.test"] = + json!(digest); + if status == "unverified" { + project["results"] = json!([]); + project["evidence"] = json!([]); + } else if status == "stale" { + project["repository"]["artifacts"][0]["digest"] = + json!(format!("sha256:{}", "d".repeat(64))); + } + project["repository"]["phase"] = json!("post-build"); + project["repository"]["coverage"] = json!({"status": "complete", "scope": "declared-artifacts", "analyzed_refs": [], "gaps": []}); + project["changes"].as_array_mut().unwrap().push(json!({ + "schema_version": "1", "id": "change.verify", "title": "Verify an existing clause", "intent": "Verify stable output", + "verification_scope": ["contract.test#stable-output"] + })); + let mut app = InMemoryApplication::new(project, &rules, &lock, &schemas).unwrap(); + assert_eq!(app.contract_health().unwrap().clauses[0].status, status); + let response = app.next("change.verify").unwrap(); + assert_eq!(response.decision.state, "needs-evidence"); + let context = response.context.unwrap(); + let clause = &context.contract_clauses[0]; + assert_eq!(clause.clause_ref, "contract.test#stable-output"); + assert_eq!(clause.text, "output remains stable"); + assert!(clause.applies_to.is_empty()); + let instance_key = "contract-clause-revalidated|contract.test#stable-output"; + assert_eq!(clause.selected_for, [instance_key]); + assert_eq!( + context.instance_source_digests[instance_key]["contract.test"], + digest + ); + app.add_evidence(json!({ + "schema_version": "1", "id": "evidence.verify", "change_id": "change.verify", + "requirement_instances": [instance_key], "contract_clause_refs": [clause.clause_ref], + "git_revision": "revision-2", "method": "output regression test", "outcome": "passed", "summary": "Stable output verified", + "artifact": {"uri": "artifact://test/output", "digest": format!("sha256:{}", "f".repeat(64)), "exit_code": 0} + })).unwrap(); + let refreshed = app.next("change.verify").unwrap(); + let context = refreshed.context.unwrap(); + let action = refreshed.decision.action.unwrap(); + let instance = &action.requirement_instances[0]; + let result = app.submit(&ResultSubmission { + change_id: "change.verify".to_owned(), action_id: action.id, + context_digest: context.digest, role: action.role, result_schema: action.expected_result_schema, + payload: json!({"outcomes": [{"instance_key": instance_key, "definition_digest": instance.definition_digest, "status": "satisfied", "summary": "Stable output verified", "basis_refs": ["contract.test", "evidence.verify"]}]}), + output_refs: vec!["evidence.verify".to_owned()], execution: None, + }).unwrap(); + assert_eq!(result.decision.state, "ready-to-merge"); + } + } + #[test] fn reports_verified_stale_and_failed_without_mutating_contracts() { let verified = build_contract_health_report( diff --git a/src/contract_scope.rs b/src/contract_scope.rs index f4a1abf..eec79a0 100644 --- a/src/contract_scope.rs +++ b/src/contract_scope.rs @@ -69,9 +69,15 @@ pub(crate) fn contract_matches_subjects(contract: &Value, subjects: &BTreeSet<&s .as_array() .filter(|clauses| !clauses.is_empty()) { - return clauses - .iter() - .any(|clause| clause_matches_subjects(contract, clause, subjects)); + return clauses.iter().any(|clause| { + clause_matches_subjects(contract, clause, subjects) + || contract["id"] + .as_str() + .zip(clause["id"].as_str()) + .is_some_and(|(contract_id, clause_id)| { + subjects.contains(format!("{contract_id}#{clause_id}").as_str()) + }) + }); } contract["applies_to"].as_array().is_some_and(|targets| { targets diff --git a/src/project.rs b/src/project.rs index 497efb0..0646fa4 100644 --- a/src/project.rs +++ b/src/project.rs @@ -58,7 +58,11 @@ pub fn build_project_snapshot( .get(change_id) .cloned() .ok_or_else(|| ProjectSnapshotError::new(format!("unknown change: {change_id}")))?; - let contracts = records_for_change(project, "contracts", change_id)?; + // Contracts are current project norms; change_id records their origin, not + // their applicability. Keep the same universe used by Contract Health and + // let Context selectors choose the clauses needed by each requirement. + let mut contracts = record_array(project, "contracts")?.to_vec(); + contracts.sort_by(|left, right| left["id"].as_str().cmp(&right["id"].as_str())); let decisions = decision_records_for_change(project, change_id)?; let results = records_for_change(project, "results", change_id)?; let evidence = records_for_change(project, "evidence", change_id)?; diff --git a/testdata/golden/v1/project-snapshot.json b/testdata/golden/v1/project-snapshot.json index 774dc73..adac0ed 100644 --- a/testdata/golden/v1/project-snapshot.json +++ b/testdata/golden/v1/project-snapshot.json @@ -94,13 +94,14 @@ "reverse_record_collections": true }, { - "case_id": "accepted-decisions-remain-visible-across-changes", + "case_id": "contracts-and-accepted-decisions-remain-visible-across-changes", "change_id": "change.place-order", "append_unrelated_records": true, "expected": { "change_id": "change.place-order", "contract_ids": [ - "contract.order-lifecycle" + "contract.order-lifecycle", + "contract.unrelated" ], "decision_ids": [ "decision.order-model", @@ -111,14 +112,16 @@ "repository_phase": "pre-build", "artifact_digests": { "change.place-order": "sha256:c4bb2ccce8564e5ad8db5678d2a7a80d7898a6399ac81591284836b8c1bb42a6", + "code.order-events-publisher": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "code.place-order-handler": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "contract.order-lifecycle": "sha256:9f6784558ea69ecb56694aa107f4fed8e17f727bbc2b6699cf95a7b0dac602a2", "contract.order-lifecycle#orders-source-of-truth": "sha256:24b95af486da75993c4b1788f7628d69ea588cd97bb0344aa054fdb4da1794a9", + "contract.unrelated": "sha256:5f44bd46933cf99596a69e16bab374b7d26f1f531850f88674241c82057a4735", + "contract.unrelated#unrelated-rule": "sha256:e4f93a742a60a2f84eab0a5df5a62b63b431f3e60f4b529f306e7505d70fadb5", "decision.order-model": "sha256:bec113df76236953ad3781f9c7069c51e7e4d5692db6de33cbb9dac91196bed3", - "decision.unrelated": "sha256:9be7d0d9979bb3fde5c321bb3196dfbcc591f089c51fe92b5b1eccb19e63437b", - "code.place-order-handler": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "code.order-events-publisher": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + "decision.unrelated": "sha256:9be7d0d9979bb3fde5c321bb3196dfbcc591f089c51fe92b5b1eccb19e63437b" }, - "digest": "sha256:a933c0ce3ca4144251042119831f963305529f38ecd9e14a05fe52ac0340e67a" + "digest": "sha256:ce439280584150d2c8a581885c902ae49474828d07d5ad1eb475dfc82b7f6661" } }, { @@ -128,6 +131,7 @@ "expected": { "change_id": "change.retry-order-events", "contract_ids": [ + "contract.order-lifecycle", "contract.shared-order-submission" ], "decision_ids": [ @@ -139,14 +143,16 @@ "repository_phase": "pre-build", "artifact_digests": { "change.retry-order-events": "sha256:8f832211a911111b183b32fd0e7860abae6a19dd18bbf0888deef0f4b4780074", + "code.order-events-publisher": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "code.place-order-handler": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "contract.order-lifecycle": "sha256:9f6784558ea69ecb56694aa107f4fed8e17f727bbc2b6699cf95a7b0dac602a2", + "contract.order-lifecycle#orders-source-of-truth": "sha256:24b95af486da75993c4b1788f7628d69ea588cd97bb0344aa054fdb4da1794a9", "contract.shared-order-submission": "sha256:a399c87f66bb65966ace25c1006c9c534e47fb1473807e050c17c7f8bce67402", "contract.shared-order-submission#submission-result": "sha256:94dcaf2a94b5b9bf12ff174be3d390b312c1e1fac9768da80774337d0c72da26", "decision.order-model": "sha256:bec113df76236953ad3781f9c7069c51e7e4d5692db6de33cbb9dac91196bed3", - "decision.shared-order-submission": "sha256:d9fd5125b28863d8893f6658b4431a58210d676dfa9ec778ef6917e47b36a329", - "code.place-order-handler": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "code.order-events-publisher": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + "decision.shared-order-submission": "sha256:d9fd5125b28863d8893f6658b4431a58210d676dfa9ec778ef6917e47b36a329" }, - "digest": "sha256:7429588807261aada265b36fe0db5120c8ec535650bd4c257d8c6f9dfd16a0da" + "digest": "sha256:a4125c804a6a0b1a86b273326ecbfe16ad290533559beec95a95cc3f68f9e706" } } ], diff --git a/tests/security_lifecycle.rs b/tests/security_lifecycle.rs index e62db60..ae94a58 100644 --- a/tests/security_lifecycle.rs +++ b/tests/security_lifecycle.rs @@ -10,6 +10,15 @@ use std::process::{Command, Output}; #[test] fn security_bindings_drive_the_reviewed_lifecycle() { + security_lifecycle(None); +} + +#[test] +fn cross_change_contracts_drive_the_reviewed_lifecycle() { + security_lifecycle(Some("change.previous")); +} + +fn security_lifecycle(contract_origin: Option<&str>) { let manifest_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let fixture_root = manifest_root.join("testdata/fixtures/security-lifecycle"); let scenario = read_yaml(fixture_root.join("scenario.yaml")); @@ -33,6 +42,18 @@ fn security_bindings_drive_the_reviewed_lifecycle() { ); } project["repository"] = pre_build_observation; + if let Some(origin) = contract_origin { + for contract in project["contracts"].as_array_mut().unwrap() { + contract["change_id"] = json!(origin); + } + } + project["contracts"].as_array_mut().unwrap().push(json!({ + "schema_version": "1", + "id": "contract.unrelated", + "change_id": "change.other", + "applies_to": ["operation.unrelated"], + "clauses": [{"id": "unrelated", "text": "Unrelated operation preserves its output."}] + })); let change_id = scenario["change_id"].as_str().unwrap(); let mut application = @@ -150,6 +171,7 @@ fn security_bindings_drive_the_reviewed_lifecycle() { .unwrap(); assert_action(&response, &scenario["after_build"][1]); + assert_revalidation_context(&application, change_id, &response); let revalidation_instances = response .decision .action @@ -182,13 +204,9 @@ fn security_bindings_drive_the_reviewed_lifecycle() { } })) .unwrap(); - let mut revalidation_payload = satisfied_payload(&response); - for outcome in revalidation_payload["outcomes"].as_array_mut().unwrap() { - outcome["basis_refs"] - .as_array_mut() - .unwrap() - .push(Value::String(revalidation_evidence_id.to_owned())); - } + response = application.next(change_id).unwrap(); + assert_revalidation_context(&application, change_id, &response); + let revalidation_payload = satisfied_payload(&response); response = application .submit(&submission( &response, @@ -212,6 +230,72 @@ fn security_bindings_drive_the_reviewed_lifecycle() { assert!(response.context.is_none()); } +fn assert_revalidation_context( + application: &InMemoryApplication<'_>, + change_id: &str, + response: &ApplicationResponse, +) { + let snapshot = application.snapshot(change_id).unwrap(); + let context = response.context.as_ref().unwrap(); + assert!( + !context + .source_refs + .iter() + .any(|reference| reference.starts_with("contract.unrelated")) + ); + assert!( + !context + .contract_clauses + .iter() + .any(|clause| clause.contract_id == "contract.unrelated") + ); + for instance in &response + .decision + .action + .as_ref() + .unwrap() + .requirement_instances + { + let reference = instance.instance_key.split_once('|').unwrap().1; + let clause = context + .contract_clauses + .iter() + .find(|clause| clause.clause_ref == reference) + .unwrap(); + assert!(clause.selected_for.contains(&instance.instance_key)); + assert_eq!(clause.digest, snapshot.artifact_digests[reference]); + assert!(!clause.text.is_empty()); + assert_eq!(clause.evidence_mode, "direct"); + assert_eq!( + clause.authority_ref.as_deref(), + Some("decision.customer-security-boundary") + ); + let contract = snapshot + .contracts + .iter() + .find(|contract| contract["id"] == clause.contract_id) + .unwrap(); + assert_eq!( + serde_json::to_value(&clause.applies_to).unwrap(), + contract["clauses"] + .as_array() + .unwrap() + .iter() + .find(|item| item["id"] == clause.clause_id) + .unwrap()["applies_to"] + ); + let sources = &context.instance_source_digests[&instance.instance_key]; + assert_eq!( + sources[&clause.clause_ref], + snapshot.artifact_digests[&clause.clause_ref] + ); + assert_eq!( + context.source_digests[&clause.clause_ref], + sources[&clause.clause_ref] + ); + } +} + fn assert_action(response: &ApplicationResponse, expected: &Value) { assert_eq!( response.decision.state, expected["state"],