Skip to content

Feat/merge review staleness - #259

Open
AlexAxthelm wants to merge 3 commits into
mainfrom
feat/merge-review-staleness
Open

AlexAxthelm wants to merge 3 commits into
mainfrom
feat/merge-review-staleness

Conversation

@AlexAxthelm

@AlexAxthelm AlexAxthelm commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Extends #258 to address the merge-review dead end: when two pending merge
candidates share a source resource, approving one merges that resource away
(its original becomes an all-null shell), leaving the other candidate
functionally invalid. This surfaces that state — hiding the approve/deny
controls and telling the reviewer which resource was merged into which.

Added as a separate PR (stack) primarily because this one has some choices around the ergonomics of merge review that we may want to discuss more without blocking #258


The primary problem I have with the current flow is around the null-shell behavior. I think the best option if we have candidates:

A + B -> D
A + C // Now Invalid

Is that A+C is marked as invalid/outdated, and the merge candidate is taken out of the review queue (like "denied", terminal state, can't be suggested again, but is a 3rd state), and we run the linker on D in hopes that it picks up a new candidate for C (this should probably be part of the approval, or even the resource creation, process - putting the new resource in the queue to look for matches), so that we would get something like:

A + B -> D
A + C ----
D + C -> E

STIT-418 (part 2 of 2) — resolve repointed resources in the Merge Review pane

Stacked on #258 (base branch feat/redirect-detail); review/merge part 1
first, then this retargets to main.

When an overlapping candidate is approved, a member of another pending candidate
gets merged away. Previously that candidate's Approve was guaranteed to 400 with
a message interpolating repr() on ORM objects (Repointed: [<...object at 0x...>]), and the only exit was Deny — recording a judgment no one made.

  • API: merge-candidate views carry repointed_resources (which members moved
    and their terminal resource), computed for PENDING candidates via a single
    batched root lookup. The approve-time guard now names the terminal resource
    ("resource 102 is now resource 301") on both the merge-candidate and
    write-path guards.
  • Frontend: the review pane shows a warning banner naming each move (linked),
    hides Approve/Deny when a member has moved, and annotates moved members with
    "(now 301)".

Also includes a small perf commit dropping redundant queries on the resolve
paths (get_resolved builds the entity from the row it already loaded; create
skips an always-empty staleness lookup).

The retire/requeue/SUPERSEDED-status flow is intentionally deferred until the
stale-candidate dead end actually blocks a reviewer.

Tests

Backend: origin-column batch mapping, N+1 batch guard, PENDING-only rule, the
cross-candidate case end-to-end (approve 400s naming both ids, no object at 0x, stays PENDING), and the apply_resource_merge guard case. Frontend: stale
banner + hidden controls, facts annotation, compat guard, and a pure-function
suite for the staleness util.

AI assistance

Implemented with Claude Code. Verified: API pytest + frontend vitest green.

When an overlapping merge candidate is approved, a member of another pending
candidate gets merged away (repointed). Previously that candidate's Approve was
guaranteed to fail with a message interpolating repr() on ORM objects
("Repointed: [<...object at 0x...>]") — no id, no pointer to where the resource
went — and the only exit was Deny, recording a review judgment no one made.

Surface the move instead: resolve repointed member ids to their terminal
resource and show the curator where each one went. Completes AC 3 of STIT-418

Backend:
- ResourceModel._parent_tree_cte now emits (origin_id, id) so a batch keeps
  each input mapped to its own root instead of returning an unkeyed set of
  roots; add root_id_by_resource_id(), a batched dict-returning resolver.
  get_root/_root_select are unchanged (documented single-id).
- Add RepointedResourceView and a repointed_resources list on MergeCandidateView
  (plain field, not computed_field, so the detail view's model_dump rebuild
  keeps it). Populated only for PENDING candidates via _resolve_candidates,
  which does a single root lookup over the union of member ids (the queue is
  unpaginated, so per-candidate resolution would be an N+1).
- Replace the repr() merge-guard message with a shared repointed_merge_error()
  that names each id and its terminal target ("resource 102 is now resource
  301"), wired into both the merge-candidate and write-path guards.

Frontend:
- New utils/mergeCandidateStaleness.js: the single reader of repointed_resources,
  degrading to "not stale" when the field is absent.
- Merge review pane shows a warning banner naming each move (both ids linked),
  hides the decision controls when stale (Approve would fail; Deny would record
  a false judgment), and annotates a moved source resource as "(now 301)".

Tests:
- Backend: origin-column batch mapping, the N+1 batch guard, the PENDING-only
  rule, the cross-candidate stale case end-to-end (queue + detail report the
  move; re-approve 400s naming both ids with no "object at 0x", stays PENDING),
  and fill the previously-empty apply_resource_merge guard stub.
- Frontend: stale banner + hidden controls, the facts annotation, a compat guard
  (payload without the field renders as before), and a pure-function suite for
  the staleness util.

The retire/requeue/SUPERSEDED-status flow is intentionally deferred (see PLAN.md)
until the stale-candidate dead end actually blocks a reviewer, keeping this PR
purely additive and rollback-safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

CD summary 3a61211

Frontend: https://witty-mushroom-017a3dc1e-259.westus2.1.azurestaticapps.net

Deployments (4)
service url fqdn
api open pr-0259-api.purplegrass-c07d0a94.westus2.azurecontainerapps.io
entity-linkage open pr-0259-el.purplegrass-c07d0a94.westus2.azurecontainerapps.io
frontend https://witty-mushroom-017a3dc1e-259.westus2.1.azurestaticapps.net
stitch-llm open pr-0259-llm.purplegrass-c07d0a94.westus2.azurecontainerapps.io
Database (1)
db_name postgres_host postgres_port postgres_db
pr_0259 stitch-dev.postgres.database.azure.com 5432 pr_0259
Jobs (2)
job image postgres_db api_url auth_mode
db-migrations ghcr.io/rmi/stitch-api:pr-0259@sha256:6d9b4dc7ec697ec57a548f3ddd3785d3c8498e3b2b830fb8015757143d3e61a0 pr_0259
seed ghcr.io/rmi/stitch-seed:pr-0259@sha256:8132cd8b82c8eb988cbc9abec651f74940391fa52a7eb37efce3450b80df67c9 https://pr-0259-api.purplegrass-c07d0a94.westus2.azurecontainerapps.io/api/v1 stitch-client-bearer-token
Images (4)
build_time commit_time git_sha image image_digest
2026-08-28T16:21:11Z 2026-08-28T16:20:54Z f7f223f ghcr.io/rmi/stitch-api:pr-0259 ghcr.io/rmi/stitch-api:pr-0259@sha256:6d9b4dc7ec697ec57a548f3ddd3785d3c8498e3b2b830fb8015757143d3e61a0
2026-08-28T16:21:11Z 2026-08-28T16:20:54Z f7f223f ghcr.io/rmi/stitch-entity-linkage:pr-0259 ghcr.io/rmi/stitch-entity-linkage:pr-0259@sha256:84e4db2403c9d16e701df08e14ce3bc7a1bc24b06d2da8aacba6c08f7ad6b18a
2026-08-28T16:21:11Z 2026-08-28T16:20:54Z f7f223f ghcr.io/rmi/stitch-seed:pr-0259 ghcr.io/rmi/stitch-seed:pr-0259@sha256:8132cd8b82c8eb988cbc9abec651f74940391fa52a7eb37efce3450b80df67c9
2026-08-28T16:21:11Z 2026-08-28T16:20:54Z f7f223f ghcr.io/rmi/stitch-stitch-llm:pr-0259 ghcr.io/rmi/stitch-stitch-llm:pr-0259@sha256:f79d040427b9b0af22516f2c48b95aa61ca64b00986c24f513479b1d1e3fe9f7

…(STIT-418)

- get_resolved loads the resource row once and, for the common non-repointed
  case, builds the entity from it directly instead of going through
  resolve_root_id + get() (which re-fetched the same id). A repointed id still
  resolves via resolve_root_id (which maps a broken chain to 404) and loads the
  terminal resource.
- create_merge_candidate returns the view with an empty staleness resolution
  instead of running the root-lookup CTE: its members are validated as
  unrepointed immediately above, so the lookup could only ever return empty.
  (approve/deny short-circuit on their terminal status.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AlexAxthelm
AlexAxthelm requested review from jdhoffa and mbarlow12 and removed request for mbarlow12 August 28, 2026 16:55
@AlexAxthelm AlexAxthelm self-assigned this Aug 28, 2026
@AlexAxthelm
AlexAxthelm marked this pull request as ready for review August 28, 2026 16:56
@AlexAxthelm
AlexAxthelm deployed to development August 28, 2026 17:01 — with GitHub Actions Active
@AlexAxthelm
AlexAxthelm deployed to development August 28, 2026 17:01 — with GitHub Actions Active
@AlexAxthelm
AlexAxthelm deployed to development August 28, 2026 17:01 — with GitHub Actions Active
@AlexAxthelm
AlexAxthelm deployed to development August 28, 2026 17:02 — with GitHub Actions Active
@AlexAxthelm
AlexAxthelm deployed to development August 28, 2026 17:02 — with GitHub Actions Active
@AlexAxthelm
AlexAxthelm deployed to development August 28, 2026 17:03 — with GitHub Actions Active
@AlexAxthelm
AlexAxthelm deployed to development August 28, 2026 17:03 — with GitHub Actions Active
@AlexAxthelm
AlexAxthelm deployed to development August 28, 2026 17:04 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown

CD summary 35f76e7

Frontend: https://witty-mushroom-017a3dc1e-259.westus2.1.azurestaticapps.net

Deployments (4)
service url fqdn
api open pr-0259-api.purplegrass-c07d0a94.westus2.azurecontainerapps.io
entity-linkage open pr-0259-el.purplegrass-c07d0a94.westus2.azurecontainerapps.io
frontend https://witty-mushroom-017a3dc1e-259.westus2.1.azurestaticapps.net
stitch-llm open pr-0259-llm.purplegrass-c07d0a94.westus2.azurecontainerapps.io
Database (1)
db_name postgres_host postgres_port postgres_db
pr_0259 stitch-dev.postgres.database.azure.com 5432 pr_0259
Jobs (1)
job image postgres_db
db-migrations ghcr.io/rmi/stitch-api:pr-0259@sha256:cfa9e21f3f572cbb4c2726f226abdc53ed9a0d13e4879762c29efde8f59c96a7 pr_0259
Images (4)
build_time commit_time git_sha image image_digest
2026-08-28T17:01:20Z 2026-08-28T17:01:00Z 388fa91 ghcr.io/rmi/stitch-api:pr-0259 ghcr.io/rmi/stitch-api:pr-0259@sha256:cfa9e21f3f572cbb4c2726f226abdc53ed9a0d13e4879762c29efde8f59c96a7
2026-08-28T17:01:19Z 2026-08-28T17:01:00Z 388fa91 ghcr.io/rmi/stitch-entity-linkage:pr-0259 ghcr.io/rmi/stitch-entity-linkage:pr-0259@sha256:af706928bf511c0f0437fec2afcd36cbe3f1d0f3a0b9a0ab768f5c51781874dd
2026-08-28T17:01:19Z 2026-08-28T17:01:00Z 388fa91 ghcr.io/rmi/stitch-seed:pr-0259 ghcr.io/rmi/stitch-seed:pr-0259@sha256:85bb8793994aee34409b738bcab02b65f107239866e332cb4044c6a71268b371
2026-08-28T17:01:23Z 2026-08-28T17:01:00Z 388fa91 ghcr.io/rmi/stitch-stitch-llm:pr-0259 ghcr.io/rmi/stitch-stitch-llm:pr-0259@sha256:ee03a011a1403b4a1a97c1dfc33eca2169a5b44ba810d02d0e08b04e96308546

Base automatically changed from feat/redirect-detail to main September 3, 2026 15:05
Copilot AI lite review requested due to automatic review settings September 4, 2026 13:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

get_resolved still re-queries the same resource on the repointed path via resolve_root_id, which is an avoidable extra round-trip and undermines the intended perf improvement.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR extends the merge-review workflow to detect and surface “stale” merge candidates caused by overlapping candidates where one approval repoints a resource that another pending candidate still references. It adds an explicit repointed_resources signal to merge-candidate API views, blocks re-approval with clearer id-based error messages, and updates the Merge Review UI to warn reviewers and hide actions when a candidate has become invalid.

Changes:

  • Backend: add repointed_resources to merge-candidate views (computed in a batched root lookup for PENDING candidates) and improve merge guards to return id-based “resource X is now resource Y” errors.
  • Frontend: add a staleness utility and update the Merge Review pane to show a warning banner, annotate moved members, and hide Approve/Deny controls for stale candidates.
  • Tests: add/extend unit + integration tests covering batch resolution, PENDING-only behavior, overlapping-candidate staleness, and merge guard messaging.
File summaries
File Description
deployments/stitch-frontend/src/utils/mergeCandidateStaleness.js Adds a single utility to interpret repointed_resources into {isStale, moves}.
deployments/stitch-frontend/src/utils/mergeCandidateStaleness.test.js Adds a pure-function test suite for the staleness utility.
deployments/stitch-frontend/src/pages/MergeCandidateReviewPage.jsx Renders stale-candidate banner, annotates moved members, and hides decision controls when stale.
deployments/stitch-frontend/src/pages/MergeCandidateReviewPage.test.jsx Adds UI tests for stale banner, hidden controls, and facts annotations.
deployments/api/src/stitch/api/entities.py Introduces RepointedResourceView and adds repointed_resources to MergeCandidateView.
deployments/api/src/stitch/api/db/og_field_resource_actions.py Improves repointed-merge error messaging and optimizes get_resolved read path.
deployments/api/src/stitch/api/db/model/resource.py Adds batched root_id_by_resource_id and augments the recursive CTE to preserve origin-to-root mapping.
deployments/api/src/stitch/api/db/merge_candidate_actions.py Computes and propagates repointed_resources for candidates (batched for list endpoint; resolved for detail/actions).
deployments/api/tests/test_merge_candidate_actions.py Updates unit tests and adds tests for batched resolution and terminal-status skip logic.
deployments/api/tests/routers/test_merge_candidates_integration.py Adds end-to-end integration test for overlapping-approval staleness and id-based 400 messaging.
deployments/api/tests/db/test_resource_actions.py Adds integration tests for root_id_by_resource_id mapping correctness and empty-input behavior.
deployments/api/tests/db/actions/test_merge.py Adds guard test ensuring apply-merge rejects repointed inputs with id-based message (no repr() leakage).
Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +196 to +198
if model.repointed_id is not None:
root_id = await resolve_root_id(session, id)
return await get(session, root_id, licensed_sources=licensed_sources)
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

CD summary 83e3315

Frontend: https://witty-mushroom-017a3dc1e-259.westus2.1.azurestaticapps.net

Deployments (4)
service url fqdn
api open pr-0259-api.purplegrass-c07d0a94.westus2.azurecontainerapps.io
entity-linkage open pr-0259-el.purplegrass-c07d0a94.westus2.azurecontainerapps.io
frontend https://witty-mushroom-017a3dc1e-259.westus2.1.azurestaticapps.net
stitch-llm open pr-0259-llm.purplegrass-c07d0a94.westus2.azurecontainerapps.io
Database (1)
db_name postgres_host postgres_port postgres_db
pr_0259 stitch-dev.postgres.database.azure.com 5432 pr_0259
Jobs (1)
job image postgres_db
db-migrations ghcr.io/rmi/stitch-api:pr-0259@sha256:4e84bbebc59552192978814d3b27c0a0d624161bd705680d12160d1dc256d94c pr_0259
Images (4)
build_time commit_time git_sha image image_digest
2026-09-04T13:17:33Z 2026-09-04T13:17:13Z bdde2c3 ghcr.io/rmi/stitch-api:pr-0259 ghcr.io/rmi/stitch-api:pr-0259@sha256:4e84bbebc59552192978814d3b27c0a0d624161bd705680d12160d1dc256d94c
2026-09-04T13:17:31Z 2026-09-04T13:17:13Z bdde2c3 ghcr.io/rmi/stitch-entity-linkage:pr-0259 ghcr.io/rmi/stitch-entity-linkage:pr-0259@sha256:34bc444baa61d180271843bd4cfa2fac766784ec93f05bf8bf287bcb058c948e
2026-09-04T13:17:34Z 2026-09-04T13:17:13Z bdde2c3 ghcr.io/rmi/stitch-seed:pr-0259 ghcr.io/rmi/stitch-seed:pr-0259@sha256:7576c09da4861ffe7feb11e8c18c37680f96fdf40b804f413967a6ae80e9a1ec
2026-09-04T13:17:30Z 2026-09-04T13:17:13Z bdde2c3 ghcr.io/rmi/stitch-stitch-llm:pr-0259 ghcr.io/rmi/stitch-stitch-llm:pr-0259@sha256:6bf247b1041f1047502a58f198faa2706cbb02e9ac92ac14e25514b3dd003cb0

@mbarlow12 mbarlow12 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing here is badly executed. The problem is that these changes (and somewhat those in #258) work around existing design issues that should be addressed. I'm concerned merging it further reinforces those designs and will make future change harder.


1. AC discrepancy

STIT-418 AC 3: "Same for any relevant Merge Candidates in the Merge Review pane."
The AC asks merge candidates to re-route repointed resources, but these changes appear to only add metadata to the response to inform the reviewer of repointing.

A narrower reading of the AC affords a much smaller change. When approving (A,B) → D, update any pending candidate containing B to contain D instead:

  • (B,C) becomes (D,C), stays PENDING, reviewer continues
  • no new API field, no frontend util, no CTE change
  • ~15 lines in approve_merge_candidate, same transaction

This might also warrant dropping the fingerprint in favor of a constraint that
a given resource id can't participate in more than one candidate at a time.

2. Additional code churn

Across #258 + #259, we've added ~1000 lines and two new permanent public API fields.

  • _parent_tree_cte was stable for months; it's now rewritten to carry an origin
    column that serves one view field
  • get is largely duplicated in get_resolved
  • requested_resource_id (#258) has zero frontend consumers. ResourceDetailPage reads location.state.redirectedFrom, which it sets itself
  • repointed_resources (#259) is a second public field for the same concept

Given how we do matching right now, have we actually observed overlapping ids in merge candidates? It seems like the edge case we're handling here is really, really unlikely.

3. Benefits of halting and addressing tech debt

I think it's worth making a decision on STIT-489: all API endpoints live behind stitch-api, keeping in mind that nothing has to move at once.

The point of committing to the direction is that it decouples the API contract from the implementation. Once an endpoint sits behind stitch-api, whether the work happens in a module, a proxied service, or something else is an implementation detail we can revisit later without affecting a consumer. This makes decisions that affect backend internals more reversible and flexible, and it's what makes the two changes below safe to pursue incrementally.

Change 1: one pending candidate per resource. A partial unique index on
merge_candidate_items.resource_id where the candidate is pending. If two pending candidates cannot share a resource, then staleness becomes impossible. I'm also pretty sure this removes the need for fingerprint as well.

Change 2: entity-linkage becomes a module rather than a deployment. Matching moves into stitch-api and excludes repointed resources and resources already claimed by a pending candidate. This would retain the ~220 lines of matching logic and drop essentially everything else from entity-linkage.

This also narrows matching to a single function with a stable signature, roughly propose_merge_groups(session, available_ids) -> list[set[resource_id], ...]. It's in-process with direct DB access, and we'd be free to update the implementation however we want as long as the signature is maintained.

Another way of thinking of this relates to STIT-672 (manual merge candidate creation): a "create merge candidates" form with linkage as an auto-fill feature (like AI enrichment). Proposals are ephemeral and generated on the fly, and we only persist whatever the user actually submits.

Change 3: making the API more strictly RESTful (somewhat out of scope here). This would help clarify the order of operations in this workflow. Candidates become a resource (in the REST sense) with the desired CRUD operations. Today

  • entity-linkage creates and persists candidates
  • approve both transitions the candidate and creates a resource
  • merging is unreachable except through a candidate.

A more RESTful pattern would be:

  • /og-merge-candidates: GET, POST
  • /og-merge-candidates/{id}: GET (optionally PUT & DELETE)
  • /og-merge-candidates/proposals: what "entity-linkage" becomes
    • ephemeral GET request that essentially pre-populates a form
    • clicking "Submit" is what persists the merge candidates (i.e. POST /og-merge-candidates)
  • /oil-gas-fields/merge: approving a merge candidate sends a POST, guarded by checking the merge candidate table
    • could also be a POST to /oil-gas-fields with the constituent ids

This is a bigger conversation than this PR, but the point is that

  • API consolidation allows us freedom to make such decisions
  • stricter REST design makes it easier to reason about what should happen for a given operation.

Related open tickets:

STIT-489 Consolidate API routes in stitch-api
STIT-757 15-hour bulk run on prod volumes
STIT-644 Entity Linkage slows down entire app
STIT-619 Speed up the bulk pass (concurrency + read cache)
STIT-719 Bulk run aborts on one transient HTTP timeout
STIT-621 Durable, multi-worker job state
STIT-740 Status stuck "Running" with no progress
STIT-620 Bound the bulk-run result summary

Setting STIT-489 aside, most of these exist because of the network boundary and in-memory job manager and how the current design/topology constrains our options. Many become either irrelevant or at least much easier to solve with direct DB access and focusing on application boundaries first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants