fix(pipeline): reject generationless delta apply

Keep plan-only exact-delta candidates usable with provisional generation zero, but make apply_file_delta_batch fail closed with missing_generation before calling the store publish path.

Add a focused regression for generation-zero apply attempts while preserving the existing publish_error coverage for positive but unreserved generations.

Validation: git diff --check; bash scripts/check-source-safety.sh; make -j8 -f Makefile.cbm build/c/test-runner; CBM_ONLY_SUITE=pipeline ./build/c/test-runner.
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-01 01:02:00 -04:00
parent 94ad6c60ee
commit 0ae059da3c
2 changed files with 50 additions and 0 deletions
+17
View File
@@ -1072,6 +1072,19 @@ static bool delta_plan_affected_paths_in_batch(const cbm_pipeline_file_delta_pla
return true;
}
static bool delta_batch_has_positive_generation(const cbm_pipeline_file_delta_t *const *deltas,
int delta_count) {
if (!deltas || delta_count <= 0) {
return false;
}
for (int i = 0; i < delta_count; i++) {
if (!deltas[i] || deltas[i]->delta.generation <= 0) {
return false;
}
}
return true;
}
int cbm_pipeline_apply_file_delta_batch(cbm_store_t *store,
const cbm_pipeline_file_delta_t *const *deltas,
int delta_count, int max_affected_paths,
@@ -1085,6 +1098,10 @@ int cbm_pipeline_apply_file_delta_batch(cbm_store_t *store,
delta_plan_set_fallback(out, cbm_delta_reason_frontier_requires_batch);
return CBM_STORE_OK;
}
if (!delta_batch_has_positive_generation(deltas, delta_count)) {
delta_plan_set_fallback(out, cbm_delta_reason_missing_generation);
return CBM_STORE_OK;
}
if (delta_count == 1 && deltas[0] &&
deltas[0]->change_kind == CBM_PIPELINE_DELTA_CHANGE_DELETE) {
rc = cbm_store_delete_file_delta_complete(
+33
View File
@@ -3942,6 +3942,38 @@ TEST(pipeline_file_delta_apply_falls_back_on_publish_error) {
PASS();
}
TEST(pipeline_file_delta_apply_falls_back_without_generation) {
enum { PIPELINE_DELTA_APPLY_ONE = 1 };
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
ASSERT_EQ(pipeline_delta_seed_existing_ownership(s, "test", "lib.go", "test.lib.Old"),
CBM_STORE_OK);
cbm_store_symbol_export_t exports[1] = {
{.qualified_name = "test.lib.Value", .node_id = CBM_STORE_NO_NODE_ID}};
cbm_pipeline_file_delta_t delta = {
.delta = {.project = "test", .rel_path = "lib.go", .exports = exports, .export_count = 1}};
cbm_file_hash_t hash = {0};
cbm_file_state_t state = {0};
pipeline_delta_attach_test_metadata(&delta, &hash, &state);
delta.delta.generation = 0;
delta.file_state.generation = 0;
const cbm_pipeline_file_delta_t *deltas[] = {&delta};
cbm_pipeline_file_delta_plan_t plan = {0};
ASSERT_EQ(cbm_pipeline_apply_file_delta_batch(s, deltas, PIPELINE_DELTA_APPLY_ONE,
CBM_SZ_4, &plan),
CBM_STORE_OK);
ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_FALLBACK);
ASSERT_STR_EQ(plan.reason, "missing_generation");
ASSERT_EQ(pipeline_delta_store_qn_exists(s, "test", "test.lib.Value"), 0);
cbm_pipeline_file_delta_plan_free(&plan);
cbm_store_close(s);
PASS();
}
TEST(pipeline_file_delta_plan_falls_back_without_existing_ownership) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
@@ -9402,6 +9434,7 @@ SUITE(pipeline) {
RUN_TEST(pipeline_file_state_persist_helper_rolls_back_on_failure);
RUN_TEST(pipeline_file_delta_plan_candidate_from_frontier);
RUN_TEST(pipeline_file_delta_apply_falls_back_on_publish_error);
RUN_TEST(pipeline_file_delta_apply_falls_back_without_generation);
RUN_TEST(pipeline_file_delta_plan_falls_back_without_existing_ownership);
RUN_TEST(pipeline_file_delta_plan_falls_back_on_external_inbound_edge);
RUN_TEST(pipeline_file_delta_plan_falls_back_on_unowned_structural_inbound_edge);