diff --git a/apps/decodex/fixtures/decision_contract/research_x_latent_contract.json b/apps/decodex/fixtures/decision_contract/research_x_latent_contract.json new file mode 100644 index 000000000..34a6ced11 --- /dev/null +++ b/apps/decodex/fixtures/decision_contract/research_x_latent_contract.json @@ -0,0 +1,96 @@ +{ + "schema": "decodex.decision_contract/1", + "record_version": 1, + "contract_id": "research-x-loop-contract", + "status": "draft_latent", + "source_intent": { + "summary": "Research X and shape follow-up work.", + "user_utterance": "research X", + "source_issue_identifier": "XY-852" + }, + "research_provenance": [ + { + "kind": "spec", + "reference": "docs/spec/loop-runtime.md", + "summary": "Research output remains latent until accepted or promoted." + }, + { + "kind": "conversation", + "reference": "codex-thread:research-x", + "summary": "Natural-language research request produced a candidate decision package." + } + ], + "research_evidence": [ + { + "claim": "A research result can carry enough detail for later issue shaping.", + "support": "The contract keeps objectives, constraints, assumptions, objections, and stop conditions together before promotion.", + "source_ref": "docs/spec/loop-runtime.md" + }, + { + "claim": "A latent contract is not execution authority.", + "support": "The status remains draft_latent and promotion metadata is absent.", + "source_ref": "docs/spec/loop-runtime.md" + } + ], + "accepted_authority": { + "accepted_objectives": [ + "Define the runtime-facing Decision Contract model.", + "Preserve private runtime evidence as the source of truth while exposing only sparse public projection references." + ], + "non_goals": [ + "Do not auto-execute research output.", + "Do not expose graph mechanics as the user workflow." + ], + "constraints": [ + "Store contract payloads in local runtime SQLite.", + "Keep Linear as a coarse public mirror." + ], + "assumptions": [ + "Future issue-shaping code can consume the accepted contract without asking the user to restate all details." + ], + "objections": [ + "Promotion must fail when required acceptance criteria are missing." + ], + "stop_conditions": [ + "Request human decision when unresolved direction affects acceptance criteria.", + "Reject or supersede a contract when later evidence invalidates it." + ] + }, + "execution_readiness": { + "summary": "Ready for issue shaping after acceptance; latent status prevents queueing until promotion.", + "ready_for_issue_shaping": true, + "missing_decisions": [], + "validation_expectations": [ + "Serialization round-trip passes.", + "Promotion transition records acceptance metadata." + ], + "risk_notes": [ + "Do not infer execution authority from research provenance alone." + ] + }, + "links": { + "generated_issue_ids": [], + "generated_issue_identifiers": [], + "execution_program_node_ids": [] + }, + "evidence_boundary": { + "private_evidence_refs": [ + { + "project_id": "decodex", + "issue_id": "XY-852", + "run_id": "research-x-run", + "attempt_number": 1, + "record_id": 1, + "event_type": "research_result" + } + ], + "public_projection_refs": [ + { + "surface": "linear", + "reference": "XY-852", + "summary": "Public issue briefing names the research-to-execution handoff without containing private loop evidence." + } + ], + "public_summary": "Latent research-to-execution contract awaiting acceptance." + } +} diff --git a/apps/decodex/src/lib.rs b/apps/decodex/src/lib.rs index 1be764da4..8ef19d56a 100644 --- a/apps/decodex/src/lib.rs +++ b/apps/decodex/src/lib.rs @@ -14,6 +14,7 @@ mod commit_message; mod default_branch_sync; mod git_credentials; mod github; +mod loop_contract; mod maintenance; mod manual; mod orchestrator; diff --git a/apps/decodex/src/loop_contract.rs b/apps/decodex/src/loop_contract.rs new file mode 100644 index 000000000..58d5275ef --- /dev/null +++ b/apps/decodex/src/loop_contract.rs @@ -0,0 +1,785 @@ +//! Versioned Loop/Decision Contract model for research-to-execution handoff. + +use serde::{Deserialize, Serialize}; + +use crate::prelude::{Result, eyre}; + +pub(crate) const DECISION_CONTRACT_SCHEMA: &str = "decodex.decision_contract/1"; +pub(crate) const DECISION_CONTRACT_RECORD_VERSION: u16 = 1; + +/// Runtime-facing state for a Loop/Decision Contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum DecisionContractStatus { + DraftLatent, + AcceptedPromoted, + RejectedSuperseded, + NeedsHumanDecision, +} +impl DecisionContractStatus { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::DraftLatent => "draft_latent", + Self::AcceptedPromoted => "accepted_promoted", + Self::RejectedSuperseded => "rejected_superseded", + Self::NeedsHumanDecision => "needs_human_decision", + } + } +} + +/// Actor class that accepted or promoted the contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum DecisionPromotionActorKind { + User, + RuntimePolicy, +} + +/// Versioned research-to-execution contract payload. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionContract { + #[serde(default = "decision_contract_schema")] + schema: String, + #[serde(default = "decision_contract_record_version")] + record_version: u16, + contract_id: String, + status: DecisionContractStatus, + source_intent: DecisionSourceIntent, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + research_provenance: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + research_evidence: Vec, + accepted_authority: DecisionAcceptedAuthority, + execution_readiness: DecisionExecutionReadiness, + #[serde(skip_serializing_if = "Option::is_none")] + promotion: Option, + #[serde(default)] + links: DecisionContractLinks, + evidence_boundary: DecisionEvidenceBoundary, +} +#[allow(dead_code)] +impl DecisionContract { + pub(crate) fn contract_id(&self) -> &str { + &self.contract_id + } + + pub(crate) fn status(&self) -> DecisionContractStatus { + self.status + } + + pub(crate) fn source_intent(&self) -> &DecisionSourceIntent { + &self.source_intent + } + + pub(crate) fn accepted_authority(&self) -> &DecisionAcceptedAuthority { + &self.accepted_authority + } + + pub(crate) fn execution_readiness(&self) -> &DecisionExecutionReadiness { + &self.execution_readiness + } + + pub(crate) fn promotion(&self) -> Option<&DecisionPromotion> { + self.promotion.as_ref() + } + + pub(crate) fn links(&self) -> &DecisionContractLinks { + &self.links + } + + pub(crate) fn validate(&self) -> Result<()> { + validate_required("decision contract schema", &self.schema)?; + validate_required("decision contract contract_id", &self.contract_id)?; + + self.source_intent.validate()?; + self.accepted_authority.validate(self.status)?; + self.execution_readiness.validate(self.status)?; + self.links.validate()?; + self.evidence_boundary.validate()?; + + if self.schema != DECISION_CONTRACT_SCHEMA { + eyre::bail!( + "Decision contract `{}` has unsupported schema `{}`.", + self.contract_id, + self.schema + ); + } + if self.record_version != DECISION_CONTRACT_RECORD_VERSION { + eyre::bail!( + "Decision contract `{}` has unsupported record_version `{}`.", + self.contract_id, + self.record_version + ); + } + if self.status == DecisionContractStatus::AcceptedPromoted && self.promotion.is_none() { + eyre::bail!( + "Accepted decision contract `{}` must include promotion metadata.", + self.contract_id + ); + } + if matches!( + self.status, + DecisionContractStatus::DraftLatent | DecisionContractStatus::NeedsHumanDecision + ) && self.promotion.is_some() + { + eyre::bail!( + "Latent decision contract `{}` must not carry promotion metadata.", + self.contract_id + ); + } + + if let Some(promotion) = &self.promotion { + promotion.validate()?; + } + + for provenance in &self.research_provenance { + provenance.validate()?; + } + for evidence in &self.research_evidence { + evidence.validate()?; + } + + Ok(()) + } + + pub(crate) fn promote(&mut self, promotion: DecisionPromotion) -> Result<()> { + match self.status { + DecisionContractStatus::DraftLatent | DecisionContractStatus::NeedsHumanDecision => {}, + DecisionContractStatus::AcceptedPromoted => { + eyre::bail!("Decision contract `{}` is already promoted.", self.contract_id); + }, + DecisionContractStatus::RejectedSuperseded => { + eyre::bail!( + "Decision contract `{}` was rejected or superseded and cannot be promoted.", + self.contract_id + ); + }, + } + + promotion.validate()?; + + let mut candidate = self.clone(); + + candidate.status = DecisionContractStatus::AcceptedPromoted; + candidate.promotion = Some(promotion); + + candidate.validate()?; + + *self = candidate; + + Ok(()) + } + + pub(crate) fn require_human_decision(&mut self, reason: impl Into) -> Result<()> { + match self.status { + DecisionContractStatus::DraftLatent | DecisionContractStatus::NeedsHumanDecision => {}, + DecisionContractStatus::AcceptedPromoted => { + eyre::bail!( + "Accepted decision contract `{}` cannot be moved back to needs-human-decision.", + self.contract_id + ); + }, + DecisionContractStatus::RejectedSuperseded => { + eyre::bail!( + "Rejected decision contract `{}` cannot be moved to needs-human-decision.", + self.contract_id + ); + }, + } + + let reason = reason.into(); + + validate_required("decision contract human-decision reason", &reason)?; + + let mut candidate = self.clone(); + + if !candidate + .execution_readiness + .missing_decisions + .iter() + .any(|existing| existing == &reason) + { + candidate.execution_readiness.missing_decisions.push(reason); + } + + candidate.status = DecisionContractStatus::NeedsHumanDecision; + candidate.promotion = None; + + candidate.validate()?; + + *self = candidate; + + Ok(()) + } + + pub(crate) fn reject_or_supersede( + &mut self, + superseded_by_contract_id: Option, + ) -> Result<()> { + let mut candidate = self.clone(); + + if let Some(contract_id) = superseded_by_contract_id { + validate_required("decision contract superseded_by_contract_id", &contract_id)?; + + candidate.links.superseded_by_contract_id = Some(contract_id); + } + + candidate.status = DecisionContractStatus::RejectedSuperseded; + + candidate.validate()?; + + *self = candidate; + + Ok(()) + } +} + +/// Natural-language source intent that led to research or design work. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionSourceIntent { + summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + user_utterance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source_issue_identifier: Option, +} +#[allow(dead_code)] +impl DecisionSourceIntent { + pub(crate) fn summary(&self) -> &str { + &self.summary + } + + pub(crate) fn source_issue_identifier(&self) -> Option<&str> { + self.source_issue_identifier.as_deref() + } + + fn validate(&self) -> Result<()> { + validate_required("decision contract source_intent.summary", &self.summary)?; + validate_optional( + "decision contract source_intent.user_utterance", + self.user_utterance.as_deref(), + )?; + + validate_optional( + "decision contract source_intent.source_issue_identifier", + self.source_issue_identifier.as_deref(), + ) + } +} + +/// Research or design source used to produce the contract. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionResearchProvenance { + kind: String, + reference: String, + summary: String, +} +impl DecisionResearchProvenance { + fn validate(&self) -> Result<()> { + validate_required("decision contract research_provenance.kind", &self.kind)?; + validate_required("decision contract research_provenance.reference", &self.reference)?; + + validate_required("decision contract research_provenance.summary", &self.summary) + } +} + +/// Non-authoritative research evidence retained before promotion. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionResearchEvidence { + claim: String, + support: String, + #[serde(skip_serializing_if = "Option::is_none")] + source_ref: Option, +} +impl DecisionResearchEvidence { + fn validate(&self) -> Result<()> { + validate_required("decision contract research_evidence.claim", &self.claim)?; + validate_required("decision contract research_evidence.support", &self.support)?; + + validate_optional( + "decision contract research_evidence.source_ref", + self.source_ref.as_deref(), + ) + } +} + +/// Proposed or accepted execution authority carried by the contract. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionAcceptedAuthority { + #[serde(default)] + accepted_objectives: Vec, + #[serde(default)] + non_goals: Vec, + #[serde(default)] + constraints: Vec, + #[serde(default)] + assumptions: Vec, + #[serde(default)] + objections: Vec, + #[serde(default)] + stop_conditions: Vec, +} +#[allow(dead_code)] +impl DecisionAcceptedAuthority { + pub(crate) fn accepted_objectives(&self) -> &[String] { + &self.accepted_objectives + } + + pub(crate) fn non_goals(&self) -> &[String] { + &self.non_goals + } + + pub(crate) fn stop_conditions(&self) -> &[String] { + &self.stop_conditions + } + + fn validate(&self, status: DecisionContractStatus) -> Result<()> { + if status == DecisionContractStatus::AcceptedPromoted && self.accepted_objectives.is_empty() + { + eyre::bail!("Accepted decision contracts must include accepted objectives."); + } + + validate_string_list("decision contract accepted_objectives", &self.accepted_objectives)?; + validate_string_list("decision contract non_goals", &self.non_goals)?; + validate_string_list("decision contract constraints", &self.constraints)?; + validate_string_list("decision contract assumptions", &self.assumptions)?; + validate_string_list("decision contract objections", &self.objections)?; + + validate_string_list("decision contract stop_conditions", &self.stop_conditions) + } +} + +/// Natural-language readiness summary for later issue shaping. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionExecutionReadiness { + summary: String, + ready_for_issue_shaping: bool, + #[serde(default)] + missing_decisions: Vec, + #[serde(default)] + validation_expectations: Vec, + #[serde(default)] + risk_notes: Vec, +} +#[allow(dead_code)] +impl DecisionExecutionReadiness { + pub(crate) fn summary(&self) -> &str { + &self.summary + } + + pub(crate) fn ready_for_issue_shaping(&self) -> bool { + self.ready_for_issue_shaping + } + + pub(crate) fn missing_decisions(&self) -> &[String] { + &self.missing_decisions + } + + fn validate(&self, status: DecisionContractStatus) -> Result<()> { + validate_required("decision contract execution_readiness.summary", &self.summary)?; + validate_string_list("decision contract missing_decisions", &self.missing_decisions)?; + validate_string_list( + "decision contract validation_expectations", + &self.validation_expectations, + )?; + validate_string_list("decision contract risk_notes", &self.risk_notes)?; + + match status { + DecisionContractStatus::AcceptedPromoted => { + if !self.ready_for_issue_shaping { + eyre::bail!("Accepted decision contracts must be ready for issue shaping."); + } + if !self.missing_decisions.is_empty() { + eyre::bail!( + "Accepted decision contracts must not carry unresolved missing decisions." + ); + } + }, + DecisionContractStatus::NeedsHumanDecision => + if self.missing_decisions.is_empty() { + eyre::bail!( + "Needs-human-decision contracts must include at least one missing decision." + ); + }, + DecisionContractStatus::DraftLatent | DecisionContractStatus::RejectedSuperseded => {}, + } + + Ok(()) + } +} + +/// Promotion metadata that records who or what accepted the contract and when. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionPromotion { + accepted_by: String, + accepted_by_kind: DecisionPromotionActorKind, + accepted_at: String, + acceptance_source: String, + #[serde(skip_serializing_if = "Option::is_none")] + promotion_reason: Option, +} +#[allow(dead_code)] +impl DecisionPromotion { + pub(crate) fn new( + accepted_by: impl Into, + accepted_by_kind: DecisionPromotionActorKind, + accepted_at: impl Into, + acceptance_source: impl Into, + promotion_reason: Option, + ) -> Result { + let promotion = Self { + accepted_by: accepted_by.into(), + accepted_by_kind, + accepted_at: accepted_at.into(), + acceptance_source: acceptance_source.into(), + promotion_reason, + }; + + promotion.validate()?; + + Ok(promotion) + } + + pub(crate) fn accepted_by(&self) -> &str { + &self.accepted_by + } + + pub(crate) fn accepted_at(&self) -> &str { + &self.accepted_at + } + + fn validate(&self) -> Result<()> { + validate_required("decision contract promotion.accepted_by", &self.accepted_by)?; + validate_required("decision contract promotion.accepted_at", &self.accepted_at)?; + validate_required( + "decision contract promotion.acceptance_source", + &self.acceptance_source, + )?; + + validate_optional( + "decision contract promotion.promotion_reason", + self.promotion_reason.as_deref(), + ) + } +} + +/// Links from the decision contract to generated execution surfaces. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionContractLinks { + #[serde(default)] + generated_issue_ids: Vec, + #[serde(default)] + generated_issue_identifiers: Vec, + #[serde(default)] + execution_program_node_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + superseded_by_contract_id: Option, +} +#[allow(dead_code)] +impl DecisionContractLinks { + pub(crate) fn generated_issue_identifiers(&self) -> &[String] { + &self.generated_issue_identifiers + } + + pub(crate) fn execution_program_node_ids(&self) -> &[String] { + &self.execution_program_node_ids + } + + pub(crate) fn superseded_by_contract_id(&self) -> Option<&str> { + self.superseded_by_contract_id.as_deref() + } + + fn validate(&self) -> Result<()> { + validate_string_list( + "decision contract links.generated_issue_ids", + &self.generated_issue_ids, + )?; + validate_string_list( + "decision contract links.generated_issue_identifiers", + &self.generated_issue_identifiers, + )?; + validate_string_list( + "decision contract links.execution_program_node_ids", + &self.execution_program_node_ids, + )?; + + validate_optional( + "decision contract links.superseded_by_contract_id", + self.superseded_by_contract_id.as_deref(), + ) + } +} + +/// Boundary between private runtime evidence and public tracker projection. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionEvidenceBoundary { + #[serde(default)] + private_evidence_refs: Vec, + #[serde(default)] + public_projection_refs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + public_summary: Option, +} +#[allow(dead_code)] +impl DecisionEvidenceBoundary { + pub(crate) fn private_evidence_refs(&self) -> &[DecisionPrivateEvidenceRef] { + &self.private_evidence_refs + } + + pub(crate) fn public_projection_refs(&self) -> &[DecisionPublicProjectionRef] { + &self.public_projection_refs + } + + fn validate(&self) -> Result<()> { + for evidence_ref in &self.private_evidence_refs { + evidence_ref.validate()?; + } + for projection_ref in &self.public_projection_refs { + projection_ref.validate()?; + } + + validate_optional( + "decision contract evidence_boundary.public_summary", + self.public_summary.as_deref(), + ) + } +} + +/// Reference to local-only runtime evidence that must not be mirrored to Linear. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionPrivateEvidenceRef { + project_id: String, + issue_id: String, + run_id: String, + attempt_number: i64, + #[serde(skip_serializing_if = "Option::is_none")] + record_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + event_type: Option, +} +impl DecisionPrivateEvidenceRef { + fn validate(&self) -> Result<()> { + validate_required("decision contract private_evidence_ref.project_id", &self.project_id)?; + validate_required("decision contract private_evidence_ref.issue_id", &self.issue_id)?; + validate_required("decision contract private_evidence_ref.run_id", &self.run_id)?; + + if self.attempt_number < 1 { + eyre::bail!("Decision contract private evidence attempt_number must be positive."); + } + + if let Some(record_id) = self.record_id + && record_id < 1 + { + eyre::bail!("Decision contract private evidence record_id must be positive."); + } + + validate_optional( + "decision contract private_evidence_ref.event_type", + self.event_type.as_deref(), + ) + } +} + +/// Reference to a low-frequency public projection such as Linear or a generated issue. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionPublicProjectionRef { + surface: String, + reference: String, + summary: String, +} +impl DecisionPublicProjectionRef { + fn validate(&self) -> Result<()> { + validate_required("decision contract public_projection_ref.surface", &self.surface)?; + validate_required("decision contract public_projection_ref.reference", &self.reference)?; + + validate_required("decision contract public_projection_ref.summary", &self.summary) + } +} + +fn decision_contract_schema() -> String { + DECISION_CONTRACT_SCHEMA.to_owned() +} + +fn decision_contract_record_version() -> u16 { + DECISION_CONTRACT_RECORD_VERSION +} + +fn validate_required(name: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + eyre::bail!("{name} must not be empty."); + } + + Ok(()) +} + +fn validate_optional(name: &str, value: Option<&str>) -> Result<()> { + if let Some(value) = value { + validate_required(name, value)?; + } + + Ok(()) +} + +fn validate_string_list(name: &str, values: &[String]) -> Result<()> { + for value in values { + validate_required(name, value)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::loop_contract::{ + DecisionContract, DecisionContractStatus, DecisionPromotion, DecisionPromotionActorKind, + }; + + fn latent_research_contract_fixture() -> DecisionContract { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/fixtures/decision_contract/research_x_latent_contract.json" + ))) + .expect("research X latent contract fixture should deserialize") + } + + fn sample_promotion() -> DecisionPromotion { + DecisionPromotion { + accepted_by: String::from("operator"), + accepted_by_kind: DecisionPromotionActorKind::User, + accepted_at: String::from("2026-06-09T10:00:00Z"), + acceptance_source: String::from("conversation"), + promotion_reason: Some(String::from("User asked to push this forward.")), + } + } + + #[test] + fn latent_research_contract_fixture_serializes_with_expected_boundary() { + let contract = latent_research_contract_fixture(); + + contract.validate().expect("latent contract should validate"); + + assert_eq!(contract.contract_id(), "research-x-loop-contract"); + assert_eq!(contract.status(), DecisionContractStatus::DraftLatent); + assert_eq!(contract.source_intent().summary(), "Research X and shape follow-up work."); + assert_eq!(contract.accepted_authority().accepted_objectives().len(), 2); + assert!(contract.execution_readiness().ready_for_issue_shaping()); + assert_eq!(contract.evidence_boundary.private_evidence_refs().len(), 1); + assert_eq!(contract.evidence_boundary.public_projection_refs().len(), 1); + assert!(contract.promotion().is_none()); + } + + #[test] + fn promotion_records_acceptance_metadata_and_blocks_double_promotion() { + let mut contract = latent_research_contract_fixture(); + + contract.promote(sample_promotion()).expect("latent contract should promote"); + + assert_eq!(contract.status(), DecisionContractStatus::AcceptedPromoted); + assert_eq!( + contract.promotion().expect("promotion should exist").accepted_at(), + "2026-06-09T10:00:00Z" + ); + assert!( + contract + .promote(contract.promotion().expect("promotion should exist").clone()) + .is_err() + ); + } + + #[test] + fn rejected_contract_cannot_be_promoted() { + let mut contract = latent_research_contract_fixture(); + + contract + .reject_or_supersede(Some(String::from("research-x-replacement"))) + .expect("contract should reject"); + + assert_eq!(contract.status(), DecisionContractStatus::RejectedSuperseded); + assert_eq!(contract.links().superseded_by_contract_id(), Some("research-x-replacement")); + assert!( + contract + .promote(DecisionPromotion { promotion_reason: None, ..sample_promotion() }) + .is_err() + ); + } + + #[test] + fn accepted_contracts_require_readiness_without_missing_decisions() { + let mut contract = latent_research_contract_fixture(); + + contract.execution_readiness.ready_for_issue_shaping = false; + + let before_failed_promotion = contract.clone(); + + assert!(contract.promote(sample_promotion()).is_err()); + assert_eq!( + contract, before_failed_promotion, + "failed promotion must not mutate the contract" + ); + + let mut contract = latent_research_contract_fixture(); + + contract + .execution_readiness + .missing_decisions + .push(String::from("Choose the first generated issue.")); + + let before_failed_promotion = contract.clone(); + + assert!(contract.promote(sample_promotion()).is_err()); + assert_eq!( + contract, before_failed_promotion, + "failed promotion must not mutate the contract" + ); + } + + #[test] + fn latent_contracts_reject_promotion_metadata() { + let mut contract = latent_research_contract_fixture(); + + contract.promotion = Some(sample_promotion()); + + assert!(contract.validate().is_err()); + } + + #[test] + fn validation_rejects_empty_optional_boundary_values() { + let mut contract = latent_research_contract_fixture(); + + contract.links.generated_issue_identifiers.push(String::from(" ")); + + assert!(contract.validate().is_err()); + + let mut contract = latent_research_contract_fixture(); + + contract.evidence_boundary.public_summary = Some(String::new()); + + assert!(contract.validate().is_err()); + + let mut contract = latent_research_contract_fixture(); + + contract.evidence_boundary.private_evidence_refs[0].record_id = Some(0); + + assert!(contract.validate().is_err()); + } + + #[test] + fn failed_non_promotion_transitions_leave_contract_unchanged() { + let mut contract = latent_research_contract_fixture(); + let before_failed_human_decision = contract.clone(); + + assert!(contract.require_human_decision(" ").is_err()); + assert_eq!( + contract, before_failed_human_decision, + "failed human-decision transition must not mutate the contract" + ); + + let before_failed_rejection = contract.clone(); + + assert!(contract.reject_or_supersede(Some(String::from(" "))).is_err()); + assert_eq!( + contract, before_failed_rejection, + "failed rejection transition must not mutate the contract" + ); + } +} diff --git a/apps/decodex/src/state.rs b/apps/decodex/src/state.rs index b4cfbc4e2..ba3c5b61c 100644 --- a/apps/decodex/src/state.rs +++ b/apps/decodex/src/state.rs @@ -22,6 +22,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ config::ServiceConfig, + loop_contract::{DecisionContract, DecisionContractStatus, DecisionPromotion}, prelude::{Result, eyre}, tracker::records::{self, LinearExecutionEventRecord}, }; diff --git a/apps/decodex/src/state/internal.rs b/apps/decodex/src/state/internal.rs index 840853acc..1791c727c 100644 --- a/apps/decodex/src/state/internal.rs +++ b/apps/decodex/src/state/internal.rs @@ -127,6 +127,7 @@ struct StateData { worktrees: HashMap, linear_execution_events: HashMap, private_execution_events: Vec, + decision_contracts: HashMap, review_handoffs: HashMap, review_orchestrations: HashMap, review_policy_checkpoints: HashMap, @@ -146,6 +147,7 @@ impl StateData { self.worktrees = loaded.worktrees; self.linear_execution_events = loaded.linear_execution_events; self.private_execution_events = loaded.private_execution_events; + self.decision_contracts = loaded.decision_contracts; self.review_handoffs = loaded.review_handoffs; self.review_orchestrations = loaded.review_orchestrations; self.review_policy_checkpoints = loaded.review_policy_checkpoints; @@ -351,6 +353,7 @@ ON linear_execution_events (service_id, issue_id, event_unix, recorded_at_unix); self.bootstrap_run_control_channels_schema()?; self.bootstrap_connector_backoffs_schema()?; self.bootstrap_private_execution_events_schema()?; + self.bootstrap_decision_contracts_schema()?; self.record_schema_version()?; Ok(()) @@ -501,6 +504,31 @@ ON private_execution_events ( Ok(()) } + fn bootstrap_decision_contracts_schema(&self) -> Result<()> { + self.connection.execute_batch( + r#" +CREATE TABLE IF NOT EXISTS decision_contracts ( + project_id TEXT NOT NULL, + contract_id TEXT NOT NULL, + source_issue_id TEXT, + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + created_at_unix INTEGER NOT NULL, + updated_at TEXT NOT NULL, + updated_at_unix INTEGER NOT NULL, + PRIMARY KEY (project_id, contract_id) +); +CREATE INDEX IF NOT EXISTS decision_contracts_source_issue_idx +ON decision_contracts (project_id, source_issue_id, updated_at_unix); +CREATE INDEX IF NOT EXISTS decision_contracts_status_idx +ON decision_contracts (project_id, status, updated_at_unix); +"#, + )?; + + Ok(()) + } + fn record_schema_version(&self) -> Result<()> { self.connection.execute_batch( r#" @@ -528,6 +556,7 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; self.load_worktrees(&mut state)?; self.load_linear_execution_events(&mut state)?; self.load_private_execution_events(&mut state)?; + self.load_decision_contracts(&mut state)?; self.load_review_handoffs(&mut state)?; self.load_review_orchestrations(&mut state)?; self.load_review_policy_checkpoints(&mut state)?; @@ -567,6 +596,7 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; persist_worktrees(&transaction, state)?; persist_linear_execution_events(&transaction, state)?; persist_private_execution_events(&transaction, state)?; + persist_decision_contracts(&transaction, state)?; persist_review_handoffs(&transaction, state)?; persist_review_orchestrations(&transaction, state)?; persist_review_policy_checkpoints(&transaction, state)?; @@ -589,6 +619,10 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; "DELETE FROM run_control_channels WHERE project_id = ?1", params![service_id], )?; + transaction.execute( + "DELETE FROM decision_contracts WHERE project_id = ?1", + params![service_id], + )?; transaction.commit()?; Ok(()) @@ -767,6 +801,37 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; Ok(self.connection.last_insert_rowid()) } + #[allow(dead_code)] + fn upsert_decision_contract(&self, record: &DecisionContractRuntimeRecord) -> Result<()> { + let payload_json = serde_json::to_string(&record.contract)?; + + self.connection.execute( + "INSERT INTO decision_contracts ( + project_id, contract_id, source_issue_id, status, payload_json, created_at, + created_at_unix, updated_at, updated_at_unix + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(project_id, contract_id) DO UPDATE SET + source_issue_id = excluded.source_issue_id, + status = excluded.status, + payload_json = excluded.payload_json, + updated_at = excluded.updated_at, + updated_at_unix = excluded.updated_at_unix", + params![ + &record.project_id, + record.contract.contract_id(), + record.source_issue_id.as_deref(), + record.status.as_str(), + payload_json, + &record.created_at, + record.created_at_unix, + &record.updated_at, + record.updated_at_unix, + ], + )?; + + Ok(()) + } + fn delete_lease(&mut self, issue_id: &str) -> Result<()> { self.connection .execute("DELETE FROM leases WHERE issue_id = ?1", params![issue_id])?; @@ -805,6 +870,10 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; "UPDATE private_execution_events SET issue_id = ?2 WHERE issue_id = ?1", params![previous_issue_id, canonical_issue_id], )?; + transaction.execute( + "UPDATE decision_contracts SET source_issue_id = ?2 WHERE source_issue_id = ?1", + params![previous_issue_id, canonical_issue_id], + )?; transaction.execute( "INSERT OR IGNORE INTO review_policy_checkpoints ( project_id, issue_id, run_id, attempt_number, phase, status, head_sha, @@ -1407,6 +1476,76 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; Ok(()) } + fn load_decision_contracts(&self, state: &mut StateData) -> Result<()> { + let mut statement = self.connection.prepare( + "SELECT project_id, contract_id, source_issue_id, status, payload_json, created_at, \ + created_at_unix, updated_at, updated_at_unix \ + FROM decision_contracts \ + ORDER BY project_id ASC, contract_id ASC", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, String>(7)?, + row.get::<_, i64>(8)?, + )) + })?; + + for row in rows { + let ( + project_id, + contract_id, + source_issue_id, + status, + payload_json, + created_at, + created_at_unix, + updated_at, + updated_at_unix, + ) = row?; + let contract = serde_json::from_str::(&payload_json)?; + let contract_status = contract.status(); + + contract.validate()?; + + if contract_id != contract.contract_id() { + eyre::bail!( + "Decision contract row `{contract_id}` contained payload `{}`.", + contract.contract_id() + ); + } + if status != contract_status.as_str() { + tracing::warn!( + project_id = %project_id, + contract_id = %contract_id, + "decision contract status column differed from payload status" + ); + } + + state.decision_contracts.insert( + DecisionContractKey::new(&project_id, &contract_id), + DecisionContractRuntimeRecord { + project_id, + source_issue_id, + status: contract_status, + contract, + created_at, + created_at_unix, + updated_at, + updated_at_unix, + }, + ); + } + + Ok(()) + } + fn load_review_handoffs(&self, state: &mut StateData) -> Result<()> { let mut statement = self.connection.prepare( "SELECT project_id, issue_id, branch_name, run_id, attempt_number, pr_url, \ @@ -1715,6 +1854,49 @@ impl PrivateExecutionEventRuntimeRecord { } } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct DecisionContractKey { + project_id: String, + contract_id: String, +} +impl DecisionContractKey { + fn new(project_id: &str, contract_id: &str) -> Self { + Self { project_id: project_id.to_owned(), contract_id: contract_id.to_owned() } + } +} + +#[derive(Clone, Debug)] +struct DecisionContractRuntimeRecord { + project_id: String, + source_issue_id: Option, + contract: DecisionContract, + status: DecisionContractStatus, + created_at: String, + created_at_unix: i64, + updated_at: String, + updated_at_unix: i64, +} +impl DecisionContractRuntimeRecord { + #[allow(dead_code)] + fn key(&self) -> DecisionContractKey { + DecisionContractKey::new(&self.project_id, self.contract.contract_id()) + } + + #[allow(dead_code)] + fn as_public(&self) -> DecisionContractRecord { + DecisionContractRecord { + project_id: self.project_id.clone(), + source_issue_id: self.source_issue_id.clone(), + contract: self.contract.clone(), + status: self.status, + created_at: self.created_at.clone(), + created_at_unix: self.created_at_unix, + updated_at: self.updated_at.clone(), + updated_at_unix: self.updated_at_unix, + } + } +} + #[derive(Clone, Debug)] struct WorktreeMappingRecord { project_id: String, @@ -2695,6 +2877,35 @@ fn persist_private_execution_events( Ok(()) } +fn persist_decision_contracts( + transaction: &Transaction<'_>, + state: &StateData, +) -> Result<()> { + for record in state.decision_contracts.values() { + let payload_json = serde_json::to_string(&record.contract)?; + + transaction.execute( + "INSERT OR REPLACE INTO decision_contracts ( + project_id, contract_id, source_issue_id, status, payload_json, created_at, + created_at_unix, updated_at, updated_at_unix + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + &record.project_id, + record.contract.contract_id(), + record.source_issue_id.as_deref(), + record.status.as_str(), + payload_json, + &record.created_at, + record.created_at_unix, + &record.updated_at, + record.updated_at_unix, + ], + )?; + } + + Ok(()) +} + fn persist_review_handoffs(transaction: &Transaction<'_>, state: &StateData) -> Result<()> { for record in state.review_handoffs.values() { transaction.execute( @@ -3475,6 +3686,16 @@ fn compare_private_execution_event_runtime_records( left.record_id.cmp(&right.record_id) } +#[allow(dead_code)] +fn compare_decision_contract_runtime_records( + left: &DecisionContractRuntimeRecord, + right: &DecisionContractRuntimeRecord, +) -> cmp::Ordering { + left.updated_at_unix + .cmp(&right.updated_at_unix) + .then_with(|| left.contract.contract_id().cmp(right.contract.contract_id())) +} + fn compare_project_run_status(left: &ProjectRunStatus, right: &ProjectRunStatus) -> cmp::Ordering { right .active_lease diff --git a/apps/decodex/src/state/models.rs b/apps/decodex/src/state/models.rs index 4df8a533e..ef52f6549 100644 --- a/apps/decodex/src/state/models.rs +++ b/apps/decodex/src/state/models.rs @@ -498,6 +498,57 @@ pub struct PreacquiredLeaseGuards { pub dispatch_slot_index: usize, } +/// SQLite-backed Loop/Decision Contract retained by the local runtime. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DecisionContractRecord { + project_id: String, + source_issue_id: Option, + contract: DecisionContract, + status: DecisionContractStatus, + created_at: String, + created_at_unix: i64, + updated_at: String, + updated_at_unix: i64, +} +#[allow(dead_code)] +impl DecisionContractRecord { + pub(crate) fn project_id(&self) -> &str { + &self.project_id + } + + pub(crate) fn source_issue_id(&self) -> Option<&str> { + self.source_issue_id.as_deref() + } + + pub(crate) fn contract(&self) -> &DecisionContract { + &self.contract + } + + pub(crate) fn contract_id(&self) -> &str { + self.contract.contract_id() + } + + pub(crate) fn status(&self) -> DecisionContractStatus { + self.status + } + + pub(crate) fn created_at(&self) -> &str { + &self.created_at + } + + pub(crate) fn created_at_unix(&self) -> i64 { + self.created_at_unix + } + + pub(crate) fn updated_at(&self) -> &str { + &self.updated_at + } + + pub(crate) fn updated_at_unix(&self) -> i64 { + self.updated_at_unix + } +} + /// Latest runtime-owned review-policy checkpoint for one run phase. #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ReviewPolicyCheckpoint { diff --git a/apps/decodex/src/state/store.rs b/apps/decodex/src/state/store.rs index d52f4d7cd..2d3975325 100644 --- a/apps/decodex/src/state/store.rs +++ b/apps/decodex/src/state/store.rs @@ -256,6 +256,13 @@ impl StateStore { { record.issue_id = canonical_issue_id.to_owned(); } + for record in state + .decision_contracts + .values_mut() + .filter(|record| record.source_issue_id.as_deref() == Some(previous_issue_id)) + { + record.source_issue_id = Some(canonical_issue_id.to_owned()); + } self.retarget_issue_identity_locked(previous_issue_id, canonical_issue_id) } @@ -1520,6 +1527,158 @@ impl StateStore { Ok(records.into_iter().map(|record| record.as_public()).collect()) } + /// Create or replace one local Loop/Decision Contract payload. + #[allow(dead_code)] + pub(crate) fn upsert_decision_contract( + &self, + project_id: &str, + source_issue_id: Option<&str>, + contract: DecisionContract, + ) -> Result { + validate_decision_contract_record_inputs(project_id, source_issue_id, &contract)?; + + let now = timestamp_parts(); + let mut state = self.lock_without_refresh()?; + let key = DecisionContractKey::new(project_id, contract.contract_id()); + let (created_at, created_at_unix) = state + .decision_contracts + .get(&key) + .map_or_else(|| (now.text.clone(), now.unix), |record| { + (record.created_at.clone(), record.created_at_unix) + }); + let record = DecisionContractRuntimeRecord { + project_id: project_id.to_owned(), + source_issue_id: source_issue_id.map(str::to_owned), + status: contract.status(), + contract, + created_at, + created_at_unix, + updated_at: now.text, + updated_at_unix: now.unix, + }; + + state.decision_contracts.insert(record.key(), record.clone()); + self.upsert_decision_contract_locked(&record)?; + + Ok(record.as_public()) + } + + /// Read one local Loop/Decision Contract by project and contract id. + #[allow(dead_code)] + pub(crate) fn decision_contract( + &self, + project_id: &str, + contract_id: &str, + ) -> Result> { + validate_required_decision_contract_field("project_id", project_id)?; + validate_required_decision_contract_field("contract_id", contract_id)?; + + let state = self.lock()?; + + Ok(state + .decision_contracts + .get(&DecisionContractKey::new(project_id, contract_id)) + .map(DecisionContractRuntimeRecord::as_public)) + } + + /// List local Loop/Decision Contracts sourced from one tracker issue. + #[allow(dead_code)] + pub(crate) fn list_decision_contracts_for_issue( + &self, + project_id: &str, + source_issue_id: &str, + ) -> Result> { + validate_required_decision_contract_field("project_id", project_id)?; + validate_required_decision_contract_field("source_issue_id", source_issue_id)?; + + let state = self.lock()?; + let mut records = state + .decision_contracts + .values() + .filter(|record| { + record.project_id == project_id + && record.source_issue_id.as_deref() == Some(source_issue_id) + }) + .cloned() + .collect::>(); + + records.sort_by(compare_decision_contract_runtime_records); + + Ok(records.into_iter().map(|record| record.as_public()).collect()) + } + + /// Promote a latent Loop/Decision Contract into accepted execution authority. + #[allow(dead_code)] + pub(crate) fn promote_decision_contract( + &self, + project_id: &str, + contract_id: &str, + promotion: DecisionPromotion, + ) -> Result { + self.update_decision_contract(project_id, contract_id, |contract| { + contract.promote(promotion) + }) + } + + /// Mark a latent Loop/Decision Contract as waiting for more human direction. + #[allow(dead_code)] + pub(crate) fn mark_decision_contract_needs_human_decision( + &self, + project_id: &str, + contract_id: &str, + reason: &str, + ) -> Result { + self.update_decision_contract(project_id, contract_id, |contract| { + contract.require_human_decision(reason.to_owned()) + }) + } + + /// Reject or supersede a Loop/Decision Contract. + #[allow(dead_code)] + pub(crate) fn reject_decision_contract( + &self, + project_id: &str, + contract_id: &str, + superseded_by_contract_id: Option, + ) -> Result { + self.update_decision_contract(project_id, contract_id, |contract| { + contract.reject_or_supersede(superseded_by_contract_id) + }) + } + + #[allow(dead_code)] + fn update_decision_contract( + &self, + project_id: &str, + contract_id: &str, + update: impl FnOnce(&mut DecisionContract) -> Result<()>, + ) -> Result { + validate_required_decision_contract_field("project_id", project_id)?; + validate_required_decision_contract_field("contract_id", contract_id)?; + + let now = timestamp_parts(); + let key = DecisionContractKey::new(project_id, contract_id); + let mut state = self.lock()?; + let mut record = state + .decision_contracts + .get(&key) + .cloned() + .ok_or_else(|| eyre::eyre!("Decision contract `{contract_id}` does not exist."))?; + + update(&mut record.contract)?; + + record.contract.validate()?; + + record.status = record.contract.status(); + record.updated_at = now.text; + record.updated_at_unix = now.unix; + + state.decision_contracts.insert(key, record.clone()); + self.upsert_decision_contract_locked(&record)?; + + Ok(record.as_public()) + } + /// Count protocol journal records for one run. pub fn event_count(&self, run_id: &str) -> Result { let state = self.lock()?; @@ -2022,6 +2181,21 @@ impl StateStore { sqlite.insert_private_execution_event(record).map(Some) } + #[allow(dead_code)] + fn upsert_decision_contract_locked( + &self, + record: &DecisionContractRuntimeRecord, + ) -> Result<()> { + let Some(sqlite) = self.sqlite.as_ref() else { + return Ok(()); + }; + let sqlite = sqlite + .lock() + .map_err(|_| eyre::eyre!("StateStore SQLite mutex is poisoned."))?; + + sqlite.upsert_decision_contract(record) + } + fn delete_lease_locked(&self, issue_id: &str) -> Result<()> { let Some(sqlite) = self.sqlite.as_ref() else { return Ok(()); @@ -2419,6 +2593,30 @@ fn validate_run_control_channel_inputs( Ok(()) } +#[allow(dead_code)] +fn validate_decision_contract_record_inputs( + project_id: &str, + source_issue_id: Option<&str>, + contract: &DecisionContract, +) -> Result<()> { + validate_required_decision_contract_field("project_id", project_id)?; + + if let Some(source_issue_id) = source_issue_id { + validate_required_decision_contract_field("source_issue_id", source_issue_id)?; + } + + contract.validate() +} + +#[allow(dead_code)] +fn validate_required_decision_contract_field(name: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + eyre::bail!("Decision contract {name} must not be empty."); + } + + Ok(()) +} + #[cfg_attr(not(test), allow(dead_code))] fn validate_run_control_action_request(request: &RunControlActionRequest<'_>) -> Result<()> { validate_required_run_control_field("project_id", request.project_id)?; diff --git a/apps/decodex/src/state/tests.rs b/apps/decodex/src/state/tests.rs index 085d77813..5c65f1b88 100644 --- a/apps/decodex/src/state/tests.rs +++ b/apps/decodex/src/state/tests.rs @@ -8,9 +8,14 @@ use std::{ }; #[cfg(unix)] use libc::{F_GETFD, FD_CLOEXEC}; +use rusqlite::{self, Connection}; +use serde_json::Value; use tempfile::TempDir; use crate::{ + loop_contract::{ + DecisionContract, DecisionContractStatus, DecisionPromotion, DecisionPromotionActorKind, + }, state::{ self, ChildAgentActivitySummary, CodexAccountActivitySummary, CodexAccountMarker, ConnectorBackoffInput, DispatchSlotLimit, EffectiveRuntimeMarker, PreacquiredLeaseGuards, @@ -63,6 +68,41 @@ fn sample_pub_101_review_orchestration() -> ReviewOrchestrationMarker { ) } +fn latent_decision_contract_fixture() -> DecisionContract { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/fixtures/decision_contract/research_x_latent_contract.json" + ))) + .expect("research X latent contract fixture should deserialize") +} + +fn sample_decision_promotion() -> DecisionPromotion { + DecisionPromotion::new( + "operator", + DecisionPromotionActorKind::User, + "2026-06-09T10:00:00Z", + "conversation", + Some(String::from("User asked Decodex to push this forward.")), + ) + .expect("sample promotion should validate") +} + +fn assert_decision_contract_retargeted(reopened: &StateStore) { + assert_eq!( + reopened + .list_decision_contracts_for_issue("pubfi", "linear-id-101") + .expect("canonical decision contracts should list") + .len(), + 1 + ); + assert!( + reopened + .list_decision_contracts_for_issue("pubfi", "PUB-101") + .expect("old decision contracts should list") + .is_empty() + ); +} + fn upsert_handoff_review_policy_checkpoint( store: &StateStore, issue_id: &str, @@ -2145,6 +2185,9 @@ fn canonicalize_issue_identity_retargets_persistent_rows_without_cache_refresh() serde_json::json!({ "summary": "cached on visible tracker key" }), ) .expect("private evidence should persist"); + writer + .upsert_decision_contract("pubfi", Some("PUB-101"), latent_decision_contract_fixture()) + .expect("decision contract should persist"); writer .upsert_review_handoff_marker("pubfi", "PUB-101", &handoff) .expect("handoff marker should persist"); @@ -2199,6 +2242,9 @@ fn canonicalize_issue_identity_retargets_persistent_rows_without_cache_refresh() .len(), 1 ); + + assert_decision_contract_retargeted(&reopened); + assert_eq!( reopened .review_handoff_marker("pubfi", "linear-id-101", "x/decodex-pub-101") @@ -2537,6 +2583,151 @@ fn private_execution_events_filter_issue_run_attempt_and_stay_out_of_linear_cach ); } +#[test] +fn decision_contracts_persist_reload_and_promote_without_linear_mirror() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let state_path = temp_dir.path().join("runtime.sqlite3"); + let store = StateStore::open(&state_path).expect("state store should open"); + let latent = latent_decision_contract_fixture(); + let record = store + .upsert_decision_contract("decodex", Some("XY-852"), latent) + .expect("latent decision contract should persist"); + + assert_eq!(record.project_id(), "decodex"); + assert_eq!(record.source_issue_id(), Some("XY-852")); + assert_eq!(record.contract_id(), "research-x-loop-contract"); + assert_eq!(record.status(), DecisionContractStatus::DraftLatent); + assert!(record.created_at_unix() > 0); + assert!(record.updated_at_unix() >= record.created_at_unix()); + + let promoted = store + .promote_decision_contract( + "decodex", + "research-x-loop-contract", + sample_decision_promotion(), + ) + .expect("latent contract should promote"); + + assert_eq!(promoted.status(), DecisionContractStatus::AcceptedPromoted); + assert_eq!( + promoted.contract().promotion().expect("promotion metadata should persist").accepted_by(), + "operator" + ); + assert!( + store + .list_linear_execution_events("decodex", "XY-852") + .expect("linear mirror should read") + .is_empty(), + "decision contracts stay in runtime SQLite and do not populate Linear cache" + ); + + let reopened = StateStore::open(&state_path).expect("state store should reopen"); + let reloaded = reopened + .decision_contract("decodex", "research-x-loop-contract") + .expect("decision contract should read") + .expect("decision contract should exist"); + + assert_eq!(reloaded.status(), DecisionContractStatus::AcceptedPromoted); + assert_eq!(reloaded.source_issue_id(), Some("XY-852")); + assert_eq!(reloaded.created_at(), record.created_at()); + assert!(reloaded.updated_at_unix() >= record.updated_at_unix()); + assert_eq!(reloaded.contract().accepted_authority().accepted_objectives().len(), 2); + + let issue_contracts = reopened + .list_decision_contracts_for_issue("decodex", "XY-852") + .expect("source issue contracts should list"); + + assert_eq!(issue_contracts.len(), 1); + assert_eq!(issue_contracts[0].contract_id(), "research-x-loop-contract"); +} + +#[test] +fn decision_contracts_record_human_decision_and_rejection_transitions() { + let store = StateStore::open_in_memory().expect("in-memory state store should open"); + + store + .upsert_decision_contract("decodex", Some("XY-852"), latent_decision_contract_fixture()) + .expect("latent decision contract should persist"); + + let waiting = store + .mark_decision_contract_needs_human_decision( + "decodex", + "research-x-loop-contract", + "Choose which generated issue should run first.", + ) + .expect("contract should record human decision need"); + + assert_eq!(waiting.status(), DecisionContractStatus::NeedsHumanDecision); + assert!( + waiting + .contract() + .execution_readiness() + .missing_decisions() + .iter() + .any(|decision| decision == "Choose which generated issue should run first.") + ); + + let rejected = store + .reject_decision_contract( + "decodex", + "research-x-loop-contract", + Some(String::from("research-x-loop-contract-v2")), + ) + .expect("contract should reject"); + + assert_eq!(rejected.status(), DecisionContractStatus::RejectedSuperseded); + assert_eq!( + rejected.contract().links().superseded_by_contract_id(), + Some("research-x-loop-contract-v2") + ); + assert!( + store + .promote_decision_contract( + "decodex", + "research-x-loop-contract", + sample_decision_promotion() + ) + .is_err(), + "rejected contracts cannot later become execution authority" + ); +} + +#[test] +fn decision_contract_reload_rejects_row_key_payload_mismatch() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let state_path = temp_dir.path().join("runtime.sqlite3"); + let store = StateStore::open(&state_path).expect("state store should open"); + + store + .upsert_decision_contract("decodex", Some("XY-852"), latent_decision_contract_fixture()) + .expect("latent decision contract should persist"); + + let mut payload = serde_json::from_str::(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/fixtures/decision_contract/research_x_latent_contract.json" + ))) + .expect("fixture should parse as JSON"); + + payload["contract_id"] = serde_json::json!("mismatched-contract-id"); + + let connection = Connection::open(&state_path).expect("sqlite should open"); + + connection + .execute( + "UPDATE decision_contracts SET payload_json = ?1 WHERE contract_id = ?2", + rusqlite::params![ + serde_json::to_string(&payload).expect("payload should serialize"), + "research-x-loop-contract", + ], + ) + .expect("decision contract row should corrupt for test"); + + assert!( + StateStore::open(&state_path).is_err(), + "decision contract row key must match the versioned payload contract_id" + ); +} + #[test] fn state_store_open_refreshes_pubfi_project_registry_across_instances() { let temp_dir = TempDir::new().expect("tempdir should create"); diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 4491955e4..c84338452 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -131,7 +131,7 @@ work that needs full private payload values. | Surface | Owns | Does Not Own | | --- | --- | --- | -| Runtime SQLite DB | active leases, attempts, run-control channels, protocol events, private execution events, worktree mappings, retry state, retained PR state, review-policy checkpoints with structured independent-review detail, phase timing, connector backoff, project registry | human backlog grooming or durable team-visible issue history | +| Runtime SQLite DB | active leases, attempts, run-control channels, protocol events, private execution events, Decision Contracts, worktree mappings, retry state, retained PR state, review-policy checkpoints with structured independent-review detail, phase timing, connector backoff, project registry | human backlog grooming or durable team-visible issue history | | Central project config | `service_id`, repo root, worktree root, tracker/GitHub credential env-var names, enabled project registration | per-run state or issue ownership | | Project `WORKFLOW.md` | repo policy, validation gate, state names, retry/review policy | runtime ownership, queue labels, credentials, model overrides | | Linear | team-visible issue state, queue/active/manual-attention labels, coarse execution ledger comments, progress/failure/handoff/closeout summaries | high-frequency runtime truth, heartbeat, token pressure, raw attempts, private execution evidence, connector retry budgets | @@ -270,6 +270,9 @@ Interpret the surfaces in this order: Do not backfill Linear with private evidence just to make the issue history look like a complete execution transcript. If a teammate needs a public update, write or wait for the next allowlisted lifecycle summary instead of pasting local evidence payloads. +The same boundary applies to Decision Contracts: the operator surface may show status, +readiness summary, generated issue links, or public projection references, but the +versioned contract payload and private evidence references remain runtime-local. Worktree visibility follows the owning dashboard section: diff --git a/docs/spec/linear-execution-ledger.md b/docs/spec/linear-execution-ledger.md index 6da265979..3f0eb39c0 100644 --- a/docs/spec/linear-execution-ledger.md +++ b/docs/spec/linear-execution-ledger.md @@ -29,6 +29,10 @@ Linear comments, the Decodex runtime database, and short-lived heartbeat markers [`post-review-lifecycle.md`](./post-review-lifecycle.md), and [`tracker-tools.md`](./tracker-tools.md). Those documents define when events may be written; this document defines what the records look like. +- Decision Contracts are not Linear execution-ledger records. A ledger record may + summarize or link to generated issues after promotion, but the versioned + `decodex.decision_contract/1` payload and private loop evidence stay in runtime + SQLite. ## Comment body format diff --git a/docs/spec/loop-runtime.md b/docs/spec/loop-runtime.md index 6d32a35f2..d566f6073 100644 --- a/docs/spec/loop-runtime.md +++ b/docs/spec/loop-runtime.md @@ -75,6 +75,50 @@ A Research/Decision stage may produce a latent Loop/Decision Contract with: The latent contract is a candidate decision package. It becomes authoritative only after the user or an accepted runtime policy promotes it. +## Decision Contract Schema + +The runtime-facing Decision Contract payload is versioned as +`decodex.decision_contract/1` with `record_version = 1`. + +The payload carries these top-level fields: + +| Field | Meaning | +| --- | --- | +| `contract_id` | Stable runtime identifier for this decision package. | +| `status` | One of `draft_latent`, `accepted_promoted`, `rejected_superseded`, or `needs_human_decision`. | +| `source_intent` | Natural-language source intent, including the original utterance or issue reference when known. | +| `research_provenance` | Research/design sources used to produce the candidate package. | +| `research_evidence` | Non-authoritative evidence claims retained for later review and issue shaping. | +| `accepted_authority` | Objectives, non-goals, constraints, assumptions, objections, and stop conditions that become authority only when status is `accepted_promoted`. | +| `execution_readiness` | Natural-language readiness summary, missing decisions, validation expectations, and risk notes. It must not expose graph ids or require the user to operate a DAG. Accepted contracts must be ready for issue shaping and must not carry unresolved missing decisions. | +| `promotion` | Metadata recording who or what accepted the decision, the acceptance source, and the acceptance time. Required only for `accepted_promoted`. | +| `links` | Generated Linear issue ids/identifiers or internal Execution Program node ids when those exist. | +| `evidence_boundary` | Local private evidence references and sparse public projection references. | + +The status is the authority boundary: + +- `draft_latent` means the research/design result is stored but cannot enqueue, + mutate tracker state, set goals, or authorize implementation. +- `accepted_promoted` means the payload's `accepted_authority` fields may be used by + the loop runtime to shape queue intent, generated issues, or internal Execution + Program nodes. The payload must include promotion metadata, set + `execution_readiness.ready_for_issue_shaping = true`, and leave + `execution_readiness.missing_decisions` empty. +- `rejected_superseded` means the payload is retained for audit/history but must not + be promoted later. +- `needs_human_decision` means the package is incomplete or contradictory enough that + execution must wait for more direction. The payload must include at least one + `execution_readiness.missing_decisions` entry. + +Research provenance and research evidence are not execution authority. They explain +why the candidate package exists and give future agents enough context to avoid asking +the user to restate all details after promotion. + +The runtime stores Decision Contracts in local SQLite first. Linear issue descriptions, +Linear execution-ledger comments, generated issue text, and operator summaries may link +to or summarize an accepted contract, but they are public/coarse mirrors and must not +become the source of truth for private loop state. + ## Promotion Boundary Promotion is the boundary between design and execution authority. diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 91ab27cff..3267fd49a 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -44,7 +44,7 @@ state or this state machine. ## Source of truth boundaries -- The Decodex runtime SQLite database is the single-machine source of truth for active leases, attempts, run-control channels, protocol events, private execution events, worktree mappings, retained PR state, review-policy checkpoints, retry state, phase timing, project registration, tracker cache, PR cache, and connector backoff. +- The Decodex runtime SQLite database is the single-machine source of truth for active leases, attempts, run-control channels, protocol events, private execution events, latent and promoted Decision Contracts, worktree mappings, retained PR state, review-policy checkpoints, retry state, phase timing, project registration, tracker cache, PR cache, and connector backoff. - Linear remains the team-visible tracker surface for issue lifecycle, queue/active/manual-attention labels, and coarse lifecycle summaries such as start, PR-ready, blocked, failed, landed, and done. - Versioned Linear execution event comments use the schema in [`linear-execution-ledger.md`](./linear-execution-ledger.md), but fine-grained runtime truth must not be rebuilt from comments every tick. @@ -67,6 +67,7 @@ mirror: | Surface | Boundary | | --- | --- | | Runtime SQLite `private_execution_events` | Structured private execution evidence for the local Decodex installation. This is where full checkpoint payloads, verification notes, local head evidence, and recovery detail belong. | +| Runtime SQLite `decision_contracts` | Versioned `decodex.decision_contract/1` payloads produced by research/design and later promoted into execution authority. The row status is indexed for local runtime lookup, but the JSON payload remains the contract authority. | | Runtime SQLite `run_control_channels` | Local control capability metadata for active run attempts. It records the project, issue, run id, attempt, transport, local channel path, channel status, and publish/update timestamps needed to route future control requests without bypassing active lease ownership. | | Runtime SQLite `review_policy_checkpoints` | Latest bounded-review checkpoint state for one project, issue, run, attempt, and phase, including structured independent-review detail. This row is the authority for review handoff and retained repair gating. | | Agent evidence under `~/.codex/decodex/agent-evidence//` | Derived local handoff view for repair agents. It may reference private evidence readback commands and compact run capsules, but it is not scheduling authority and is not a public mirror. | @@ -120,7 +121,9 @@ This boundary does not create a project-local runtime database contract. The run - Lane: The branch plus linked Git worktree checkout associated with one issue. - Decision Contract: An accepted loop-runtime decision package, also called the Loop/Decision Contract. Research output is only latent until accepted or promoted - under [`loop-runtime.md`](./loop-runtime.md). + under [`loop-runtime.md`](./loop-runtime.md). The runtime-facing serialized payload + is `decodex.decision_contract/1`; statuses are `draft_latent`, + `accepted_promoted`, `rejected_superseded`, and `needs_human_decision`. - Execution Program: Internal loop-runtime state derived from accepted Decision Contracts. It may use DAG semantics, but normal Linear issues remain the executable lanes. @@ -425,6 +428,8 @@ The runtime database stores at least: - run attempts and attempt status - protocol event journals - private execution events scoped by project, issue, run, and attempt +- Decision Contracts scoped by project and contract id, with optional source issue + linkage for later issue shaping - worktree mappings - retained PR and post-review state - review-policy checkpoints diff --git a/docs/spec/tracker-tools.md b/docs/spec/tracker-tools.md index 62b3462ee..c51ab6a5c 100644 --- a/docs/spec/tracker-tools.md +++ b/docs/spec/tracker-tools.md @@ -112,6 +112,10 @@ In either invalid case, `decodex` must fail the attempt rather than infer which - `issue_progress_checkpoint` must keep the routed issue description generic. The full structured checkpoint payload belongs in private runtime execution events, not in the issue description or Linear comments. +- Research-to-execution Decision Contracts are local runtime records, not tracker + tool comments or issue-description payloads. Tracker tools may later publish sparse + public projections or generated issue links after promotion, but they must not copy + the private `decodex.decision_contract/1` payload into Linear. - `issue_progress_checkpoint` must accept only the normalized execution phases `probing`, `implementing`, `verifying`, `blocked`, `ready_for_review`, `review_repair`, `ready_to_land`, and `closeout`. - `issue_progress_checkpoint` must not replace `issue_review_checkpoint`, `issue_review_handoff`, `issue_review_repair_complete`, `issue_closeout_complete`, or `issue_terminal_finalize`. - `decodex` treats `issue_progress_checkpoint` as execution memory only. Checkpoint phase, focus, next action, blockers, or evidence do not by themselves authorize review handoff, repair completion, merge, closeout, or terminal success.