Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/concepts.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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がキャッシュ先を無視すると確認できた場合だけ、索引をファイルへ保存します。
Expand Down
5 changes: 5 additions & 0 deletions skill-src/adf-builder/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
85 changes: 85 additions & 0 deletions src/contract_health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 9 additions & 3 deletions src/contract_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
26 changes: 16 additions & 10 deletions testdata/golden/v1/project-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
},
{
Expand All @@ -128,6 +131,7 @@
"expected": {
"change_id": "change.retry-order-events",
"contract_ids": [
"contract.order-lifecycle",
"contract.shared-order-submission"
],
"decision_ids": [
Expand All @@ -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"
}
}
],
Expand Down
98 changes: 91 additions & 7 deletions tests/security_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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"],
Expand Down
Loading