Skip to content

Commit 9a646a6

Browse files
authored
Close all 7 findings from the end-to-end claims audit (round 2) (#11)
* fix(executor,cli): execute SQL via provider registry; refuse to fake SQL success SQL tasks now route through ProviderRegistry built from conduit.yaml connections in run/apply/backfill/serve. The subprocess fallback that printed 'SQL execution completed' with rows_affected=0 is now a hard error naming the unconfigured connection. (Claims-audit finding 1) Also: cmd_run's final failure message now surfaces the actual task error (e.g. the missing-connection message) to stderr instead of a generic "see task output above", and the native SQL provider's completion log line was reworded to "SQL execution finished via provider" so it can't be confused with the deleted fake-stub message. Signed-off-by: Jayveer Singh <[email protected]> * fix(cli): apply exits non-zero when a task fails or errors Both failure branches returned Ok(()) so CI gating on conduit apply was impossible. (Claims-audit finding 2, CLI half) Signed-off-by: Jayveer Singh <[email protected]> * feat(planner,cli): reject stale plan files via base environment version DeploymentPlan now records the environment revision it was generated against; apply refuses a plan whose base version no longer matches the live environment, and refuses plans targeting a different environment. Docs updated to the real conflict output. (Claims-audit finding 3) Signed-off-by: Jayveer Singh <[email protected]> * feat(cli): validate data contracts during apply; block on Error severity cmd_apply now evaluates each executed task's contracts against its emitted evidence via ContractEvaluator. Error-severity failures abort before the environment is updated, exit non-zero, and print the DeploymentValidation summary. (Claims-audit finding 5, contracts half) Signed-off-by: Jayveer Singh <[email protected]> * test(cli): make passing-contracts apply test assert the new evaluation output Signed-off-by: Jayveer Singh <[email protected]> * feat(cli,executor): wire incremental engine + watermark persistence into run/apply Tasks with incremental config now get a real IncrementalContext (env vars via TaskContext.extra_env, SQL rewritten via rewrite_sql), emitted watermarks advance a WatermarkStore persisted at .conduit/watermarks.json, and --full-refresh actually forces a full refresh. (Claims-audit finding 5, incremental half) Also fixes a prerequisite gap found while wiring this up: the YAML `incremental:` block was parsed into YamlIncrementalConfig but never attached to the compiled Task (ParsedTask had no incremental field and resolver.rs hardcoded `incremental: None`), so declaring `incremental:` on a task was silently a no-op. yaml_parser.rs now resolves it via the existing resolve_incremental_config and threads it through ParsedTask into Task.incremental. Signed-off-by: Jayveer Singh <[email protected]> * fix(api,cli): thread the requested environment through scheduler config and task context trigger_run now inserts environment/triggered_by into the scheduler run config (the scheduler reads them from there); the serve executor and conduit run use the run's environment instead of hardcoding production; run gains --env. (Claims-audit finding 4, threading half) Signed-off-by: Jayveer Singh <[email protected]> * fix(api): reopen persistent state on serve startup instead of starting blank AppState now opens the durable snapshots_db (shared path with the CLI), loads environments.json (persisting after every env mutation), and rehydrates the run cache from the durable event log. Restarting serve no longer loses the operational view. (Claims-audit finding 4, persistence half) Signed-off-by: Jayveer Singh <[email protected]> * feat(api): POST /apply executes the stored plan and updates the environment Plans generated via POST /plan are cached by id; apply looks up the reviewed plan, enforces target-environment and base-version (409 on stale), executes tasks through the provider registry, validates contracts, stores snapshots, and records the env update with history. (Claims-audit finding 2, API half) Signed-off-by: Jayveer Singh <[email protected]> * feat(cli): honor run --max-tasks and backfill --max-concurrent conduit run executes dispatched tasks on a semaphore-bounded pool instead of serially awaiting each; backfill runs partitions through a JoinSet bounded by --max-concurrent. (Claims-audit finding 7, concurrency half) Signed-off-by: Jayveer Singh <[email protected]> * feat(cli,distributed): real worker/cluster-status/drain over gRPC conduit worker now runs the real gRPC worker runtime; cluster status calls the ClusterStatus RPC; cluster drain uses a new DrainWorker RPC whose directive is delivered on the worker's next heartbeat. Worker-side SQL stub now fails honestly instead of reporting success. (Claims-audit finding 6, part A) Signed-off-by: Jayveer Singh <[email protected]> * test(distributed): cover the heartbeat drain-directive branch Signed-off-by: Jayveer Singh <[email protected]> * feat(cli,distributed): run --distributed starts a real coordinator and dispatches to workers The banner-only path is gone: --distributed serves the coordinator gRPC endpoint on --bind (durable assignment recovery under .conduit/), maps scheduler dispatches onto the distributed protocol, and feeds worker results back into the scheduler. Exit code reflects the run outcome. (Claims-audit finding 6, part B) Signed-off-by: Jayveer Singh <[email protected]> * test(cli): reap the killed worker process in the distributed run test kill() without wait() leaves a zombie until the test binary exits; also silences the clippy zombie_processes warning. Signed-off-by: Jayveer Singh <[email protected]> * docs: align API reference, CLI reference, and concept docs with implemented behavior API reference: remove unrouted endpoints (run cancel, SSE logs, snapshots CRUD), fix path mismatches (dags/compile, dags/{id}/runs, environments/promote, lineage/trace/*, /ws/events), document the real plan/apply semantics (plan_id cache, 409 stale-plan, 422 apply_failed), the real auth (--auth-enabled, Bearer keys), per-IP rate limiting, the real error taxonomy, and real request/response shapes throughout. CLI reference: rewritten from actual --help output. Removes fictional commands (schedule, events, audit-log, snapshot *, health, verify-snapshots, cleanup) and documents the real ones that were missing (impact, backfill, worker, cluster, query, preview, env set-policy/diff/history). Concept docs: replace fictional snapshot/schedule/webhook/audit-log/ verify-snapshots/replay-with-modifications examples with the real equivalents (env history/rollback/diff, replay --events-only, events API, /ws/events), fix CONDUIT_ENV -> CONDUIT_ENVIRONMENT, and state honestly what retention and event triggers do and don't support. CLI help: run --distributed now says workers must connect. Also commit the claims-audit fix plan. (Claims-audit finding 7, docs half + residue from findings 2-6; closes the 2026-07-13 plan's Task 12) Signed-off-by: Jayveer Singh <[email protected]> --------- Signed-off-by: Jayveer Singh <[email protected]>
1 parent 76a19be commit 9a646a6

40 files changed

Lines changed: 6321 additions & 1318 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

conduit-api/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ conduit-scheduler = { path = "../conduit-scheduler" }
1414
conduit-planner = { path = "../conduit-planner" }
1515
conduit-lineage = { path = "../conduit-lineage" }
1616
conduit-providers = { path = "../conduit-providers" }
17+
conduit-executor = { path = "../conduit-executor" }
1718
axum = { workspace = true, features = ["ws"] }
1819
tower = { workspace = true }
1920
tower-http = { workspace = true }
@@ -42,4 +43,3 @@ conduit-common = { path = "../conduit-common" }
4243
conduit-compiler = { path = "../conduit-compiler" }
4344
conduit-state = { path = "../conduit-state" }
4445
conduit-scheduler = { path = "../conduit-scheduler" }
45-
conduit-executor = { path = "../conduit-executor" }

conduit-api/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ pub enum ApiError {
1616
Unauthorized(String),
1717
Forbidden(String),
1818
PromotionPolicyViolation(String),
19+
Conflict(String),
20+
ApplyFailed(String),
1921
}
2022

2123
impl IntoResponse for ApiError {
@@ -52,6 +54,8 @@ impl IntoResponse for ApiError {
5254
"promotion_policy_violation",
5355
msg,
5456
),
57+
ApiError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg),
58+
ApiError::ApplyFailed(msg) => (StatusCode::UNPROCESSABLE_ENTITY, "apply_failed", msg),
5559
};
5660

5761
let body = json!({

conduit-api/src/handlers/envs.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ pub async fn create_environment(
9090
.env_manager
9191
.create(&body.name, body.based_on.as_deref())
9292
.map_err(ApiError::from)?;
93+
state.persist_environments();
9394

9495
// Broadcast event
9596
let event = json!({
@@ -156,6 +157,7 @@ pub async fn delete_environment(
156157
.env_manager
157158
.delete(&env_name)
158159
.map_err(ApiError::from)?;
160+
state.persist_environments();
159161

160162
Ok(Json(json!({
161163
"message": format!("Environment '{}' deleted", env_name),
@@ -174,6 +176,7 @@ pub async fn promote_environment(
174176
.env_manager
175177
.promote(&body.source, &body.target)
176178
.map_err(ApiError::from)?;
179+
state.persist_environments();
177180

178181
// Broadcast event
179182
let event = json!({
@@ -385,6 +388,7 @@ pub async fn update_env_policy(
385388
.env_manager
386389
.set_promotion_policy(&env_name, policy)
387390
.map_err(ApiError::from)?;
391+
state.persist_environments();
388392

389393
Ok(Json(json!({
390394
"environment": env.id,
@@ -413,6 +417,7 @@ pub async fn rollback_environment(
413417
.env_manager
414418
.rollback(&env_name, body.to_version)
415419
.map_err(ApiError::from)?;
420+
state.persist_environments();
416421

417422
let event = json!({
418423
"type": "environment_rolled_back",

conduit-api/src/handlers/plan.rs

Lines changed: 218 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ pub async fn generate_plan(
6161
// Generate deployment plan
6262
let deploy = conduit_planner::DeploymentPlan::generate(&plan, &env, &state.snapshot_store);
6363

64+
// Cache it so a later POST /apply can look it up by id and apply
65+
// exactly what was reviewed here.
66+
state.store_plan(&deploy);
67+
6468
// Broadcast plan event
6569
let event = json!({
6670
"type": "plan_generated",
@@ -110,66 +114,247 @@ pub async fn generate_plan(
110114
}
111115

112116
/// POST /api/v1/apply — apply a deployment plan.
117+
///
118+
/// Honors `plan_id` when provided: looks the plan up in the in-memory
119+
/// cache populated by POST /plan, enforces that it targets the requested
120+
/// environment and that the environment hasn't moved since the plan was
121+
/// generated (409 on stale), then executes it for real — dispatching each
122+
/// `Execute` action through the provider registry, validating contracts,
123+
/// storing snapshots, and recording the environment update with history.
124+
/// Without `plan_id`, generates a fresh plan against the current
125+
/// environment state and applies it immediately.
113126
pub async fn apply_plan(
114127
auth: RequireAuth,
115128
State(state): State<Arc<AppState>>,
116129
Json(body): Json<ApplyRequest>,
117130
) -> Result<Json<Value>, ApiError> {
118131
auth.require(Permission::ApplyPlan)?;
119132

120-
let env_name = body.environment.as_deref().unwrap_or("production");
133+
let env_name = body
134+
.environment
135+
.as_deref()
136+
.unwrap_or("production")
137+
.to_string();
121138

122-
// Compile and generate fresh plan
139+
// Compile current DAGs — needed to look up task definitions for execution.
123140
let (plan, stats) = ConduitPlan::compile(&state.dags_path)
124141
.map_err(|e| ApiError::CompilationFailed(e.to_string()))?;
125-
126142
if !stats.errors.is_empty() {
127143
let error_msgs: Vec<String> = stats.errors.iter().map(|e| e.to_string()).collect();
128144
return Err(ApiError::CompilationFailed(error_msgs.join("; ")));
129145
}
130146

131-
let env = state
132-
.env_manager
133-
.get(env_name)
134-
.unwrap_or_else(|_| conduit_common::snapshot::Environment::new(env_name));
135-
136-
let deploy = conduit_planner::DeploymentPlan::generate(&plan, &env, &state.snapshot_store);
147+
let deploy = if let Some(plan_id) = body.plan_id.as_deref() {
148+
let stored = state.get_plan(plan_id).ok_or_else(|| {
149+
ApiError::NotFound(format!(
150+
"plan '{}' not found (plans are cached in-memory; regenerate via POST /api/v1/plan)",
151+
plan_id
152+
))
153+
})?;
154+
if stored.target_environment != env_name {
155+
return Err(ApiError::BadRequest(format!(
156+
"plan '{}' targets environment '{}', not '{}'",
157+
plan_id, stored.target_environment, env_name
158+
)));
159+
}
160+
let current_version = state
161+
.env_manager
162+
.get(&env_name)
163+
.map(|e| e.current_version)
164+
.unwrap_or(0);
165+
if current_version != stored.base_environment_version {
166+
return Err(ApiError::Conflict(format!(
167+
"stale plan: environment '{}' is at version {} but plan '{}' was generated against version {} — regenerate the plan",
168+
env_name, current_version, plan_id, stored.base_environment_version
169+
)));
170+
}
171+
stored
172+
} else {
173+
let env = state
174+
.env_manager
175+
.get(&env_name)
176+
.unwrap_or_else(|_| conduit_common::snapshot::Environment::new(&env_name));
177+
let deploy = conduit_planner::DeploymentPlan::generate(&plan, &env, &state.snapshot_store);
178+
state.store_plan(&deploy);
179+
deploy
180+
};
137181

138182
if deploy.stats.tasks_to_execute == 0 && deploy.stats.tasks_to_remove == 0 {
139183
return Ok(Json(json!({
184+
"plan_id": deploy.id,
140185
"message": format!("Nothing to apply. Environment '{}' is up to date.", env_name),
141-
"tasks_executed": 0,
142-
"tasks_reused": 0,
143-
"tasks_removed": 0,
186+
"status": "noop",
187+
"tasks_executed": 0, "tasks_reused": 0, "tasks_removed": 0,
144188
})));
145189
}
146190

147-
// In production, this would dispatch to the scheduler/executor.
148-
// For now, we record the intent and report what would happen.
191+
state.broadcast_event(
192+
&json!({
193+
"type": "apply_started",
194+
"plan_id": deploy.id,
195+
"environment": env_name,
196+
"tasks_to_execute": deploy.stats.tasks_to_execute,
197+
"timestamp": Utc::now().to_rfc3339(),
198+
})
199+
.to_string(),
200+
);
149201

150-
let executable_count = deploy.executable_actions().len();
202+
// ── Execute the plan (mirrors CLI cmd_apply) ──
203+
use conduit_executor::process_runner::{ProcessRunner, TaskContext};
204+
use conduit_planner::ActionKind;
151205

152-
// Broadcast apply event
153-
let event = json!({
154-
"type": "apply_started",
155-
"plan_id": deploy.id,
156-
"environment": env_name,
157-
"tasks_to_execute": executable_count,
158-
"timestamp": Utc::now().to_rfc3339(),
159-
});
160-
state.broadcast_event(&event.to_string());
206+
let registry = state.provider_registry.read().ok().and_then(|g| g.clone());
207+
let contract_index: std::collections::HashMap<
208+
(String, String),
209+
&conduit_common::contracts::TaskContracts,
210+
> = deploy
211+
.pending_contracts
212+
.iter()
213+
.map(|tc| {
214+
(
215+
(tc.dag_id.clone().unwrap_or_default(), tc.task_id.clone()),
216+
tc,
217+
)
218+
})
219+
.collect();
220+
let mut contract_results: Vec<conduit_common::contracts::ValidationResult> = Vec::new();
221+
let mut new_snapshots: std::collections::HashMap<(String, String), String> =
222+
std::collections::HashMap::new();
223+
let (mut executed, mut reused, mut removed) = (0usize, 0usize, 0usize);
224+
let logical_date = Utc::now();
225+
let run_id = format!("apply_{}", Utc::now().format("%Y%m%d%H%M%S"));
226+
227+
for action in &deploy.actions {
228+
match &action.action {
229+
ActionKind::Execute => {
230+
let task = plan
231+
.dags
232+
.get(&action.dag_id)
233+
.and_then(|dag| dag.tasks.get(&action.task_id))
234+
.ok_or_else(|| {
235+
ApiError::ApplyFailed(format!(
236+
"task {}.{} not found in compiled plan",
237+
action.dag_id, action.task_id
238+
))
239+
})?;
240+
241+
let context = TaskContext {
242+
dag_id: action.dag_id.clone(),
243+
run_id: run_id.clone(),
244+
task_id: action.task_id.clone(),
245+
attempt: 1,
246+
logical_date,
247+
environment: env_name.clone(),
248+
params: Default::default(),
249+
extra_env: Vec::new(),
250+
};
251+
252+
let output = ProcessRunner::run_with_providers(task, &context, registry.as_deref())
253+
.await
254+
.map_err(|e| {
255+
ApiError::ApplyFailed(format!(
256+
"task {}.{} execution error: {}",
257+
action.dag_id, action.task_id, e
258+
))
259+
})?;
260+
if output.exit_code != 0 {
261+
return Err(ApiError::ApplyFailed(format!(
262+
"task {}.{} failed with exit code {}: {}",
263+
action.dag_id,
264+
action.task_id,
265+
output.exit_code,
266+
output.stderr.trim()
267+
)));
268+
}
269+
270+
if let Some(tc) =
271+
contract_index.get(&(action.dag_id.clone(), action.task_id.clone()))
272+
{
273+
let result = conduit_common::contracts::ContractEvaluator::evaluate(
274+
tc,
275+
&output.evidence,
276+
);
277+
let blocked = !result.passed;
278+
contract_results.push(result);
279+
if blocked {
280+
return Err(ApiError::ApplyFailed(format!(
281+
"contract validation failed for {}.{} — environment not updated",
282+
action.dag_id, action.task_id
283+
)));
284+
}
285+
}
286+
287+
let snap_id = format!(
288+
"snap_{}_{}",
289+
action.task_id,
290+
Utc::now().format("%Y%m%d%H%M%S%3f")
291+
);
292+
if let Some(ref fp) = action.fingerprint {
293+
let snapshot = conduit_common::snapshot::Snapshot {
294+
id: snap_id.clone(),
295+
fingerprint: fp.clone(),
296+
dag_id: action.dag_id.clone(),
297+
task_id: action.task_id.clone(),
298+
created_at: Utc::now(),
299+
parent_fingerprints: vec![],
300+
metadata: Default::default(),
301+
};
302+
let _ = state.snapshot_store.put(snapshot);
303+
}
304+
new_snapshots.insert((action.dag_id.clone(), action.task_id.clone()), snap_id);
305+
executed += 1;
306+
}
307+
ActionKind::ReuseSnapshot { .. } => reused += 1,
308+
ActionKind::Skip => {}
309+
ActionKind::Remove => removed += 1,
310+
}
311+
}
312+
313+
// ── Update the environment (history-recorded, rollbackable) ──
314+
if state.env_manager.get(&env_name).is_err() {
315+
let _ = state.env_manager.create(&env_name, None);
316+
}
317+
let mut env_snapshot = state
318+
.env_manager
319+
.get(&env_name)
320+
.unwrap_or_else(|_| conduit_common::snapshot::Environment::new(&env_name));
321+
deploy.apply_to_environment(&mut env_snapshot, &new_snapshots);
322+
let recorded_version = state
323+
.env_manager
324+
.apply_snapshot_map(
325+
&env_name,
326+
env_snapshot.snapshot_map.clone(),
327+
deploy.id.clone(),
328+
)
329+
.map_err(|e| ApiError::Internal(e.to_string()))?;
330+
state.persist_environments();
331+
332+
if let Some(store) = &state.event_store {
333+
let _ = store.append(conduit_common::event::EventKind::PlanApplied {
334+
plan_id: deploy.id.clone(),
335+
environment: env_name.clone(),
336+
tasks_executed: executed as u32,
337+
tasks_skipped: reused as u32,
338+
});
339+
}
340+
state.broadcast_event(
341+
&json!({
342+
"type": "apply_completed",
343+
"plan_id": deploy.id,
344+
"environment": env_name,
345+
"tasks_executed": executed,
346+
"timestamp": Utc::now().to_rfc3339(),
347+
})
348+
.to_string(),
349+
);
161350

162351
Ok(Json(json!({
163352
"plan_id": deploy.id,
164353
"environment": env_name,
165-
"status": "accepted",
166-
"tasks_to_execute": deploy.stats.tasks_to_execute,
167-
"tasks_to_reuse": deploy.stats.tasks_to_reuse,
168-
"tasks_to_skip": deploy.stats.tasks_to_skip,
169-
"tasks_to_remove": deploy.stats.tasks_to_remove,
170-
"message": format!(
171-
"Apply accepted. {} tasks queued for execution in '{}'.",
172-
deploy.stats.tasks_to_execute, env_name
173-
),
354+
"status": "applied",
355+
"tasks_executed": executed,
356+
"tasks_reused": reused,
357+
"tasks_removed": removed,
358+
"environment_version": recorded_version,
174359
})))
175360
}

conduit-api/src/handlers/runs.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,17 @@ pub async fn trigger_run(
6868
.map(|dt| dt.with_timezone(&Utc))
6969
.unwrap_or(now);
7070

71-
let config = body.config.unwrap_or_default();
71+
let mut config = body.config.unwrap_or_default();
7272
let environment = body.environment.unwrap_or_else(|| "production".to_string());
73+
// The scheduler reads environment/triggered_by out of the run config
74+
// (scheduler.rs handle_dag_run_requested) — without these keys every
75+
// API-triggered run is logged as production/scheduler.
76+
config
77+
.entry("environment".to_string())
78+
.or_insert_with(|| environment.clone());
79+
config
80+
.entry("triggered_by".to_string())
81+
.or_insert_with(|| "api".to_string());
7382

7483
let task_states: HashMap<String, String> = dag
7584
.tasks

0 commit comments

Comments
 (0)