Skip to content

fix(hal-mcp): push_mission_context upserts partial note rows — every push fails on project_id NOT NULL #74

Description

@BluegReeno

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

  1. Open a mission in the local workspace, edit a note description.
  2. Run /edifice push.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions