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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ assessment is accepted, implementation receives that Result, matching
governance, and matching artifacts. This makes the assessment a reusable input
instead of asking later actions to rediscover the same scope.

While impact assessment is still pending, `next` and `explain` select that
action before deriving repository-wide Contract health. Unrelated Result and
Evidence history is not loaded for this first step.

Each action also carries advisory execution guidance. Impact assessment
normally recommends an economy model, while challenge recommends a
high-accuracy model. The listed escalation conditions tell an orchestrator when
Expand Down
3 changes: 2 additions & 1 deletion docs/MCP-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ Action IDはAction本体のdigest、Context digestはその生成元入力のdig

- 再評価が同じActionを返すなら、そのActionは今も現在のものなので受理する。再起動前に行った作業はやり直さない。
- 再評価が別のActionを返すなら、`ACTION_NOT_CURRENT`で拒否し、現在のAction IDとContext digestを示す。Agentは`adf_next`から現在のActionに対してやり直す。
- 同じActionとContextに対するResultが既にあるなら、二重提出として冪等に再生し、Resultを二重に書かない。内容が違えば`WRITE_CONFLICT`にする。
- 同じActionとContextに対する同じResultが既にあるなら、二重提出として冪等に再生し、Resultを二重に書かない。
- 既存Resultを評価した後も同じActionとContextが現在の作業として再発行される場合に限り、異なるResultを訂正版として受理する。保存時に既存Result IDを照合し、並行変更があれば`WRITE_CONFLICT`にする。完了または別Actionへ遷移したResultは訂正できない。
- 既に書いたContract、Decision、Evidence、コードは削除やrollbackをせず、現在入力として再評価する。

Generated Contextを正本化しない方針は変えません。受理の根拠はmemoryではなく、正本を再評価した結果との一致です。derived cacheのreadをAction認証へ流用してはいけません。
Expand Down
2 changes: 2 additions & 0 deletions docs/concepts.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ Contractまたは条項の`evidence_mode`で、検証に掛ける費用を選べ

`adf next`は、各作業に必要な文脈だけを組み立てます。影響評価には、リポジトリ、Contract、Decisionの索引と、過去の影響評価を最大3件まで渡します。評価が確定した後の実装には、その評価結果と、対象に合う規範やコードだけを渡します。後続の作業がリポジトリ全体を調べ直す必要はありません。

影響評価が済んでいない間、`adf next`と`adf explain`は、リポジトリ全体のContract検証状態を計算する前に影響評価を次の作業として選びます。この最初の段階では、無関係なChangeのResultとEvidenceを読み込みません。

各作業には、実行環境へ向けたモデルの推奨も含まれます。影響評価には通常、軽量なモデルを推奨します。ただし、影響なしと結論付ける場合、根拠が矛盾する場合、セキュリティ、プライバシー、決済、元に戻せないデータ変更の可能性がある場合は、精度の高いモデルへの切り替えを勧めます。ADF自体はモデルを選ばず、LLMも実行しません。

