diff --git a/README.md b/README.md index 99d029304..82e88fa13 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,9 @@ cargo run -p decodex --bin decodex -- status --live cargo run -p decodex --bin decodex -- diagnose --json cargo run -p decodex --bin decodex -- maintenance prune --dry-run cargo run -p decodex --bin decodex -- lane steer --run-id --expected-turn-id --message +cargo run -p decodex --bin decodex -- research compile --intent "research X" +cargo run -p decodex --bin decodex -- research compile --input research-design-run.json +cargo run -p decodex --bin decodex -- research promote cargo run -p decodex --bin decodex -- radar refresh-upstream-queue cargo run -p decodex --bin decodex -- radar refresh-release-delta cargo run -p decodex --bin decodex -- radar validate @@ -136,6 +139,15 @@ publishes snapshots every 15 seconds, and Linear-backed queue/status scans run a most every 5 minutes per project unless an operator or agent requests an explicit scan with `POST /api/linear-scan`. +`decodex research compile` is the native Decodex research/design entrypoint. It +accepts minimal natural-language intake or a structured research/design JSON packet, +then persists a `decodex.decision_contract/1` payload in local runtime SQLite. Compile +outcomes distinguish `decision_ready`, `not_decision_ready`, `blocked`, and +`needs_human_decision`. A compiled contract is latent and cannot queue work, mutate +tracker state, set goals, or authorize implementation. `decodex research promote` +records explicit acceptance for a stored contract; only promoted contracts may later +feed issue shaping or internal Execution Program readiness. + ### Install from Source ```sh diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index b50deb940..5b477a681 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -6,7 +6,7 @@ use std::{ }; use clap::{ - Args, Parser, Subcommand, + Args, Parser, Subcommand, ValueEnum, builder::{ Styles, styling::{AnsiColor, Effects}, @@ -34,6 +34,10 @@ use crate::{ RadarValidateRequest, }, recovery::{self, ReviewHandoffDiagnoseRequest, ReviewHandoffRebindRequest}, + research_design::{ + self, ResearchDesignCompileRequest, ResearchDesignOutcome, ResearchDesignPromoteRequest, + ResearchDesignRunInput, + }, runtime, }; @@ -69,6 +73,7 @@ impl Cli { Command::Status(args) => args.run(), Command::Diagnose(args) => args.run(), Command::Evidence(args) => args.run(), + Command::Research(args) => args.run(), Command::Recover(args) => args.run(), Command::ArchiveLinear(args) => args.run(), Command::Maintenance(args) => args.run(), @@ -610,6 +615,127 @@ impl EvidenceCommand { } } +#[derive(Debug, Args)] +struct ResearchCommand { + #[command(flatten)] + project_config: ProjectConfigArgs, + #[command(subcommand)] + command: ResearchSubcommand, +} +impl ResearchCommand { + fn run(&self) -> Result<()> { + match &self.command { + ResearchSubcommand::Compile(args) => args.run(self.project_config.as_path()), + ResearchSubcommand::Promote(args) => args.run(self.project_config.as_path()), + } + } +} + +#[derive(Debug, Args)] +struct ResearchCompileCommand { + /// Structured research/design input JSON. Use `-` to read from stdin. + #[arg(long, value_name = "JSON")] + input: Option, + /// Natural-language research/design intent for minimal intake. + #[arg(long, value_name = "TEXT", conflicts_with = "input")] + intent: Option, + /// Source tracker issue identifier to link to the contract. + #[arg(long, value_name = "ISSUE")] + source_issue: Option, + /// Outcome for minimal natural-language intake. + #[arg(long, value_enum, default_value = "not-decision-ready")] + outcome: ResearchOutcomeArg, + /// Emit structured JSON instead of human-readable text. + #[arg(long)] + json: bool, +} +impl ResearchCompileCommand { + fn run(&self, config_path: Option<&Path>) -> Result<()> { + let input = self.research_input()?; + let report = + research_design::run_compile(ResearchDesignCompileRequest { config_path, input })?; + + if self.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!( + "research compile {}: contract={} status={} ready_after_promotion={} authority={}", + research_outcome_label(report.outcome), + report.contract_id, + report.contract_status.as_str(), + report.issue_generation_ready_after_promotion, + report.execution_authority_granted + ); + println!("{}", report.feedback); + } + + Ok(()) + } + + fn research_input(&self) -> Result { + match (&self.input, &self.intent) { + (Some(path), None) => read_research_input(path), + (None, Some(intent)) => Ok(ResearchDesignRunInput::from_intent( + intent.clone(), + self.source_issue.clone(), + self.outcome.into(), + )), + (None, None) => + eyre::bail!("research compile requires either --input or --intent ."), + (Some(_), Some(_)) => + eyre::bail!("research compile accepts --input or --intent, not both."), + } + } +} + +#[derive(Debug, Args)] +struct ResearchPromoteCommand { + /// Decision Contract identifier to promote. + #[arg(value_name = "CONTRACT_ID")] + contract_id: String, + /// Actor accepting the contract. + #[arg(long, value_name = "TEXT", default_value = "operator")] + accepted_by: String, + /// Acceptance source, usually conversation or runtime policy. + #[arg(long, value_name = "TEXT", default_value = "conversation")] + acceptance_source: String, + /// RFC3339 acceptance timestamp. Defaults to current UTC time. + #[arg(long, value_name = "RFC3339")] + accepted_at: Option, + /// Optional acceptance reason. + #[arg(long, value_name = "TEXT")] + reason: Option, + /// Emit structured JSON instead of human-readable text. + #[arg(long)] + json: bool, +} +impl ResearchPromoteCommand { + fn run(&self, config_path: Option<&Path>) -> Result<()> { + let report = research_design::run_promote(ResearchDesignPromoteRequest { + config_path, + contract_id: &self.contract_id, + accepted_by: &self.accepted_by, + accepted_at: self.accepted_at.as_deref(), + acceptance_source: &self.acceptance_source, + promotion_reason: self.reason.clone(), + })?; + + if self.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!( + "research promote: contract={} status={} authority={} ready={}", + report.contract_id, + report.contract_status.as_str(), + report.execution_authority_granted, + report.ready_for_issue_shaping + ); + } + + Ok(()) + } +} + #[derive(Debug, Args)] struct RecoverCommand { #[command(flatten)] @@ -1319,6 +1445,8 @@ enum Command { Diagnose(DiagnoseCommand), /// Inspect local-only private execution evidence for one issue or run. Evidence(EvidenceCommand), + /// Compile or promote Decodex-native research/design contracts. + Research(ResearchCommand), /// Diagnose or explicitly repair supported retained-lane recovery cases. Recover(RecoverCommand), /// Dry-run or archive old terminal Linear issues by repo label. @@ -1378,6 +1506,33 @@ enum LaneSubcommand { Steer(LaneSteerCommand), } +#[derive(Debug, Subcommand)] +enum ResearchSubcommand { + /// Compile bounded research/design input into a latent Decision Contract. + Compile(ResearchCompileCommand), + /// Promote an accepted Decision Contract into execution authority. + Promote(ResearchPromoteCommand), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +#[value(rename_all = "kebab-case")] +enum ResearchOutcomeArg { + DecisionReady, + NotDecisionReady, + Blocked, + NeedsHumanDecision, +} +impl From for ResearchDesignOutcome { + fn from(value: ResearchOutcomeArg) -> Self { + match value { + ResearchOutcomeArg::DecisionReady => Self::DecisionReady, + ResearchOutcomeArg::NotDecisionReady => Self::NotDecisionReady, + ResearchOutcomeArg::Blocked => Self::Blocked, + ResearchOutcomeArg::NeedsHumanDecision => Self::NeedsHumanDecision, + } + } +} + #[derive(Debug, Subcommand)] enum RecoverSubcommand { /// Recover retained review lanes whose handoff marker is missing. @@ -1448,6 +1603,15 @@ fn lane_steer_report_is_failure(report: &LaneSteerReport) -> bool { matches!(report.outcome.as_str(), "rejected" | "failed" | "timed_out" | "fallback") } +fn research_outcome_label(outcome: ResearchDesignOutcome) -> &'static str { + match outcome { + ResearchDesignOutcome::DecisionReady => "decision-ready", + ResearchDesignOutcome::NotDecisionReady => "not-decision-ready", + ResearchDesignOutcome::Blocked => "blocked", + ResearchDesignOutcome::NeedsHumanDecision => "needs-human-decision", + } +} + fn render_lane_steer_report(report: &LaneSteerReport) -> String { format!( "lane steer {}: issue={} run_id={} attempt={} expected_turn_id={} current_turn_id={} response_turn_id={} failure_class={} audit_record_id={} delivery_status={}\n", @@ -1464,6 +1628,22 @@ fn render_lane_steer_report(report: &LaneSteerReport) -> String { ) } +fn read_research_input(path: &Path) -> Result { + let raw = if path == Path::new("-") { + let mut raw = String::new(); + + io::stdin().read_to_string(&mut raw)?; + + raw + } else { + fs::read_to_string(path)? + }; + + serde_json::from_str(&raw).map_err(|error| { + eyre::eyre!("Failed to parse research input `{}`: {error}", path.display()) + }) +} + fn read_attempt_request(request: &str) -> Result { let raw = if request == "-" { let mut raw = String::new(); @@ -1504,9 +1684,10 @@ mod tests { RadarLedgerIngestExistingCommand, RadarLedgerSubcommand, RadarLedgerSummaryCommand, RadarRefreshReleaseDeltaCommand, RadarRefreshUpstreamQueueCommand, RadarRenderSignalCommand, RadarSubcommand, RadarValidateCommand, RecoverCommand, - RecoverSubcommand, ReviewHandoffDiagnoseCommand, ReviewHandoffRebindCommand, - ReviewHandoffRecoveryCommand, ReviewHandoffRecoverySubcommand, RunCommand, ServeCommand, - StatusCommand, + RecoverSubcommand, ResearchCommand, ResearchCompileCommand, ResearchOutcomeArg, + ResearchPromoteCommand, ResearchSubcommand, ReviewHandoffDiagnoseCommand, + ReviewHandoffRebindCommand, ReviewHandoffRecoveryCommand, ReviewHandoffRecoverySubcommand, + RunCommand, ServeCommand, StatusCommand, }; #[test] @@ -2224,6 +2405,81 @@ mod tests { )); } + #[test] + fn parses_research_compile_with_intent_and_project_config() { + let cli = Cli::parse_from([ + "decodex", + "research", + "--config", + "./project.toml", + "compile", + "--intent", + "research X", + "--source-issue", + "XY-860", + "--outcome", + "needs-human-decision", + "--json", + ]); + + assert!(matches!( + cli.command, + Command::Research(ResearchCommand { + project_config: ProjectConfigArgs { config: Some(config) }, + command: ResearchSubcommand::Compile(ResearchCompileCommand { + intent: Some(intent), + source_issue: Some(source_issue), + outcome: ResearchOutcomeArg::NeedsHumanDecision, + json: true, + .. + }) + }) if config == Path::new("./project.toml") + && intent == "research X" + && source_issue == "XY-860" + )); + } + + #[test] + fn parses_research_promote_with_acceptance_metadata() { + let cli = Cli::parse_from([ + "decodex", + "research", + "--config", + "./project.toml", + "promote", + "research-design-contract", + "--accepted-by", + "operator", + "--accepted-at", + "2026-06-10T00:00:00Z", + "--acceptance-source", + "conversation", + "--reason", + "push this forward", + "--json", + ]); + + assert!(matches!( + cli.command, + Command::Research(ResearchCommand { + project_config: ProjectConfigArgs { config: Some(config) }, + command: ResearchSubcommand::Promote(ResearchPromoteCommand { + contract_id, + accepted_by, + accepted_at: Some(accepted_at), + acceptance_source, + reason: Some(reason), + json: true, + }) + }) if config == Path::new("./project.toml") + && contract_id == "research-design-contract" + && accepted_by == "operator" + && accepted_at == "2026-06-10T00:00:00Z" + && acceptance_source == "conversation" + && reason == "push this forward" + )); + } + #[test] fn parses_diagnose_with_json_limit_and_project_config() { let cli = Cli::parse_from([ diff --git a/apps/decodex/src/lib.rs b/apps/decodex/src/lib.rs index 8ef19d56a..12999cd82 100644 --- a/apps/decodex/src/lib.rs +++ b/apps/decodex/src/lib.rs @@ -24,6 +24,7 @@ mod prelude { } mod radar; mod recovery; +mod research_design; mod run_control; mod runtime; mod tracker; diff --git a/apps/decodex/src/loop_contract.rs b/apps/decodex/src/loop_contract.rs index 58d5275ef..cc41db38e 100644 --- a/apps/decodex/src/loop_contract.rs +++ b/apps/decodex/src/loop_contract.rs @@ -49,6 +49,8 @@ pub(crate) struct DecisionContract { research_provenance: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] research_evidence: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + research_options: Vec, accepted_authority: DecisionAcceptedAuthority, execution_readiness: DecisionExecutionReadiness, #[serde(skip_serializing_if = "Option::is_none")] @@ -75,6 +77,10 @@ impl DecisionContract { &self.accepted_authority } + pub(crate) fn research_options(&self) -> &[DecisionResearchOption] { + &self.research_options + } + pub(crate) fn execution_readiness(&self) -> &DecisionExecutionReadiness { &self.execution_readiness } @@ -138,6 +144,9 @@ impl DecisionContract { for evidence in &self.research_evidence { evidence.validate()?; } + for option in &self.research_options { + option.validate()?; + } Ok(()) } @@ -303,6 +312,35 @@ impl DecisionResearchEvidence { } } +/// Option comparison retained as non-authoritative research context. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub(crate) struct DecisionResearchOption { + option: String, + #[serde(default)] + tradeoffs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rejected_reason: Option, +} +#[allow(dead_code)] +impl DecisionResearchOption { + pub(crate) fn option(&self) -> &str { + &self.option + } + + fn validate(&self) -> Result<()> { + validate_required("decision contract research_options.option", &self.option)?; + validate_string_list("decision contract research_options.tradeoffs", &self.tradeoffs)?; + validate_optional("decision contract research_options.decision", self.decision.as_deref())?; + + validate_optional( + "decision contract research_options.rejected_reason", + self.rejected_reason.as_deref(), + ) + } +} + /// Proposed or accepted execution authority carried by the contract. #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] pub(crate) struct DecisionAcceptedAuthority { @@ -360,6 +398,12 @@ pub(crate) struct DecisionExecutionReadiness { validation_expectations: Vec, #[serde(default)] risk_notes: Vec, + #[serde(default)] + proposed_issue_summaries: Vec, + #[serde(default)] + conflict_domains: Vec, + #[serde(default)] + queue_intent: Vec, } #[allow(dead_code)] impl DecisionExecutionReadiness { @@ -375,6 +419,14 @@ impl DecisionExecutionReadiness { &self.missing_decisions } + pub(crate) fn proposed_issue_summaries(&self) -> &[String] { + &self.proposed_issue_summaries + } + + pub(crate) fn conflict_domains(&self) -> &[String] { + &self.conflict_domains + } + 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)?; @@ -383,6 +435,12 @@ impl DecisionExecutionReadiness { &self.validation_expectations, )?; validate_string_list("decision contract risk_notes", &self.risk_notes)?; + validate_string_list( + "decision contract proposed_issue_summaries", + &self.proposed_issue_summaries, + )?; + validate_string_list("decision contract conflict_domains", &self.conflict_domains)?; + validate_string_list("decision contract queue_intent", &self.queue_intent)?; match status { DecisionContractStatus::AcceptedPromoted => { diff --git a/apps/decodex/src/research_design.rs b/apps/decodex/src/research_design.rs new file mode 100644 index 000000000..73c071349 --- /dev/null +++ b/apps/decodex/src/research_design.rs @@ -0,0 +1,1166 @@ +//! Decodex-native research/design runner and Decision Contract compiler. + +use std::{ + env, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::{self, Value}; +use sha2::{Digest as _, Sha256}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +use crate::{ + config::ServiceConfig, + loop_contract::{ + DecisionContract, DecisionContractStatus, DecisionPromotion, DecisionPromotionActorKind, + }, + prelude::{Result, eyre}, + runtime, + state::{DecisionContractRecord, StateStore}, +}; + +/// Research/design outcome before any execution authority exists. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ResearchDesignOutcome { + DecisionReady, + NotDecisionReady, + Blocked, + NeedsHumanDecision, +} +impl ResearchDesignOutcome { + fn contract_status(self) -> DecisionContractStatus { + match self { + Self::DecisionReady | Self::NotDecisionReady | Self::Blocked => + DecisionContractStatus::DraftLatent, + Self::NeedsHumanDecision => DecisionContractStatus::NeedsHumanDecision, + } + } +} + +/// Structured bounded research/design input compiled into a latent Decision Contract. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResearchDesignRunInput { + #[serde(skip_serializing_if = "Option::is_none")] + contract_id: Option, + intent: String, + #[serde(skip_serializing_if = "Option::is_none")] + source_issue_identifier: Option, + outcome: ResearchDesignOutcome, + #[serde(default)] + provenance: Vec, + #[serde(default)] + evidence: Vec, + #[serde(default)] + options: Vec, + #[serde(default)] + ai_subwork: Vec, + #[serde(default)] + objectives: Vec, + #[serde(default)] + non_goals: Vec, + #[serde(default)] + constraints: Vec, + #[serde(default)] + assumptions: Vec, + #[serde(default)] + objections: Vec, + #[serde(default)] + unresolved_decisions: Vec, + #[serde(default)] + evidence_gaps: Vec, + #[serde(default)] + blockers: Vec, + #[serde(default)] + stop_conditions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + readiness_summary: Option, + #[serde(default)] + validation_expectations: Vec, + #[serde(default)] + risk_notes: Vec, + #[serde(default)] + proposed_issue_summaries: Vec, + #[serde(default)] + conflict_domains: Vec, + #[serde(default)] + queue_intent: Vec, + #[serde(default)] + private_evidence_refs: Vec, + #[serde(default)] + public_projection_refs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + public_summary: Option, +} +impl ResearchDesignRunInput { + pub(crate) fn from_intent( + intent: impl Into, + source_issue_identifier: Option, + outcome: ResearchDesignOutcome, + ) -> Self { + Self { + contract_id: None, + intent: intent.into(), + source_issue_identifier, + outcome, + provenance: Vec::new(), + evidence: Vec::new(), + options: Vec::new(), + ai_subwork: Vec::new(), + objectives: Vec::new(), + non_goals: Vec::new(), + constraints: Vec::new(), + assumptions: Vec::new(), + objections: Vec::new(), + unresolved_decisions: Vec::new(), + evidence_gaps: Vec::new(), + blockers: Vec::new(), + stop_conditions: Vec::new(), + readiness_summary: None, + validation_expectations: Vec::new(), + risk_notes: Vec::new(), + proposed_issue_summaries: Vec::new(), + conflict_domains: Vec::new(), + queue_intent: Vec::new(), + private_evidence_refs: Vec::new(), + public_projection_refs: Vec::new(), + public_summary: None, + } + } + + pub(crate) fn source_issue_identifier(&self) -> Option<&str> { + self.source_issue_identifier.as_deref() + } +} + +/// Research source that contributed to a compiler run. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResearchProvenanceInput { + kind: String, + reference: String, + summary: String, +} +impl ResearchProvenanceInput { + fn normalized(self) -> Result { + Ok(Self { + kind: normalize_required_text("provenance.kind", self.kind)?, + reference: normalize_required_text("provenance.reference", self.reference)?, + summary: normalize_required_text("provenance.summary", self.summary)?, + }) + } +} + +/// Evidence claim retained as research context, not execution authority. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResearchEvidenceInput { + claim: String, + support: String, + #[serde(skip_serializing_if = "Option::is_none")] + source_ref: Option, +} +impl ResearchEvidenceInput { + fn normalized(self) -> Result { + Ok(Self { + claim: normalize_required_text("evidence.claim", self.claim)?, + support: normalize_required_text("evidence.support", self.support)?, + source_ref: normalize_optional_text("evidence.source_ref", self.source_ref)?, + }) + } +} + +/// Candidate option considered during bounded research/design. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResearchOptionInput { + option: String, + #[serde(default)] + tradeoffs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rejected_reason: Option, +} +impl ResearchOptionInput { + fn normalized(self) -> Result { + Ok(Self { + option: normalize_required_text("options.option", self.option)?, + tradeoffs: normalize_text_list("options.tradeoffs", self.tradeoffs)?, + decision: normalize_optional_text("options.decision", self.decision)?, + rejected_reason: normalize_optional_text( + "options.rejected_reason", + self.rejected_reason, + )?, + }) + } +} + +/// AI-owned subwork folded back into the main coherent contract. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResearchSubworkInput { + worker_kind: String, + objective: String, + outcome: String, + #[serde(default)] + evidence_refs: Vec, +} +impl ResearchSubworkInput { + fn normalized(self) -> Result { + Ok(Self { + worker_kind: normalize_required_text("ai_subwork.worker_kind", self.worker_kind)?, + objective: normalize_required_text("ai_subwork.objective", self.objective)?, + outcome: normalize_required_text("ai_subwork.outcome", self.outcome)?, + evidence_refs: normalize_text_list("ai_subwork.evidence_refs", self.evidence_refs)?, + }) + } + + fn summary(&self) -> String { + if self.evidence_refs.is_empty() { + self.outcome.clone() + } else { + format!("{} Evidence refs: {}.", self.outcome, self.evidence_refs.join(", ")) + } + } +} + +/// Runtime-private evidence pointer retained inside the Decision Contract boundary. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResearchPrivateEvidenceRefInput { + #[serde(skip_serializing_if = "Option::is_none")] + project_id: Option, + 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 ResearchPrivateEvidenceRefInput { + fn normalized(self) -> Result { + Ok(Self { + project_id: normalize_optional_text( + "private_evidence_refs.project_id", + self.project_id, + )?, + issue_id: normalize_required_text("private_evidence_refs.issue_id", self.issue_id)?, + run_id: normalize_required_text("private_evidence_refs.run_id", self.run_id)?, + attempt_number: self.attempt_number, + record_id: self.record_id, + event_type: normalize_optional_text( + "private_evidence_refs.event_type", + self.event_type, + )?, + }) + } +} + +/// Sparse public projection pointer, such as an issue or summary record. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResearchPublicProjectionRefInput { + surface: String, + reference: String, + summary: String, +} +impl ResearchPublicProjectionRefInput { + fn normalized(self) -> Result { + Ok(Self { + surface: normalize_required_text("public_projection_refs.surface", self.surface)?, + reference: normalize_required_text("public_projection_refs.reference", self.reference)?, + summary: normalize_required_text("public_projection_refs.summary", self.summary)?, + }) + } +} + +/// CLI/runtime request for compiling and persisting one research/design contract. +pub(crate) struct ResearchDesignCompileRequest<'a> { + pub(crate) config_path: Option<&'a Path>, + pub(crate) input: ResearchDesignRunInput, +} + +/// CLI/runtime request for promoting an already persisted research/design contract. +pub(crate) struct ResearchDesignPromoteRequest<'a> { + pub(crate) config_path: Option<&'a Path>, + pub(crate) contract_id: &'a str, + pub(crate) accepted_by: &'a str, + pub(crate) accepted_at: Option<&'a str>, + pub(crate) acceptance_source: &'a str, + pub(crate) promotion_reason: Option, +} + +/// Compiler report for one persisted research/design run. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct ResearchDesignRunReport { + pub(crate) outcome: ResearchDesignOutcome, + pub(crate) contract_id: String, + pub(crate) contract_status: DecisionContractStatus, + pub(crate) source_issue_id: Option, + pub(crate) ready_for_issue_shaping: bool, + pub(crate) issue_generation_ready_after_promotion: bool, + pub(crate) execution_authority_granted: bool, + pub(crate) feedback: String, + pub(crate) missing_decisions: Vec, + pub(crate) blockers: Vec, + pub(crate) proposed_issue_summaries: Vec, + pub(crate) conflict_domains: Vec, + pub(crate) private_evidence_ref_count: usize, + pub(crate) public_projection_ref_count: usize, +} +impl ResearchDesignRunReport { + fn from_compilation( + input: &NormalizedResearchDesignInput, + contract: &DecisionContract, + ) -> Self { + Self { + outcome: input.outcome, + contract_id: contract.contract_id().to_owned(), + contract_status: contract.status(), + source_issue_id: input.source_issue_identifier.clone(), + ready_for_issue_shaping: contract.execution_readiness().ready_for_issue_shaping(), + issue_generation_ready_after_promotion: input.ready_for_issue_shaping(), + execution_authority_granted: false, + feedback: default_feedback(input.outcome).to_owned(), + missing_decisions: input.missing_decisions(), + blockers: input.blockers.clone(), + proposed_issue_summaries: contract + .execution_readiness() + .proposed_issue_summaries() + .to_vec(), + conflict_domains: contract.execution_readiness().conflict_domains().to_vec(), + private_evidence_ref_count: input.private_evidence_refs.len(), + public_projection_ref_count: input.public_projection_refs.len(), + } + } + + fn with_record(mut self, record: &DecisionContractRecord) -> Self { + self.contract_id = record.contract_id().to_owned(); + self.contract_status = record.status(); + self.ready_for_issue_shaping = + record.contract().execution_readiness().ready_for_issue_shaping(); + + self + } +} + +/// Promotion report for an accepted research/design contract. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct ResearchDesignPromotionReport { + pub(crate) contract_id: String, + pub(crate) contract_status: DecisionContractStatus, + pub(crate) execution_authority_granted: bool, + pub(crate) ready_for_issue_shaping: bool, +} + +struct ResearchDesignCompilation { + contract: DecisionContract, + report: ResearchDesignRunReport, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct NormalizedResearchDesignInput { + contract_id: String, + intent: String, + source_issue_identifier: Option, + outcome: ResearchDesignOutcome, + provenance: Vec, + evidence: Vec, + options: Vec, + ai_subwork: Vec, + objectives: Vec, + non_goals: Vec, + constraints: Vec, + assumptions: Vec, + objections: Vec, + unresolved_decisions: Vec, + evidence_gaps: Vec, + blockers: Vec, + stop_conditions: Vec, + readiness_summary: String, + validation_expectations: Vec, + risk_notes: Vec, + proposed_issue_summaries: Vec, + conflict_domains: Vec, + queue_intent: Vec, + private_evidence_refs: Vec, + public_projection_refs: Vec, + public_summary: Option, +} +impl NormalizedResearchDesignInput { + fn new(input: ResearchDesignRunInput) -> Result { + let contract_id = match input.contract_id.clone() { + Some(contract_id) => normalize_required_text("contract_id", contract_id)?, + None => generated_contract_id(&input)?, + }; + + Ok(Self { + contract_id, + intent: normalize_required_text("intent", input.intent)?, + source_issue_identifier: normalize_optional_text( + "source_issue_identifier", + input.source_issue_identifier, + )?, + outcome: input.outcome, + provenance: input + .provenance + .into_iter() + .map(ResearchProvenanceInput::normalized) + .collect::>>()?, + evidence: input + .evidence + .into_iter() + .map(ResearchEvidenceInput::normalized) + .collect::>>()?, + options: input + .options + .into_iter() + .map(ResearchOptionInput::normalized) + .collect::>>()?, + ai_subwork: input + .ai_subwork + .into_iter() + .map(ResearchSubworkInput::normalized) + .collect::>>()?, + objectives: normalize_text_list("objectives", input.objectives)?, + non_goals: normalize_text_list("non_goals", input.non_goals)?, + constraints: normalize_text_list("constraints", input.constraints)?, + assumptions: normalize_text_list("assumptions", input.assumptions)?, + objections: normalize_text_list("objections", input.objections)?, + unresolved_decisions: normalize_text_list( + "unresolved_decisions", + input.unresolved_decisions, + )?, + evidence_gaps: normalize_text_list("evidence_gaps", input.evidence_gaps)?, + blockers: normalize_text_list("blockers", input.blockers)?, + stop_conditions: normalize_text_list("stop_conditions", input.stop_conditions)?, + readiness_summary: normalize_optional_text( + "readiness_summary", + input.readiness_summary, + )? + .unwrap_or_else(|| default_feedback(input.outcome).to_owned()), + validation_expectations: normalize_text_list( + "validation_expectations", + input.validation_expectations, + )?, + risk_notes: normalize_text_list("risk_notes", input.risk_notes)?, + proposed_issue_summaries: normalize_text_list( + "proposed_issue_summaries", + input.proposed_issue_summaries, + )?, + conflict_domains: normalize_text_list("conflict_domains", input.conflict_domains)?, + queue_intent: normalize_text_list("queue_intent", input.queue_intent)?, + private_evidence_refs: input + .private_evidence_refs + .into_iter() + .map(ResearchPrivateEvidenceRefInput::normalized) + .collect::>>()?, + public_projection_refs: input + .public_projection_refs + .into_iter() + .map(ResearchPublicProjectionRefInput::normalized) + .collect::>>()?, + public_summary: normalize_optional_text("public_summary", input.public_summary)?, + }) + } + + fn validate_outcome(&self) -> Result<()> { + match self.outcome { + ResearchDesignOutcome::DecisionReady => self.validate_decision_ready(), + ResearchDesignOutcome::NotDecisionReady => Ok(()), + ResearchDesignOutcome::Blocked => self.validate_blocked(), + ResearchDesignOutcome::NeedsHumanDecision => self.validate_needs_human_decision(), + } + } + + fn validate_decision_ready(&self) -> Result<()> { + if self.objectives.is_empty() { + eyre::bail!("decision-ready research requires at least one accepted objective."); + } + if self.evidence.is_empty() { + eyre::bail!("decision-ready research requires at least one evidence claim."); + } + if self.proposed_issue_summaries.is_empty() { + eyre::bail!( + "decision-ready research requires at least one proposed issue summary for downstream shaping." + ); + } + if !self.unresolved_decisions.is_empty() || !self.evidence_gaps.is_empty() { + eyre::bail!( + "decision-ready research cannot carry unresolved decisions or evidence gaps." + ); + } + if !self.blockers.is_empty() { + eyre::bail!("decision-ready research cannot carry unresolved blockers."); + } + + Ok(()) + } + + fn validate_blocked(&self) -> Result<()> { + if self.blockers.is_empty() { + eyre::bail!("blocked research requires at least one blocker."); + } + + Ok(()) + } + + fn validate_needs_human_decision(&self) -> Result<()> { + if self.unresolved_decisions.is_empty() { + eyre::bail!("needs-human-decision research requires an unresolved decision."); + } + + Ok(()) + } + + fn ready_for_issue_shaping(&self) -> bool { + self.outcome == ResearchDesignOutcome::DecisionReady + } + + fn missing_decisions(&self) -> Vec { + let mut missing = Vec::new(); + + missing.extend(self.unresolved_decisions.clone()); + missing.extend(self.evidence_gaps.iter().map(|gap| format!("Evidence gap: {gap}"))); + + if self.outcome == ResearchDesignOutcome::NotDecisionReady && missing.is_empty() { + missing.push(String::from( + "Research is not decision-ready; gather more evidence or narrow the decision.", + )); + } + + missing + } +} + +/// Compile and persist a research/design result into the local runtime store. +pub(crate) fn run_compile( + request: ResearchDesignCompileRequest<'_>, +) -> Result { + let state_store = runtime::open_runtime_store()?; + let config_path = resolve_research_project_config_path(request.config_path, &state_store)?; + let config = ServiceConfig::from_path(&config_path)?; + + runtime::register_project_config(&state_store, &config_path, true)?; + + persist_research_design_run(&state_store, config.service_id(), request.input) +} + +/// Promote an already persisted contract into accepted execution authority. +pub(crate) fn run_promote( + request: ResearchDesignPromoteRequest<'_>, +) -> Result { + let state_store = runtime::open_runtime_store()?; + let config_path = resolve_research_project_config_path(request.config_path, &state_store)?; + let config = ServiceConfig::from_path(&config_path)?; + + runtime::register_project_config(&state_store, &config_path, true)?; + + let accepted_at = match request.accepted_at { + Some(accepted_at) => accepted_at.to_owned(), + None => OffsetDateTime::now_utc().format(&Rfc3339)?, + }; + let promotion = DecisionPromotion::new( + request.accepted_by, + DecisionPromotionActorKind::User, + accepted_at, + request.acceptance_source, + request.promotion_reason, + )?; + let record = promote_research_design_contract( + &state_store, + config.service_id(), + request.contract_id, + promotion, + )?; + + Ok(ResearchDesignPromotionReport { + contract_id: record.contract_id().to_owned(), + contract_status: record.status(), + execution_authority_granted: true, + ready_for_issue_shaping: record.contract().execution_readiness().ready_for_issue_shaping(), + }) +} + +fn persist_research_design_run( + store: &StateStore, + project_id: &str, + input: ResearchDesignRunInput, +) -> Result { + let source_issue_id = input.source_issue_identifier().map(str::to_owned); + let compilation = compile_research_design_run(input, project_id)?; + let record = store.upsert_decision_contract( + project_id, + source_issue_id.as_deref(), + compilation.contract, + )?; + + Ok(ResearchDesignRunReport { source_issue_id, ..compilation.report.with_record(&record) }) +} + +fn promote_research_design_contract( + store: &StateStore, + project_id: &str, + contract_id: &str, + promotion: DecisionPromotion, +) -> Result { + let record = store.promote_decision_contract(project_id, contract_id, promotion)?; + + ensure_contract_authorizes_execution(&record)?; + + Ok(record) +} + +#[allow(dead_code)] +fn ensure_contract_authorizes_execution(record: &DecisionContractRecord) -> Result<()> { + if record.status() != DecisionContractStatus::AcceptedPromoted { + eyre::bail!( + "Research/design contract `{}` is not accepted; refusing to create execution work from unaccepted research.", + record.contract_id() + ); + } + if !record.contract().execution_readiness().ready_for_issue_shaping() { + eyre::bail!( + "Accepted research/design contract `{}` is not ready for issue shaping.", + record.contract_id() + ); + } + if !record.contract().execution_readiness().missing_decisions().is_empty() { + eyre::bail!( + "Accepted research/design contract `{}` still has unresolved decisions.", + record.contract_id() + ); + } + + Ok(()) +} + +fn compile_research_design_run( + input: ResearchDesignRunInput, + project_id: &str, +) -> Result { + let normalized = NormalizedResearchDesignInput::new(input)?; + + normalized.validate_outcome()?; + + let contract = build_decision_contract(&normalized, project_id)?; + let report = ResearchDesignRunReport::from_compilation(&normalized, &contract); + + Ok(ResearchDesignCompilation { contract, report }) +} + +fn build_decision_contract( + input: &NormalizedResearchDesignInput, + project_id: &str, +) -> Result { + let payload = serde_json::json!({ + "schema": crate::loop_contract::DECISION_CONTRACT_SCHEMA, + "record_version": crate::loop_contract::DECISION_CONTRACT_RECORD_VERSION, + "contract_id": input.contract_id, + "status": input.outcome.contract_status(), + "source_intent": { + "summary": input.intent, + "user_utterance": input.intent, + "source_issue_identifier": input.source_issue_identifier, + }, + "research_provenance": research_provenance_json(input), + "research_evidence": research_evidence_json(input), + "research_options": research_options_json(input), + "accepted_authority": { + "accepted_objectives": input.objectives, + "non_goals": input.non_goals, + "constraints": input.constraints, + "assumptions": input.assumptions, + "objections": input.objections, + "stop_conditions": stop_conditions(input), + }, + "execution_readiness": { + "summary": input.readiness_summary, + "ready_for_issue_shaping": input.ready_for_issue_shaping(), + "missing_decisions": input.missing_decisions(), + "validation_expectations": input.validation_expectations, + "risk_notes": risk_notes(input), + "proposed_issue_summaries": input.proposed_issue_summaries, + "conflict_domains": input.conflict_domains, + "queue_intent": queue_intent(input), + }, + "links": { + "generated_issue_ids": [], + "generated_issue_identifiers": [], + "execution_program_node_ids": [], + }, + "evidence_boundary": { + "private_evidence_refs": private_evidence_refs_json(input, project_id), + "public_projection_refs": public_projection_refs_json(input), + "public_summary": input.public_summary, + }, + }); + let contract = serde_json::from_value::(payload)?; + + contract.validate()?; + + Ok(contract) +} + +fn resolve_research_project_config_path( + config_path: Option<&Path>, + state_store: &StateStore, +) -> Result { + if let Some(config_path) = config_path { + return ServiceConfig::resolve_project_config_path(config_path); + } + + let cwd = env::current_dir()?; + + runtime::registered_config_path_for_cwd(state_store, &cwd)?.ok_or_else(|| { + eyre::eyre!( + "No Decodex project config found. Pass this command's --config or register one with `decodex project add `." + ) + }) +} + +fn research_provenance_json(input: &NormalizedResearchDesignInput) -> Vec { + let mut provenance = input + .provenance + .iter() + .map(|item| { + serde_json::json!({ + "kind": item.kind, + "reference": item.reference, + "summary": item.summary, + }) + }) + .collect::>(); + + for subwork in &input.ai_subwork { + provenance.push(serde_json::json!({ + "kind": format!("ai_subwork_{}", subwork.worker_kind), + "reference": subwork.objective, + "summary": subwork.summary(), + })); + } + + provenance +} + +fn research_evidence_json(input: &NormalizedResearchDesignInput) -> Vec { + input + .evidence + .iter() + .map(|item| { + serde_json::json!({ + "claim": item.claim, + "support": item.support, + "source_ref": item.source_ref, + }) + }) + .collect() +} + +fn research_options_json(input: &NormalizedResearchDesignInput) -> Vec { + input + .options + .iter() + .map(|item| { + serde_json::json!({ + "option": item.option, + "tradeoffs": item.tradeoffs, + "decision": item.decision, + "rejected_reason": item.rejected_reason, + }) + }) + .collect() +} + +fn private_evidence_refs_json( + input: &NormalizedResearchDesignInput, + project_id: &str, +) -> Vec { + input + .private_evidence_refs + .iter() + .map(|item| { + serde_json::json!({ + "project_id": item.project_id.as_deref().unwrap_or(project_id), + "issue_id": item.issue_id, + "run_id": item.run_id, + "attempt_number": item.attempt_number, + "record_id": item.record_id, + "event_type": item.event_type, + }) + }) + .collect() +} + +fn public_projection_refs_json(input: &NormalizedResearchDesignInput) -> Vec { + input + .public_projection_refs + .iter() + .map(|item| { + serde_json::json!({ + "surface": item.surface, + "reference": item.reference, + "summary": item.summary, + }) + }) + .collect() +} + +fn stop_conditions(input: &NormalizedResearchDesignInput) -> Vec { + let mut stop_conditions = input.stop_conditions.clone(); + + for blocker in &input.blockers { + stop_conditions.push(format!("Stop research promotion until blocker resolves: {blocker}")); + } + + stop_conditions +} + +fn risk_notes(input: &NormalizedResearchDesignInput) -> Vec { + let mut risk_notes = input.risk_notes.clone(); + + if input.outcome == ResearchDesignOutcome::NotDecisionReady { + risk_notes.push(String::from( + "Research is not decision-ready and must not become implementation work.", + )); + } + + for blocker in &input.blockers { + risk_notes.push(format!("Research/design blocker: {blocker}")); + } + + risk_notes +} + +fn queue_intent(input: &NormalizedResearchDesignInput) -> Vec { + if !input.queue_intent.is_empty() { + return input.queue_intent.clone(); + } + if input.proposed_issue_summaries.is_empty() { + return Vec::new(); + } + + vec![String::from("Shape generated issues only after explicit Decision Contract promotion.")] +} + +fn default_feedback(outcome: ResearchDesignOutcome) -> &'static str { + match outcome { + ResearchDesignOutcome::DecisionReady => + "Decision-ready research/design output is stored as a latent contract until promotion.", + ResearchDesignOutcome::NotDecisionReady => + "Research/design output is not decision-ready and must not become implementation work.", + ResearchDesignOutcome::Blocked => + "Research/design output is blocked; resolve blockers before promotion.", + ResearchDesignOutcome::NeedsHumanDecision => + "Research/design output needs an explicit human decision before execution authority exists.", + } +} + +fn generated_contract_id(input: &ResearchDesignRunInput) -> Result { + let slug = intent_slug(&input.intent); + let encoded = serde_json::to_vec(input)?; + let digest = Sha256::digest(&encoded); + let mut hash = String::with_capacity(12); + + for byte in digest.iter().take(6) { + hash.push(char::from(b"0123456789abcdef"[(byte >> 4) as usize])); + hash.push(char::from(b"0123456789abcdef"[(byte & 0x0f) as usize])); + } + + Ok(format!("research-design-{slug}-{hash}")) +} + +fn intent_slug(intent: &str) -> String { + let mut slug = String::new(); + let mut previous_dash = false; + + for character in intent.chars() { + if character.is_ascii_alphanumeric() { + slug.push(character.to_ascii_lowercase()); + + previous_dash = false; + } else if !previous_dash && !slug.is_empty() { + slug.push('-'); + + previous_dash = true; + } + if slug.len() >= 40 { + break; + } + } + + while slug.ends_with('-') { + slug.pop(); + } + + if slug.is_empty() { String::from("research") } else { slug } +} + +fn normalize_required_text(name: &str, value: impl Into) -> Result { + let value = value.into(); + let value = value.trim(); + + if value.is_empty() { + eyre::bail!("{name} must not be empty."); + } + + Ok(value.to_owned()) +} + +fn normalize_optional_text(name: &str, value: Option) -> Result> { + value.map(|value| normalize_required_text(name, value)).transpose() +} + +fn normalize_text_list(name: &str, values: Vec) -> Result> { + values.into_iter().map(|value| normalize_required_text(name, value)).collect() +} + +#[cfg(test)] +mod tests { + use crate::{ + loop_contract::{DecisionContractStatus, DecisionPromotion, DecisionPromotionActorKind}, + research_design::{ + self, ResearchDesignOutcome, ResearchDesignRunInput, ResearchEvidenceInput, + ResearchOptionInput, ResearchPrivateEvidenceRefInput, ResearchProvenanceInput, + ResearchPublicProjectionRefInput, ResearchSubworkInput, + }, + state::StateStore, + }; + + fn decision_ready_input() -> ResearchDesignRunInput { + ResearchDesignRunInput { + contract_id: Some(String::from("research-design-contract")), + intent: String::from("research Decodex native research runner"), + source_issue_identifier: Some(String::from("XY-860")), + outcome: ResearchDesignOutcome::DecisionReady, + provenance: vec![ResearchProvenanceInput { + kind: String::from("spec"), + reference: String::from("docs/spec/loop-runtime.md"), + summary: String::from("Research output is latent until accepted or promoted."), + }], + evidence: vec![ResearchEvidenceInput { + claim: String::from("Decision-ready research can shape downstream issues."), + support: String::from( + "The compiler carries objectives, validation expectations, and issue summaries.", + ), + source_ref: Some(String::from("docs/spec/loop-runtime.md")), + }], + options: vec![ResearchOptionInput { + option: String::from("Compile to Decision Contract"), + tradeoffs: vec![String::from("Preserves the existing runtime authority boundary.")], + decision: Some(String::from("Use the existing Decision Contract schema.")), + rejected_reason: None, + }], + ai_subwork: vec![ResearchSubworkInput { + worker_kind: String::from("scout"), + objective: String::from("Inspect predecessor contract surfaces."), + outcome: String::from("Found existing Decision Contract persistence."), + evidence_refs: vec![String::from("XY-852")], + }], + objectives: vec![String::from( + "Implement a native research/design compiler for Decodex work.", + )], + non_goals: vec![String::from("Do not auto-execute latent research.")], + constraints: vec![String::from("Store private evidence in runtime-local state.")], + assumptions: vec![String::from( + "Downstream issue shaping will consume only promoted contracts.", + )], + objections: vec![String::from("Promotion must remain explicit.")], + unresolved_decisions: Vec::new(), + evidence_gaps: Vec::new(), + blockers: Vec::new(), + stop_conditions: vec![String::from("Stop if promotion authority is missing.")], + readiness_summary: Some(String::from( + "Ready for issue shaping after explicit promotion.", + )), + validation_expectations: vec![String::from("Run the registered Decodex gate.")], + risk_notes: vec![String::from("Do not expose internal graph mechanics.")], + proposed_issue_summaries: vec![String::from( + "Wire natural-language research trigger into Decodex plugin surface.", + )], + conflict_domains: vec![String::from("runtime")], + queue_intent: Vec::new(), + private_evidence_refs: vec![ResearchPrivateEvidenceRefInput { + project_id: None, + issue_id: String::from("XY-860"), + run_id: String::from("run-860"), + attempt_number: 1, + record_id: Some(7), + event_type: Some(String::from("research_design_result")), + }], + public_projection_refs: vec![ResearchPublicProjectionRefInput { + surface: String::from("linear"), + reference: String::from("XY-860"), + summary: String::from("Sparse public issue reference only."), + }], + public_summary: Some(String::from("Latent research/design contract.")), + } + } + + fn sample_promotion() -> DecisionPromotion { + DecisionPromotion::new( + "operator", + DecisionPromotionActorKind::User, + "2026-06-10T00:00:00Z", + "conversation", + Some(String::from("User asked to push this forward.")), + ) + .expect("sample promotion should validate") + } + + #[test] + fn decision_ready_research_persists_latent_contract() { + let store = StateStore::open_in_memory().expect("store should open"); + let report = + research_design::persist_research_design_run(&store, "decodex", decision_ready_input()) + .expect("run should persist"); + + assert_eq!(report.outcome, ResearchDesignOutcome::DecisionReady); + assert_eq!(report.contract_status, DecisionContractStatus::DraftLatent); + assert_eq!(report.source_issue_id.as_deref(), Some("XY-860")); + assert!(report.ready_for_issue_shaping); + assert!(report.issue_generation_ready_after_promotion); + assert!(!report.execution_authority_granted); + assert_eq!(report.private_evidence_ref_count, 1); + assert_eq!(report.public_projection_ref_count, 1); + + let record = store + .decision_contract("decodex", "research-design-contract") + .expect("contract lookup should work") + .expect("contract should exist"); + + assert_eq!(record.status(), DecisionContractStatus::DraftLatent); + assert_eq!(record.contract().research_options().len(), 1); + assert_eq!( + record.contract().execution_readiness().proposed_issue_summaries(), + &[String::from("Wire natural-language research trigger into Decodex plugin surface.")] + ); + assert!( + store + .list_linear_execution_events("decodex", "XY-860") + .expect("linear cache should read") + .is_empty(), + "research contracts must not mirror private payloads to Linear" + ); + } + + #[test] + fn not_decision_ready_research_records_feedback_without_promoting() { + let store = StateStore::open_in_memory().expect("store should open"); + let mut input = decision_ready_input(); + + input.contract_id = Some(String::from("not-ready-contract")); + input.outcome = ResearchDesignOutcome::NotDecisionReady; + + input.objectives.clear(); + input.proposed_issue_summaries.clear(); + + input.unresolved_decisions = + vec![String::from("Choose whether runtime or plugin UX owns first exposure.")]; + + let report = research_design::persist_research_design_run(&store, "decodex", input) + .expect("run should persist"); + let record = store + .decision_contract("decodex", "not-ready-contract") + .expect("contract lookup should work") + .expect("contract should exist"); + + assert_eq!(report.outcome, ResearchDesignOutcome::NotDecisionReady); + assert_eq!(record.status(), DecisionContractStatus::DraftLatent); + assert!(!record.contract().execution_readiness().ready_for_issue_shaping()); + assert!(report.feedback.contains("must not become implementation work")); + assert!(research_design::ensure_contract_authorizes_execution(&record).is_err()); + } + + #[test] + fn blocked_and_needs_human_decision_outcomes_stay_distinct() { + let mut blocked = decision_ready_input(); + + blocked.contract_id = Some(String::from("blocked-contract")); + blocked.outcome = ResearchDesignOutcome::Blocked; + blocked.blockers = vec![String::from("Required source is unavailable.")]; + + blocked.objectives.clear(); + blocked.evidence.clear(); + blocked.proposed_issue_summaries.clear(); + + let blocked_report = research_design::persist_research_design_run( + &StateStore::open_in_memory().expect("store should open"), + "decodex", + blocked, + ) + .expect("blocked run should persist"); + + assert_eq!(blocked_report.outcome, ResearchDesignOutcome::Blocked); + assert_eq!(blocked_report.contract_status, DecisionContractStatus::DraftLatent); + assert_eq!(blocked_report.blockers, vec![String::from("Required source is unavailable.")]); + + let mut human = decision_ready_input(); + + human.contract_id = Some(String::from("human-decision-contract")); + human.outcome = ResearchDesignOutcome::NeedsHumanDecision; + human.unresolved_decisions = vec![String::from("Choose the production architecture.")]; + + human.objectives.clear(); + human.evidence.clear(); + human.proposed_issue_summaries.clear(); + + let human_report = research_design::persist_research_design_run( + &StateStore::open_in_memory().expect("store should open"), + "decodex", + human, + ) + .expect("human decision run should persist"); + + assert_eq!(human_report.outcome, ResearchDesignOutcome::NeedsHumanDecision); + assert_eq!(human_report.contract_status, DecisionContractStatus::NeedsHumanDecision); + assert_eq!( + human_report.missing_decisions, + vec![String::from("Choose the production architecture.")] + ); + } + + #[test] + fn explicit_promotion_grants_execution_authority() { + let store = StateStore::open_in_memory().expect("store should open"); + + research_design::persist_research_design_run(&store, "decodex", decision_ready_input()) + .expect("run should persist"); + + let promoted = research_design::promote_research_design_contract( + &store, + "decodex", + "research-design-contract", + sample_promotion(), + ) + .expect("promotion should succeed"); + + assert_eq!(promoted.status(), DecisionContractStatus::AcceptedPromoted); + assert!(research_design::ensure_contract_authorizes_execution(&promoted).is_ok()); + } + + #[test] + fn unaccepted_research_refuses_auto_execution() { + let store = StateStore::open_in_memory().expect("store should open"); + + research_design::persist_research_design_run(&store, "decodex", decision_ready_input()) + .expect("run should persist"); + + let record = store + .decision_contract("decodex", "research-design-contract") + .expect("contract lookup should work") + .expect("contract should exist"); + let error = research_design::ensure_contract_authorizes_execution(&record) + .expect_err("latent research must not authorize execution"); + + assert!( + error + .to_string() + .contains("refusing to create execution work from unaccepted research") + ); + } +} diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index d14392a0b..7d055b940 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -69,6 +69,23 @@ An empty `POST /api/linear-scan` queues a scan for all enabled projects. Request consumed by the next 15-second control-plane tick and still respect any active tracker rate-limit backoff. +Research/design work has a runtime-local command path: + +```sh +decodex research compile --intent "research X" +decodex research compile --input research-design-run.json +decodex research promote +``` + +`research compile` writes a local Decision Contract candidate into runtime SQLite and +returns a bounded outcome: `decision_ready`, `not_decision_ready`, `blocked`, or +`needs_human_decision`. It may retain private evidence references, option comparisons, +proposed issue summaries, conflict domains, and queue intent for later issue shaping, +but it does not enqueue issues, mutate Linear state, set goals, or start lane +execution. `research promote` records the accepted boundary for an existing contract; +later issue generation or Execution Program readiness must consume the promoted +contract instead of treating a research summary as authority. + Lane inspect and interrupt are local control APIs, not dashboard UI actions: ```sh diff --git a/docs/reference/research-runs.md b/docs/reference/research-runs.md index dd120a784..37c1ef1bb 100644 --- a/docs/reference/research-runs.md +++ b/docs/reference/research-runs.md @@ -20,6 +20,10 @@ results. documentation lanes. - A research run may contain useful evidence, alternatives, and objections, but it does not by itself define repository truth. +- For Decodex-specific loop-runtime work, `decodex research compile` supersedes this + artifact lane as the runtime-owned path. It stores a + `decodex.decision_contract/1` payload in local runtime SQLite and leaves the result + latent until explicit promotion. ## Promotion rules @@ -31,6 +35,9 @@ results. `docs/reference/`. - If a research result records a durable tradeoff or design choice, promote the conclusion into `docs/decisions/`. +- If a Decodex-native research/design result should feed issue shaping or unattended + execution, promote the stored Decision Contract first. Do not infer acceptance from + a research summary or from a `docs/research/` JSON artifact. ## Practical reading rule diff --git a/docs/spec/loop-runtime.md b/docs/spec/loop-runtime.md index d566f6073..94a7734e2 100644 --- a/docs/spec/loop-runtime.md +++ b/docs/spec/loop-runtime.md @@ -54,10 +54,12 @@ through the lane runtime contract. ## Research/Decision Stage -Decodex should own a native Research/Decision stage for Decodex work. That stage may -eventually replace the external research skill for Decodex planning, but the current -external `docs/research/` artifact lane remains supporting evidence only until a -Decodex-native adapter is implemented. +Decodex owns a native Research/Decision compiler for Decodex work. That stage accepts +natural-language intent such as `research X` plus bounded research/design evidence +when available, then stores a local Decision Contract candidate. It supersedes the +external research skill for Decodex runtime authority: the old external +`docs/research/` artifact lane remains supporting evidence and inspiration for method, +but it is not the authority surface for Decodex loop state. A Research/Decision stage may produce a latent Loop/Decision Contract with: @@ -75,6 +77,19 @@ 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. +Native research/design compiler outcomes are: + +| Outcome | Contract status | Meaning | +| --- | --- | --- | +| `decision_ready` | `draft_latent` | Bounded evidence, option comparison, assumptions, objections, and proposed issue-readiness data are sufficient for downstream issue shaping after promotion. It is still not execution authority while latent. | +| `not_decision_ready` | `draft_latent` | The run preserved useful evidence or objections, but missing evidence or unresolved direction means it must not become implementation work. | +| `blocked` | `draft_latent` | The run cannot finish its bounded research/design pass because a non-decision blocker must be resolved first. | +| `needs_human_decision` | `needs_human_decision` | The package needs explicit human direction before promotion or execution can be considered. | + +The compiler may fold AI-owned subwork into the main contract as provenance and +evidence, but the main contract remains coherent and the user does not choose +subagents, graph ids, lanes, or goal commands. + ## Decision Contract Schema The runtime-facing Decision Contract payload is versioned as @@ -89,8 +104,9 @@ The payload carries these top-level fields: | `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. | +| `research_options` | Non-authoritative option comparisons retained with tradeoffs, selected decision notes, or rejected-option reasons. | | `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. | +| `execution_readiness` | Natural-language readiness summary, missing decisions, validation expectations, risk notes, proposed issue summaries, conflict domains, and queue intent. 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. | diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 361bb3185..f280d839f 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -60,6 +60,9 @@ state or this state machine. paths and credentials plus `WORKFLOW.md` for execution policy. They do not store runtime ownership. - The local SQLite database must not become a replacement for the human issue backlog. It is the operator control-plane state for this machine. +- `decodex research compile` and `decodex research promote` are runtime-local + Decision Contract writes. They update the SQLite `decision_contracts` surface and do + not by themselves create Linear issues, queue intent, goals, or executable lanes. The evidence boundary is ordered from private runtime authority to public collaboration mirror: diff --git a/plugins/decodex/skills/decodex/SKILL.md b/plugins/decodex/skills/decodex/SKILL.md index c92ebb7e4..2bf1941d5 100644 --- a/plugins/decodex/skills/decodex/SKILL.md +++ b/plugins/decodex/skills/decodex/SKILL.md @@ -18,6 +18,9 @@ specs. Decodex has two supported use modes: landing, closeout, and operator status. - Planning support: agents shape Decodex-friendly issue sets, queue strategy, dependency boundaries, and concurrency before retained-lane automation starts. +- Research/design mode: Decodex compiles ambiguous planning intent into local + Decision Contracts with `decodex research compile`, then records acceptance with + `decodex research promote` before any execution authority exists. ## First Steps @@ -38,6 +41,9 @@ specs. Decodex has two supported use modes: ## Authority Split - Runtime behavior belongs to `apps/decodex/src/` and `docs/spec/`. +- Decodex-native research/design behavior belongs to `apps/decodex/src/research_design.rs` + and `docs/spec/loop-runtime.md`; external research artifacts are supporting + evidence only for Decodex runtime semantics. - Operator lane-control capabilities belong to `docs/spec/lane-control.md`, with the low-level app-server method boundary in `docs/spec/app-server.md`. - Operator procedures belong to `docs/runbook/`. @@ -59,3 +65,6 @@ Treat this plugin and the Decodex repository docs as the Decodex-specific author shell state when a registered project config declares them. - Do not turn a manual CLI task into retained-lane automation unless the user asks for automation or the current registered workflow requires it. +- Do not treat a research summary, `docs/research/` artifact, or compiled latent + contract as accepted execution authority. A Decodex research/design result must be + promoted before later issue shaping or Execution Program readiness can consume it.