From 0d48c107de0c32b388233fc74ea0980db424a809 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 9 Jun 2026 23:19:15 +0800 Subject: [PATCH 1/2] {"schema":"decodex/commit/1","summary":"Add research-to-execution contract schema and latent handoff model","authority":"XY-852"} --- .../research_x_latent_contract.json | 96 +++ apps/decodex/src/lib.rs | 1 + apps/decodex/src/loop_contract.rs | 730 ++++++++++++++++++ apps/decodex/src/state.rs | 1 + apps/decodex/src/state/internal.rs | 223 +++++- apps/decodex/src/state/models.rs | 51 ++ apps/decodex/src/state/store.rs | 198 +++++ apps/decodex/src/state/tests.rs | 191 +++++ docs/reference/operator-control-plane.md | 5 +- docs/spec/linear-execution-ledger.md | 4 + docs/spec/loop-runtime.md | 44 ++ docs/spec/runtime.md | 9 +- docs/spec/tracker-tools.md | 4 + 13 files changed, 1553 insertions(+), 4 deletions(-) create mode 100644 apps/decodex/fixtures/decision_contract/research_x_latent_contract.json create mode 100644 apps/decodex/src/loop_contract.rs 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..0ecef8bd2 --- /dev/null +++ b/apps/decodex/src/loop_contract.rs @@ -0,0 +1,730 @@ +//! 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()?; + + self.status = DecisionContractStatus::AcceptedPromoted; + self.promotion = Some(promotion); + + self.validate() + } + + 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)?; + + if !self.execution_readiness.missing_decisions.iter().any(|existing| existing == &reason) { + self.execution_readiness.missing_decisions.push(reason); + } + + self.status = DecisionContractStatus::NeedsHumanDecision; + self.promotion = None; + + self.validate() + } + + pub(crate) fn reject_or_supersede( + &mut self, + superseded_by_contract_id: Option, + ) -> Result<()> { + if let Some(contract_id) = superseded_by_contract_id { + validate_required("decision contract superseded_by_contract_id", &contract_id)?; + + self.links.superseded_by_contract_id = Some(contract_id); + } + + self.status = DecisionContractStatus::RejectedSuperseded; + + self.validate() + } +} + +/// 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; + + assert!(contract.promote(sample_promotion()).is_err()); + + let mut contract = latent_research_contract_fixture(); + + contract + .execution_readiness + .missing_decisions + .push(String::from("Choose the first generated issue.")); + + assert!(contract.promote(sample_promotion()).is_err()); + } + + #[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()); + } +} 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 ccf227d9b..a17af6de3 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(()) @@ -480,6 +483,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#" @@ -488,7 +516,7 @@ CREATE TABLE IF NOT EXISTS schema_meta ( value TEXT NOT NULL ); INSERT INTO schema_meta (key, value) -VALUES ('schema_version', '8') +VALUES ('schema_version', '9') ON CONFLICT(key) DO UPDATE SET value = excluded.value; "#, )?; @@ -507,6 +535,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)?; @@ -546,6 +575,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)?; @@ -568,6 +598,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(()) @@ -746,6 +780,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])?; @@ -784,6 +849,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, @@ -1386,6 +1455,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, \ @@ -1693,6 +1832,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, @@ -2671,6 +2853,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( @@ -3450,6 +3661,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 ed7ecee9a..3643d9ff6 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 0dd0d5ecb..aafde8484 100644 --- a/apps/decodex/src/state/store.rs +++ b/apps/decodex/src/state/store.rs @@ -255,6 +255,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) } @@ -1519,6 +1526,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()?; @@ -2020,6 +2179,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(()); @@ -2417,6 +2591,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 9545e1020..11fe1bd30 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, @@ -2141,6 +2181,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"); @@ -2195,6 +2238,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") @@ -2533,6 +2579,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 a4cab474b..6eb1e1223 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, 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, 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 9475cd821..8e9942f97 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 6170e6825..077108d62 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. 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. | @@ -119,7 +120,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. @@ -403,6 +406,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 69a4fd296..79bba7e61 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. From 3d43cadeb3989e25ce7901de2094049b7eede30c Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 10 Jun 2026 00:39:02 +0800 Subject: [PATCH 2/2] {"schema":"decodex/commit/1","summary":"Make decision contract transitions atomic","authority":"XY-852"} --- apps/decodex/src/loop_contract.rs | 77 ++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/apps/decodex/src/loop_contract.rs b/apps/decodex/src/loop_contract.rs index 0ecef8bd2..58d5275ef 100644 --- a/apps/decodex/src/loop_contract.rs +++ b/apps/decodex/src/loop_contract.rs @@ -158,10 +158,16 @@ impl DecisionContract { promotion.validate()?; - self.status = DecisionContractStatus::AcceptedPromoted; - self.promotion = Some(promotion); + let mut candidate = self.clone(); - self.validate() + 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<()> { @@ -185,29 +191,46 @@ impl DecisionContract { validate_required("decision contract human-decision reason", &reason)?; - if !self.execution_readiness.missing_decisions.iter().any(|existing| existing == &reason) { - self.execution_readiness.missing_decisions.push(reason); + let mut candidate = self.clone(); + + if !candidate + .execution_readiness + .missing_decisions + .iter() + .any(|existing| existing == &reason) + { + candidate.execution_readiness.missing_decisions.push(reason); } - self.status = DecisionContractStatus::NeedsHumanDecision; - self.promotion = None; + candidate.status = DecisionContractStatus::NeedsHumanDecision; + candidate.promotion = None; - self.validate() + 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)?; - self.links.superseded_by_contract_id = Some(contract_id); + candidate.links.superseded_by_contract_id = Some(contract_id); } - self.status = DecisionContractStatus::RejectedSuperseded; + candidate.status = DecisionContractStatus::RejectedSuperseded; - self.validate() + candidate.validate()?; + + *self = candidate; + + Ok(()) } } @@ -686,7 +709,13 @@ mod tests { 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(); @@ -695,7 +724,13 @@ mod tests { .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] @@ -727,4 +762,24 @@ mod tests { 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" + ); + } }