Summary
push_mission_context writes partial note updates with .upsert(), which Postgres executes as
INSERT … ON CONFLICT DO UPDATE. The INSERT arm must satisfy every NOT NULL column, but the
payload only ever carries the fields the caller changed. Any push therefore fails with:
null value in column "project_id" of relation "edifice_notes" violates not-null constraint
/edifice push is broken end to end: field observations edited on site cannot be pushed back.
Location
supabase/functions/hal-mcp/index.ts:441
const row: Record<string, unknown> = { id: obs.note_id };
if (obs.type) row.type = obs.type;
if (obs.description) row.description = obs.description;
// … only the fields the caller supplied
rows.push(row);
// …
const { error } = await db.from("edifice_notes").upsert(rows); // ← INSERT … ON CONFLICT
project_id is never part of row, because the tool's contract is a partial update keyed by
note_id — the row already exists and already has its project_id.
Two further defects in the same block
1. updated is fabricated. :443 sets updated = rows.length — the number of rows sent,
not the number affected. On success the tool reports rows it may never have touched. The
{ updated, skipped, errors } contract the skill relays to the user is not trustworthy.
2. upsert would silently create orphan notes. A note_id that does not exist should be an
error, not an insert. Today the NOT NULL on project_id is the only thing preventing a bogus
note_id from creating a phantom note — the constraint is masking the design fault rather than
the design being right.
Fix direction
Replace the batch upsert with a targeted UPDATE per observation, which is what the contract
has always meant:
const { data, error } = await db
.from("edifice_notes")
.update(fields) // row without `id`
.eq("id", obs.note_id)
.select("id");
- Count
updated from the returned rows, so a non-existent note_id lands in skipped or
errors instead of being reported as written.
- A push of N observations becomes N statements. Mission-sized payloads are a few dozen rows, so
this is acceptable; if it ever isn't, move it to a plpgsql RPC doing
UPDATE … FROM (VALUES …) in one statement — do not go back to upsert.
- Behaviour must stay idempotent: pushing the same observations twice changes nothing.
Reproduction
- Open a mission in the local workspace, edit a note description.
- Run
/edifice push.
errors contains the null value in column "project_id" message; nothing is written.
Acceptance
push_mission_context updates existing edifice_notes rows by note_id, preserving
project_id and every other column the payload does not mention.
updated reflects rows actually affected.
- An unknown
note_id is reported, never inserted.
- Pushing twice is a no-op.
Related
BluegReeno/bluegreen-marketplace#31 — the skill-side report. PR
BluegReeno/bluegreen-marketplace#41 only documents the failure as a known issue; the fix
belongs here.
Summary
push_mission_contextwrites partial note updates with.upsert(), which Postgres executes asINSERT … ON CONFLICT DO UPDATE. TheINSERTarm must satisfy everyNOT NULLcolumn, but thepayload only ever carries the fields the caller changed. Any push therefore fails with:
/edifice pushis broken end to end: field observations edited on site cannot be pushed back.Location
supabase/functions/hal-mcp/index.ts:441project_idis never part ofrow, because the tool's contract is a partial update keyed bynote_id— the row already exists and already has itsproject_id.Two further defects in the same block
1.
updatedis fabricated.:443setsupdated = rows.length— the number of rows sent,not the number affected. On success the tool reports rows it may never have touched. The
{ updated, skipped, errors }contract the skill relays to the user is not trustworthy.2.
upsertwould silently create orphan notes. Anote_idthat does not exist should be anerror, not an insert. Today the
NOT NULLonproject_idis the only thing preventing a bogusnote_idfrom creating a phantom note — the constraint is masking the design fault rather thanthe design being right.
Fix direction
Replace the batch
upsertwith a targetedUPDATEper observation, which is what the contracthas always meant:
updatedfrom the returned rows, so a non-existentnote_idlands inskippedorerrorsinstead of being reported as written.this is acceptable; if it ever isn't, move it to a plpgsql RPC doing
UPDATE … FROM (VALUES …)in one statement — do not go back toupsert.Reproduction
/edifice push.errorscontains thenull value in column "project_id"message; nothing is written.Acceptance
push_mission_contextupdates existingedifice_notesrows bynote_id, preservingproject_idand every other column the payload does not mention.updatedreflects rows actually affected.note_idis reported, never inserted.Related
BluegReeno/bluegreen-marketplace#31— the skill-side report. PRBluegReeno/bluegreen-marketplace#41only documents the failure as a known issue; the fixbelongs here.