実行環境がすでに把握している処理時間、モデル名、入出力Token数、ツール呼び出し数、再試行回数は、`adf_submit`で任意に記録できます。外部Runnerは`adf_begin_execution`と`adf_complete_execution`を使い、Result提出後に確定したToken数や、失敗・中断した実行も追記できます。外部実行では、キャッシュ作成Token、キャッシュ読取Token、推論Token、実行環境が報告した米ドル費用も記録できます。この実行RecordはADFの状態、Result ID、鮮度、Evidence検証には影響しません。同じResultに提出時の計測値とRunnerの完了Recordがある場合は、Runnerの値だけを集計します。
Expand Down
94 changes: 82 additions & 12 deletions src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::contract_health::{ContractHealthReport, build_contract_health_report}
use crate::detection::detect_typed_facts_with_registry;
use crate::explain::{ExplainReport, ExplanationBuilder};
use crate::framework_lock::{FrameworkLock, validate_framework_lock};
use crate::kernel::{KernelDecision, ProjectSnapshot, ThinKernel};
use crate::kernel::{KernelDecision, ProjectSnapshot, ThinKernel, impact_assessment_pending};
use crate::project::build_project_snapshot;
use crate::rules::{RuleIndex, compile_rule_index_with_registry};
use crate::schema::SchemaRegistry;
Expand Down Expand Up @@ -40,6 +40,11 @@ pub trait ProjectStore {
fn snapshot(&self, change_id: &str) -> Result<ProjectSnapshot, ProjectStoreError>;
fn contract_health(&self) -> Result<ContractHealthReport, ProjectStoreError>;
fn append_result(&mut self, result: &Value) -> Result<(), ProjectStoreError>;
fn replace_result(
&mut self,
result: &Value,
expected_result_id: &str,
) -> Result<(), ProjectStoreError>;
fn add_evidence(&mut self, evidence: &Value) -> Result<(), ProjectStoreError>;
fn upsert_decision(
&mut self,
Expand Down Expand Up @@ -128,21 +133,26 @@ impl<'a, Store: ProjectStore> Application<'a, Store> {
&self.signal_registry,
)
.map_err(|error| application_error(error.to_string()))?;
let contract_health = self
.store
.contract_health()
.map_err(|error| application_error(error.to_string()))?;
let contract_health = if impact_assessment_pending(&snapshot) {
None
} else {
Some(
self.store
.contract_health()
.map_err(|error| application_error(error.to_string()))?,
)
};
let decision = ThinKernel.evaluate_with_health(
&snapshot,
&self.rule_index,
&detection,
Some(&contract_health),
contract_health.as_ref(),
);
let context = ContextCompiler.compile_with_health(
&decision,
&snapshot,
&detection,
Some(&contract_health),
contract_health.as_ref(),
);
if let Some(context) = &context {
self.issued.insert(
Expand Down Expand Up @@ -213,6 +223,32 @@ impl<'a, Store: ProjectStore> Application<'a, Store> {
Ok(ApplicationSubmission { result, response })
}

/// Replace the Result for an Action that the current Project still issues.
///
/// This is deliberately separate from ordinary submission: callers must
/// prove that the existing Result did not complete or supersede the Action,
/// and the Store compares its ID again when writing so concurrent changes
/// cannot be lost.
pub(crate) fn correct_issued_with_snapshot(
&mut self,
context: &GeneratedContext,
submission: &ResultSubmission,
snapshot: &ProjectSnapshot,
expected_result_id: &str,
) -> Result<ApplicationSubmission, ApplicationError> {
let result = prepare_result(context, snapshot, submission, self.schema_registry)
.map_err(|error| application_error(error.to_string()))?;
self.store
.replace_result(&result, expected_result_id)
.map_err(|error| application_error(error.to_string()))?;
self.issued.remove(&(
submission.action_id.clone(),
submission.context_digest.clone(),
));
let response = self.next(&submission.change_id)?;
Ok(ApplicationSubmission { result, response })
}

/// Recompute the current decision and its trace without issuing an Action.
pub fn explain(&self, change_id: &str) -> Result<ExplainReport, ApplicationError> {
let snapshot = self.snapshot(change_id)?;
Expand All @@ -227,15 +263,20 @@ impl<'a, Store: ProjectStore> Application<'a, Store> {
&self.signal_registry,
)
.map_err(|error| application_error(error.to_string()))?;
let contract_health = self
.store
.contract_health()
.map_err(|error| application_error(error.to_string()))?;
let contract_health = if impact_assessment_pending(&snapshot) {
None
} else {
Some(
self.store
.contract_health()
.map_err(|error| application_error(error.to_string()))?,
)
};
let decision = ThinKernel.evaluate_with_health(
&snapshot,
&self.rule_index,
&detection,
Some(&contract_health),
contract_health.as_ref(),
);
Ok(ExplanationBuilder.build(&snapshot, &self.rule_index, &detection, &decision))
}
Expand Down Expand Up @@ -393,6 +434,35 @@ impl ProjectStore for InMemoryProjectStore<'_> {
Ok(())
}

fn replace_result(
&mut self,
result: &Value,
expected_result_id: &str,
) -> Result<(), ProjectStoreError> {
self.schema_registry
.validate("result", result)
.map_err(|error| project_store_error(error.to_string()))?;
let action_id = result["action_id"]
.as_str()
.ok_or_else(|| project_store_error("Result action_id must be a string"))?;
let context_digest = result["context_digest"]
.as_str()
.ok_or_else(|| project_store_error("Result context_digest must be a string"))?;
let results = self.record_collection_mut("results")?;
let existing = results
.iter_mut()
.find(|candidate| {
candidate["action_id"].as_str() == Some(action_id)
&& candidate["context_digest"].as_str() == Some(context_digest)
})
.ok_or_else(|| project_store_error("Result to replace does not exist"))?;
if existing["id"].as_str() != Some(expected_result_id) {
return Err(project_store_error("Result changed before correction"));
}
*existing = result.clone();
Ok(())
}

fn add_evidence(&mut self, evidence: &Value) -> Result<(), ProjectStoreError> {
self.schema_registry
.validate("evidence", evidence)
Expand Down
31 changes: 31 additions & 0 deletions src/filesystem_project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,28 @@ impl<'a> FileProjectStore<'a> {
self.write_new(&path, result, FileFormat::Json)
}

pub fn replace_result(
&mut self,
result: &Value,
expected_result_id: &str,
) -> Result<(), FileProjectError> {
self.schema_registry
.validate("result", result)
.map_err(|error| file_error(error.to_string()))?;
let change_id = safe_id(required_string(result, "change_id", "Result")?)?;
self.change_path(change_id)?;
let path = self
.change_root
.join(change_id)
.join("results")
.join(result_filename(result)?);
let existing = read_json(&path)?;
if existing["id"].as_str() != Some(expected_result_id) {
return Err(file_error("Result changed before correction"));
}
self.write_atomic(&path, result, FileFormat::Json)
}

pub fn upsert_contract(
&mut self,
contract: &Value,
Expand Down Expand Up @@ -1047,6 +1069,15 @@ impl ProjectStore for FileProjectStore<'_> {
.map_err(|error| ProjectStoreError::new(error.to_string()))
}

fn replace_result(
&mut self,
result: &Value,
expected_result_id: &str,
) -> Result<(), ProjectStoreError> {
FileProjectStore::replace_result(self, result, expected_result_id)
.map_err(|error| ProjectStoreError::new(error.to_string()))
}

fn add_evidence(&mut self, evidence: &Value) -> Result<(), ProjectStoreError> {
FileProjectStore::add_evidence(self, evidence)
.map_err(|error| ProjectStoreError::new(error.to_string()))
Expand Down
8 changes: 6 additions & 2 deletions src/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@ impl ThinKernel {
contract_health: Option<&ContractHealthReport>,
) -> KernelDecision {
let assessment = fresh_impact_assessment(snapshot);
let assessment_required = snapshot.change["impact_assessment"].as_str() == Some("required");
if assessment_required && assessment.is_none() {
if impact_assessment_pending(snapshot) {
let previous = current_impact_assessments(snapshot);
let action = make_action(
snapshot,
Expand Down Expand Up @@ -502,6 +501,11 @@ impl ThinKernel {
}
}

pub(crate) fn impact_assessment_pending(snapshot: &ProjectSnapshot) -> bool {
snapshot.change["impact_assessment"].as_str() == Some("required")
&& fresh_impact_assessment(snapshot).is_none()
}

fn current_impact_assessments(snapshot: &ProjectSnapshot) -> Vec<String> {
snapshot
.results
Expand Down
94 changes: 91 additions & 3 deletions src/project_application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,20 @@ impl ProjectApplicationService {
let snapshot = application
.snapshot(&key.change_id)
.map_err(application_error)?;
let existing_result = snapshot
.results
.iter()
.find(|result| result_for_action(result, key));
if let Some(result) = existing_result
&& submitted_payload_matches(&result["payload"], &payload)
&& result["output_refs"] == json!(output_refs)
{
let result_id = required_record_id(result, "Result")?.to_owned();
let response = application
.next(&key.change_id)
.map_err(application_error)?;
return Ok(self.completed_response(key, result_id, response, &application));
}
validate_output_refs(&entry, &output_refs, &snapshot)?;
assert_framework_identity(&entry, &application)?;
let role = required_context_string(&entry.context, &["action", "role"])?;
Expand All @@ -281,9 +295,21 @@ impl ProjectApplicationService {
output_refs,
execution,
};
let ApplicationSubmission { result, response } = application
.submit_issued_with_snapshot(&entry.context, &submission, &snapshot)
.map_err(application_error)?;
let ApplicationSubmission { result, response } = if let Some(existing) = existing_result {
let expected_result_id = required_record_id(existing, "Result")?;
application
.correct_issued_with_snapshot(
&entry.context,
&submission,
&snapshot,
expected_result_id,
)
.map_err(application_error)?
} else {
application
.submit_issued_with_snapshot(&entry.context, &submission, &snapshot)
.map_err(application_error)?
};
let rule_index_digest = application.rule_index_digest().to_owned();
let framework_lock_digest = application.framework_lock_digest().to_owned();
let result_id = result["id"]
Expand Down Expand Up @@ -314,6 +340,37 @@ impl ProjectApplicationService {
})
}

fn completed_response(
&mut self,
key: &IssuedActionKey,
result_id: String,
response: crate::application::ApplicationResponse,
application: &crate::application::Application<
'_,
crate::filesystem_project::FileProjectStore<'_>,
>,
) -> SubmitServiceResponse {
let next_response = next_response_value(&key.change_id, &response);
let next_issued = issued_entry(
&key.change_id,
&response,
application.rule_index_digest(),
application.framework_lock_digest(),
);
self.issued.remove(key);
let issued_action = next_issued.map(|(next_key, next_entry)| {
self.issued.insert(next_key.clone(), next_entry);
next_key
});
SubmitServiceResponse {
schema_version: MCP_APPLICATION_PROTOCOL_VERSION.to_owned(),
result_id,
already_completed: true,
next_response,
issued_action,
}
}

/// Replays a submission whose Result is already stored, or `None` when this
/// Action and Context produced no Result yet.
fn replay_submission(
Expand Down Expand Up @@ -686,6 +743,11 @@ fn submitted_payload_matches(stored: &Value, submitted: &Value) -> bool {
}
}

fn result_for_action(result: &Value, key: &IssuedActionKey) -> bool {
result["action_id"].as_str() == Some(key.action_id.as_str())
&& result["context_digest"].as_str() == Some(key.context_digest.as_str())
}

fn issued_entry(
change_id: &str,
response: &ApplicationResponse,
Expand Down Expand Up @@ -1033,6 +1095,32 @@ mod tests {
validate_output_refs(&entry, &["evidence.persisted".to_owned()], &snapshot).unwrap();
}

#[test]
fn different_submission_is_correctable_only_while_the_same_action_is_current() {
let key = IssuedActionKey {
change_id: "change.place-order".to_owned(),
action_id: "action.record-evidence".to_owned(),
context_digest: format!("sha256:{}", "1".repeat(64)),
};
let existing = json!({
"action_id": key.action_id,
"context_digest": key.context_digest,
"payload": {"outcomes": [{"status": "inconclusive"}]},
"output_refs": ["evidence.old"]
});
assert!(result_for_action(&existing, &key));
assert!(!submitted_payload_matches(
&existing["payload"],
&json!({"outcomes": [{"status": "satisfied"}]})
));

let superseded = IssuedActionKey {
action_id: "action.challenge".to_owned(),
..key.clone()
};
assert!(!result_for_action(&existing, &superseded));
}

#[test]
fn evidence_inputs_are_derived_from_the_issued_requirement_instances() {
let digest = |character: char| format!("sha256:{}", character.to_string().repeat(64));
Expand Down
Loading
Loading