fix(pipeline): require tuple proof for structural delta preflight

Add an internal store query that returns exact inbound edge tuples and owner paths for file-delta preflight.

Use it in the exact-delta planner so unowned structural CONTAINS_FILE edges are accepted only when the candidate regenerates the same source, target, and type tuple. Existing realistic full-pipeline structure still falls back when the descriptor omits that edge; live exact-delta routing remains unwired.

Validation: git diff --check; bash scripts/check-source-safety.sh; make -j8 -f Makefile.cbm build/c/test-runner; CBM_ONLY_SUITE=store_nodes ./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 00:04:37 -04:00
parent 907884db07
commit e8c7d855ab
5 changed files with 268 additions and 10 deletions
+40 -10
View File
@@ -637,30 +637,60 @@ static bool delta_path_in_batch(const char *path, const cbm_pipeline_file_delta_
return false;
}
static bool delta_batch_contains_edge(const cbm_pipeline_file_delta_t *const *deltas,
int delta_count, const char *source_qn,
const char *target_qn, const char *type) {
if (!deltas || !source_qn || !target_qn || !type) {
return false;
}
for (int i = 0; i < delta_count; i++) {
const cbm_pipeline_file_delta_t *delta = deltas[i];
if (!delta) {
continue;
}
for (int j = 0; j < delta->delta.edge_count; j++) {
const cbm_store_delta_edge_t *edge = &delta->delta.edges[j];
if (edge->source_qn && edge->target_qn && edge->type &&
strcmp(edge->source_qn, source_qn) == 0 &&
strcmp(edge->target_qn, target_qn) == 0 && strcmp(edge->type, type) == 0) {
return true;
}
}
}
return false;
}
static bool delta_unowned_inbound_edge_is_regenerated(
const cbm_store_inbound_edge_t *edge, const cbm_pipeline_file_delta_t *const *deltas,
int delta_count) {
return edge && edge->source_rel_path && edge->source_rel_path[0] == '\0' && edge->type &&
strcmp(edge->type, "CONTAINS_FILE") == 0 &&
delta_batch_contains_edge(deltas, delta_count, edge->source_qn, edge->target_qn,
edge->type);
}
static bool delta_inbound_edges_supported(cbm_store_t *store,
const cbm_pipeline_file_delta_t *delta,
const cbm_pipeline_file_delta_t *const *deltas,
int delta_count,
cbm_pipeline_file_delta_plan_t *plan) {
char **source_paths = NULL;
int source_count = 0;
int rc = cbm_store_list_file_delta_inbound_source_paths(
store, delta->delta.project, delta->delta.rel_path, &source_paths, &source_count);
cbm_store_inbound_edge_t *edges = NULL;
int edge_count = 0;
int rc = cbm_store_list_file_delta_inbound_edges(store, delta->delta.project,
delta->delta.rel_path, &edges, &edge_count);
if (rc != CBM_STORE_OK) {
delta_plan_set_fallback(plan, cbm_delta_reason_preflight_error);
return false;
}
bool ok = true;
for (int i = 0; i < source_count; i++) {
if (!delta_path_in_batch(source_paths[i], deltas, delta_count)) {
for (int i = 0; i < edge_count; i++) {
if (!delta_path_in_batch(edges[i].source_rel_path, deltas, delta_count) &&
!delta_unowned_inbound_edge_is_regenerated(&edges[i], deltas, delta_count)) {
ok = false;
break;
}
}
for (int i = 0; i < source_count; i++) {
free(source_paths[i]);
}
free(source_paths);
cbm_store_free_inbound_edges(edges, edge_count);
if (!ok) {
delta_plan_set_fallback(plan, cbm_delta_reason_inbound_edges_require_full);
}
+112
View File
@@ -11,6 +11,7 @@
#include <stdint.h>
#include "foundation/constants.h"
#include <limits.h>
#include <math.h>
enum {
@@ -2343,6 +2344,117 @@ int cbm_store_list_file_delta_inbound_source_paths(cbm_store_t *s, const char *p
return store_collect_text_column(s, stmt, "list_file_delta_inbound_source_paths", out, count);
}
void cbm_store_free_inbound_edges(cbm_store_inbound_edge_t *edges, int count) {
if (!edges) {
return;
}
for (int i = 0; i < count; i++) {
free(edges[i].source_qn);
free(edges[i].target_qn);
free(edges[i].type);
free(edges[i].source_rel_path);
free(edges[i].target_rel_path);
}
free(edges);
}
static int store_inbound_edges_grow(cbm_store_inbound_edge_t **items, int *cap) {
if (!items || !cap) {
return CBM_STORE_ERR;
}
if (*cap > INT_MAX / ST_GROWTH) {
return CBM_STORE_ERR;
}
int new_cap = (*cap > 0) ? *cap * ST_GROWTH : ST_INIT_CAP_8;
cbm_store_inbound_edge_t *next = realloc(*items, (size_t)new_cap * sizeof(*next));
if (!next) {
return CBM_STORE_ERR;
}
*items = next;
*cap = new_cap;
return CBM_STORE_OK;
}
int cbm_store_list_file_delta_inbound_edges(cbm_store_t *s, const char *project,
const char *rel_path,
cbm_store_inbound_edge_t **out, int *count) {
if (out) {
*out = NULL;
}
if (count) {
*count = 0;
}
if (!s || !project || !rel_path || !out || !count) {
if (s) {
store_set_error(s, "list_file_delta_inbound_edges: invalid argument");
}
return CBM_STORE_ERR;
}
const char *sql =
"SELECT src.qualified_name, tgt.qualified_name, e.type, "
" COALESCE(src_owner.rel_path, ''), COALESCE(tgt_owner.rel_path, '') "
"FROM edges e "
"JOIN node_owners tgt_owner "
" ON tgt_owner.project = ?1 AND tgt_owner.rel_path = ?2 "
" AND tgt_owner.node_id = e.target_id "
"JOIN nodes src ON src.id = e.source_id "
"JOIN nodes tgt ON tgt.id = e.target_id "
"LEFT JOIN node_owners src_owner "
" ON src_owner.project = ?1 AND src_owner.node_id = e.source_id "
"WHERE e.project = ?1 "
" AND (src_owner.rel_path IS NULL OR src_owner.rel_path != ?2) "
"ORDER BY src.qualified_name, tgt.qualified_name, e.type;";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "list_file_delta_inbound_edges prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
bind_text(stmt, ST_COL_2, rel_path);
int cap = 0;
int n = 0;
int partial = 0;
cbm_store_inbound_edge_t *items = NULL;
int rc = CBM_STORE_OK;
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap && store_inbound_edges_grow(&items, &cap) != CBM_STORE_OK) {
rc = CBM_STORE_ERR;
break;
}
cbm_store_inbound_edge_t *edge = &items[n];
memset(edge, 0, sizeof(*edge));
partial = 1;
edge->source_qn = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
edge->target_qn = heap_strdup((const char *)sqlite3_column_text(stmt, 1));
edge->type = heap_strdup((const char *)sqlite3_column_text(stmt, 2));
edge->source_rel_path = heap_strdup((const char *)sqlite3_column_text(stmt, 3));
edge->target_rel_path = heap_strdup((const char *)sqlite3_column_text(stmt, 4));
if (!edge->source_qn || !edge->target_qn || !edge->type || !edge->source_rel_path ||
!edge->target_rel_path) {
rc = CBM_STORE_ERR;
break;
}
n++;
partial = 0;
}
if (rc != SQLITE_DONE && rc != CBM_STORE_ERR) {
store_set_error_sqlite(s, "list_file_delta_inbound_edges");
rc = CBM_STORE_ERR;
} else if (rc == SQLITE_DONE) {
rc = CBM_STORE_OK;
}
sqlite3_finalize(stmt);
if (rc != CBM_STORE_OK) {
cbm_store_free_inbound_edges(items, n + partial);
return rc;
}
*out = items;
*count = n;
return CBM_STORE_OK;
}
int cbm_store_upsert_symbol_export(cbm_store_t *s, const char *project,
const char *qualified_name, const char *rel_path,
int64_t node_id, int64_t generation) {
+15
View File
@@ -108,6 +108,14 @@ typedef struct {
const char *derived_kind;
} cbm_store_delta_edge_t;
typedef struct {
char *source_qn;
char *target_qn;
char *type;
char *source_rel_path; /* empty when source node has no owner metadata */
char *target_rel_path;
} cbm_store_inbound_edge_t;
typedef struct {
const char *qualified_name;
int64_t node_id; /* CBM_STORE_NO_NODE_ID resolves by qualified_name when present. */
@@ -531,6 +539,13 @@ int cbm_store_list_file_delta_inbound_source_paths(cbm_store_t *s, const char *p
const char *rel_path, char ***out,
int *count);
/* Caller frees with cbm_store_free_inbound_edges(). */
int cbm_store_list_file_delta_inbound_edges(cbm_store_t *s, const char *project,
const char *rel_path,
cbm_store_inbound_edge_t **out, int *count);
void cbm_store_free_inbound_edges(cbm_store_inbound_edge_t *edges, int count);
int cbm_store_upsert_symbol_export(cbm_store_t *s, const char *project,
const char *qualified_name, const char *rel_path,
int64_t node_id, int64_t generation);
+89
View File
@@ -4107,6 +4107,94 @@ TEST(pipeline_file_delta_plan_falls_back_on_full_pipeline_structure_edge) {
PASS();
}
TEST(pipeline_file_delta_plan_accepts_regenerated_structural_inbound_edge) {
enum { PIPELINE_DELTA_TEST_BASE_GENERATION = 1 };
const char *project = "test";
const char *rel_path = "src/main.go";
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK);
char *file_qn = cbm_pipeline_fqn_compute(project, rel_path, "__file__");
char *folder_qn = cbm_pipeline_fqn_folder(project, "src");
ASSERT_NOT_NULL(file_qn);
ASSERT_NOT_NULL(folder_qn);
cbm_node_t old_file = {.project = (char *)project,
.label = "File",
.name = "main.go",
.qualified_name = file_qn,
.file_path = (char *)rel_path,
.properties_json = "{}"};
int64_t file_id = cbm_store_upsert_node(s, &old_file);
ASSERT_GT(file_id, CBM_STORE_NO_NODE_ID);
ASSERT_EQ(cbm_store_upsert_node_owner(s, project, file_id, rel_path,
PIPELINE_DELTA_TEST_BASE_GENERATION),
CBM_STORE_OK);
cbm_file_state_t base_state = {.project = (char *)project,
.rel_path = (char *)rel_path,
.content_hash = "base-content",
.git_oid = "",
.mtime_ns = 1,
.size = 10,
.language = "go",
.pass_fingerprint = "test-pass",
.generation = PIPELINE_DELTA_TEST_BASE_GENERATION,
.indexed_at = "2026-06-30T00:00:00Z"};
ASSERT_EQ(cbm_store_upsert_file_state(s, &base_state), CBM_STORE_OK);
cbm_node_t folder = {.project = (char *)project,
.label = "Folder",
.name = "src",
.qualified_name = folder_qn,
.file_path = "src",
.properties_json = "{}"};
int64_t folder_id = cbm_store_upsert_node(s, &folder);
ASSERT_GT(folder_id, CBM_STORE_NO_NODE_ID);
cbm_edge_t contains = {.project = (char *)project,
.source_id = folder_id,
.target_id = file_id,
.type = "CONTAINS_FILE",
.properties_json = "{}"};
int64_t edge_id = cbm_store_insert_edge(s, &contains);
ASSERT_GT(edge_id, CBM_STORE_NO_NODE_ID);
ASSERT_EQ(cbm_store_upsert_edge_owner(s, project, edge_id, rel_path, NULL,
PIPELINE_DELTA_TEST_BASE_GENERATION),
CBM_STORE_OK);
cbm_node_t new_file = {.project = (char *)project,
.label = "File",
.name = "main.go",
.qualified_name = file_qn,
.file_path = (char *)rel_path,
.properties_json = "{}"};
cbm_store_delta_edge_t regenerated_edge = {.source_qn = folder_qn,
.target_qn = file_qn,
.type = "CONTAINS_FILE",
.properties_json = "{}"};
cbm_pipeline_file_delta_t delta = {.delta = {.project = project,
.rel_path = rel_path,
.nodes = &new_file,
.node_count = 1,
.edges = &regenerated_edge,
.edge_count = 1}};
cbm_file_hash_t hash = {0};
cbm_file_state_t state = {0};
pipeline_delta_attach_test_metadata(&delta, &hash, &state);
cbm_pipeline_file_delta_plan_t plan = {0};
ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK);
ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE);
ASSERT_STR_EQ(plan.reason, "candidate");
ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, rel_path), 1);
cbm_pipeline_file_delta_plan_free(&plan);
free(folder_qn);
free(file_qn);
cbm_store_close(s);
PASS();
}
TEST(pipeline_file_delta_plan_falls_back_without_file_metadata) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
@@ -9109,6 +9197,7 @@ SUITE(pipeline) {
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);
RUN_TEST(pipeline_file_delta_plan_falls_back_on_full_pipeline_structure_edge);
RUN_TEST(pipeline_file_delta_plan_accepts_regenerated_structural_inbound_edge);
RUN_TEST(pipeline_file_delta_plan_falls_back_without_file_metadata);
RUN_TEST(pipeline_file_delta_plan_falls_back_on_unsupported_edges);
RUN_TEST(pipeline_file_delta_plan_falls_back_on_delete);
+12
View File
@@ -1043,6 +1043,18 @@ TEST(store_rebuild_file_delta_owners_derives_from_graph) {
CBM_STORE_OK);
ASSERT_EQ(node_owners, 1);
ASSERT_EQ(edge_owners, 1);
cbm_store_inbound_edge_t *inbound = NULL;
int inbound_count = 0;
ASSERT_EQ(cbm_store_list_file_delta_inbound_edges(s, "test", "src/main.go", &inbound,
&inbound_count),
CBM_STORE_OK);
ASSERT_EQ(inbound_count, 1);
ASSERT_STR_EQ(inbound[0].source_qn, "test.src");
ASSERT_STR_EQ(inbound[0].target_qn, "test.src.main.__file__");
ASSERT_STR_EQ(inbound[0].type, "CONTAINS_FILE");
ASSERT_STR_EQ(inbound[0].source_rel_path, "");
ASSERT_STR_EQ(inbound[0].target_rel_path, "src/main.go");
cbm_store_free_inbound_edges(inbound, inbound_count);
ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "stale.go"), 0);
ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "stale.go"), 0);