fix(hal-mcp): push_mission_context uses targeted UPDATE, not upsert - #76
Conversation
push_mission_context built a partial row per observation and called
.upsert() on edifice_notes. Supabase compiles upsert to INSERT ... ON
CONFLICT DO UPDATE, so the INSERT arm must satisfy every NOT NULL column
(including project_id, never in the partial payload) — every push failed
with a not-null violation.
Changes:
- Replace the batched upsert with a per-observation UPDATE ... WHERE
id = note_id ... RETURNING id, which never touches columns the caller
did not supply (no schema change, no DDL)
- Count `updated` from rows actually returned by the database, not from
the number of rows sent
- Report an unknown note_id in `errors` ("not found") instead of silently
inserting an orphan row — an UPDATE cannot create rows
- Rewrite the note-path unit tests to mock .update().eq().select() and add
a test pinning the unknown-note_id behavior
Behaviour stays idempotent: pushing the same observations twice re-issues
the same UPDATE and reports the same count with no state change.
Fixes #74
🔍 Comprehensive PR ReviewPR: #76 SummaryThis PR replaces the batched The one gap all three converge on, from different angles: no test currently proves that one observation's failure doesn't affect a sibling's success in the same batch call — a coverage gap on currently-correct code, not a defect. Verdict:
🟠 High Issues (test-coverage hardening, not blocking)No test exercises a mixed batch (partial success + partial failure) in one call📍 The rewritten loop's cross-iteration aggregation ( View suggested testDeno.test("push_mission_context — mixed batch: one succeeds, one not found", async () => {
const STATIC_KEY = "test-static-key-abc123";
Deno.env.set("SUPABASE_SECRET_KEYS", JSON.stringify({ hal_api_key: STATIC_KEY }));
const s = stub(_adminClient, "from", returnsNext([
noteUpdateChain({ data: [{ id: "n1" }], error: null }) as any,
noteUpdateChain({ data: [], error: null }) as any,
]));
try {
const res = await fetchHandler(callTool(STATIC_KEY, "push_mission_context", {
observations: [
{ note_id: "n1", description: "Fissure façade" },
{ note_id: "n2", description: "orphan edit" },
],
}, 608));
const body = await parseMcpResponse(res);
assertEquals(body.result.isError, true);
const result = JSON.parse(body.result.content[0].text);
assertEquals(result.updated, 1);
assertEquals(result.errors.length, 1);
assertEquals(result.errors[0].includes("n2") && result.errors[0].includes("not found"), true);
} finally {
s.restore();
Deno.env.set("SUPABASE_SECRET_KEYS", DEFAULT_SECRET_KEYS);
}
});🟡 Medium Issues (Needs Decision)
|
| Issue | Location | Suggestion |
|---|---|---|
data === null (no error) untested for the not-found branch |
index.ts:443-445 |
Add a { data: null, error: null } mock variant; fold into the same follow-up |
noteUpdateChain looks like it duplicates makeUpdateMock but actually terminates the chain differently (array vs. single-row) |
index.test.ts:749 |
No action — confirmed EXTENDS, not DUPLICATE |
| Idempotency (issue #74 AC #4) has no test at any level | index.test.ts (absent) |
No unit-test action — follows from Postgres UPDATE semantics, not app logic; belongs at integration level if ever needed |
✅ What's Good
- Root cause fixed, not papered over — old silent-success bug fully replaced, no try/except swallowing, no fabricated
project_id. - Fail-fast preserved — every error path lands in
errorsand flipsisError: true. - Per-row isolation is a genuine improvement over the old atomic-batch upsert.
- The incidentally-touched 5th test (
isError when obs succeed but crop fails) was independently re-verified by two agents against its original assertions, not just trusted from the deviation note. - Zero migration/DDL changes confirmed via
git diff; all CI checks green. - KISS/YAGNI respected — no premature parallelization, matching the issue's explicit scope limit.
📋 Suggested Follow-up Issues
| Issue Title | Priority | Related Finding |
|---|---|---|
| "hal-mcp: add mixed-outcome + eq()-targeting test coverage for push_mission_context note loop" | P3 | Bundles the HIGH + MEDIUM + one LOW finding above |
Note on missing agents
comment-quality and docs-impact artifacts were not produced for this run. docs-impact is already marked N/A in scope.md (no DDL/contract change in this PR), and the diff introduces no non-obvious comments requiring quality review, so this gap is flagged for visibility but not expected to change the verdict.
Next Steps
- No auto-fix applied — zero CRITICAL/HIGH production-code issues; the HIGH finding is a test addition, left for your call rather than auto-applied to a draft PR.
- Optionally open the bundled follow-up issue before merging, or merge as-is and track separately — draft status + green CI mean neither finding is merge-blocking.
Reviewed by Archon comprehensive-pr-review workflow
Artifacts: /Users/renaud/.archon/workspaces/BluegReeno/hal/artifacts/runs/e875e7c33d74a11daca5d9ac5fa1555f/review/
…h_mission_context Fixed: - HIGH: no test covered mixed outcomes (one success, one not-found) in a single push_mission_context call — the exact new cross-iteration aggregation behavior this PR introduced. Added "mixed batch: one succeeds, one not found". - MEDIUM: noteUpdateChain discarded eq() arguments, so row-targeting by note_id was unverifiable. noteUpdateChain now records eq() calls on chain._eqCalls; asserted in the multi-observation success test and the new mixed-batch test. - LOW: data === null (no error) branch of the not-found guard was untested. Added "null data (no error) is treated as not found". Skipped: - noteUpdateChain vs makeUpdateMock "duplication" (LOW) — reviewers independently confirmed EXTENDS not DUPLICATE (different terminal chain shape); no action needed. - Idempotency unit test (LOW) — reviewers' own recommendation was no unit-test action, since it's an UPDATE-semantics property, not application logic; would belong at the integration/migration-smoke-test layer if ever verified. All 63 Deno tests pass; deno check clean.
⚡ Self-Fix Report (Aggressive)Status: COMPLETE Fixes Applied (3 total)
View all fixes
Tests Added
Skipped (2)
Suggested Follow-up Issues(none — the one bundled follow-up the consolidated review suggested was fully absorbed into this fix) Validation✅ Type check | Self-fix by Archon · aggressive mode · fixes pushed to |
Summary
push_mission_contextused a batchedupsertto write field-note edits back toedifice_notes. Every push failed withnull value in column "project_id" of relation "edifice_notes" violates not-null constraint, because the upsert payload only carried the edited fields, not the full row — anINSERTfallback (upsert's semantics on no match) tripped theNOT NULLonproject_id.upsertwith a per-observation, targetedUPDATE ... .eq("id", note_id).select("id"). A targeted update never inserts, so it never needsproject_id(or any other NOT NULL column) in its payload — the schema was already correct, only the write path was wrong.updatedis now counted from the rows theUPDATEactually returned/affected, not assumed from the input length.note_id(no row matched) is now routed intoerrors, never silently inserted as a new row.NOT NULLconstraint onedifice_notes— per the hard constraint, the schema is correct and stays untouched.UPDATEs and reports the sameupdatedcount with no state change.Changes
supabase/functions/hal-mcp/index.ts(+14/-13): swap the batchedupsertcall for a per-note targetedUPDATE ... .eq("id", note_id).select("id"); deriveupdatedfrom returned rows; route unmatchednote_ids intoerrors.supabase/functions/hal-mcp/index.test.ts(+77/-29): rewrite the four existing note-path unit tests to mock the new.update().eq().select()chain; add a new "unknown note_id → error, not insert" test; also fix a fifth pre-existing test (push_mission_context — isError when obs succeed but crop fails, not enumerated in the original investigation) that mocked the oldupsertshape and would otherwise fail red against the new handler.Validation
make test(deno checkon both edge functions + offlinepytest): ✅ 157 passed, 17 deselected.deno test index.test.ts(hal-mcp): ✅ 61 passed, 0 failed — including the 5 rewrittenpush_mission_contextnote-path tests and the new unknown-note_idcase.deno linton touched files: net-zero new findings (4 pre-existing findings, identical before/after; not part of the CI gate, which runsdeno checkonly).NOT NULLconstraint onedifice_notestouched — a targetedUPDATEnever needsproject_idin its payload.UPDATEs with no row-count or state drift.Fixes #74