Skip to content

Fix concept reassignment discarding and recreating unchanged BC entities - #323

Merged
pendingintent merged 5 commits into
masterfrom
release-v1.6.2
Sep 24, 2026
Merged

pendingintent merged 5 commits into
masterfrom
release-v1.6.2

Conversation

@pendingintent

Copy link
Copy Markdown
Owner

Summary

Reassigning the same Biomedical Concept code(s) to an activity via POST /soa/{soa_id}/activities/{activity_id}/concepts deleted every activity_concept row for that activity and recreated it with a fresh UID — even when nothing had actually changed. Because the next UID was computed as MAX(live rows)+1 immediately after the activity's own row was deleted, this could reissue the exact number that was just freed. The subsequent orphan-cleanup step then unconditionally deleted the biomedical_concept row for that UID, wiping out the freshly recreated entity (and its properties) moments after creation, and leaving the activity's concept assignment pointing at nothing.

This surfaced while investigating a real report: after reassigning concept C114209 to a study's RANDOMIZATION activity, its USDM export showed the Biomedical Concept with no properties. Root-caused, reproduced, and fixed against the live data — confirmed the fix makes an unchanged reassignment a byte-for-byte no-op (same activity_concept.id and biomedical_concept.id before/after).

Changes

  • set_activity_concepts now reconciles instead of replacing (src/soa_builder/web/routers/activities.py): diffs the requested codes against the activity's current assignment. Codes that are unchanged keep their row and UID completely untouched — no delete, no reinsert, no new UID, no re-triggered CDISC Library enrichment/property-population background tasks. Only genuinely added/removed codes are touched.
  • Durable uid_counter table (new migration in migrate_database.py): all 11 get_next_*_uid generators in utils.py now draw from a persistent per-(soa_id, prefix) counter instead of recomputing MAX(live rows)+1 on every call, so a freed number is never reissued — even when rows are genuinely removed and re-added within the same request. Each generator lazily seeds the counter once from its original max-scan logic, so existing SoAs pick up exactly where their current numbering left off.
  • Guarded _cleanup_orphaned_concept_rows (app.py): won't delete a biomedical_concept row that's still referenced by a live activity_concept row — defense-in-depth alongside the two fixes above.
  • Removed a dead, unreachable duplicate of set_activity_concepts in app.py (shadowed at the HTTP layer by the router version, confirmed via routing order + live testing). It carried the same bug and never scheduled the biomedical-concept-property background population task at all — so concepts assigned via the HTML form UI (ui_set_activity_concepts, which called this dead copy directly) never got their properties populated at all. The UI form handler now calls the fixed router implementation directly.

Test plan

  • pytest — full suite passes (668 tests), including two new regression tests: reassigning an unchanged code is a true no-op, and adding+removing codes in one call preserves the kept code's UID.
  • Fixed two pre-existing tests for this endpoint that posted the wrong request-body shape and only asserted status_code in (200, 422) (i.e. never actually verified success).
  • flake8 clean on all changed files.
  • Verified uid_counter migration is idempotent (created once, no errors, no duplicate rows on a second run against a DB copy).
  • Verified live against the originally-reported production data: reassigning C114209 to the RANDOMIZATION activity is now a no-op (activity_concept.id and biomedical_concept.id identical before/after), with its 3 properties intact.

🤖 Generated with Claude Code

Reassigning the same concept code(s) to an activity deleted every
activity_concept row and recreated it with a fresh UID, even when
nothing actually changed. Because the next UID was computed as
MAX(live rows)+1 immediately after the activity's own row was
deleted, this could reissue the exact number just freed, and the
subsequent orphan cleanup then deleted the freshly recreated
biomedical_concept row (and its properties), leaving the activity's
concept assignment pointing at nothing.

- set_activity_concepts now diffs old vs. new codes and only touches
  what actually changed; unchanged codes keep their row and UID
  untouched.
- Added a durable uid_counter table and moved all 11 get_next_*_uid
  generators onto it so a freed number is never reissued, even when
  rows are genuinely removed and re-added within one request.
- Guarded _cleanup_orphaned_concept_rows against deleting a UID that
  was reissued to a live row.
- Removed a dead, unreachable duplicate of set_activity_concepts in
  app.py (shadowed by the router version) that carried the same bug
  and never scheduled the biomedical-concept-property background
  population task at all; the UI form handler now calls the fixed
  router implementation directly.
@pendingintent pendingintent linked an issue Sep 16, 2026 that may be closed by this pull request
@pendingintent pendingintent self-assigned this Sep 16, 2026
@pendingintent pendingintent added the bug Something isn't working label Sep 16, 2026
@pendingintent pendingintent added this to the release-v1.6.2 milestone Sep 16, 2026
extra_skip stripped freeze_id out of the INSERT column list before
col_overrides could apply its sentinel value, so the column fell back
to SQLite's NULL default instead of the intended 0.
The background enrichment task only wrote the fetched name back to
biomedical_concept.name/label, never to the denormalized
activity_concept.concept_title snapshot shown in the UI. When the
initial assignment-time lookup missed, that snapshot stayed stuck on
the raw concept code even after enrichment succeeded.
@pendingintent

pendingintent commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner Author

@claude tests failed on the latest push

The action was defaulting to claude-opus-5[1m], a model/beta our
ANTHROPIC_API_KEY isn't entitled to, causing every Claude-triggered
workflow to fail instantly (is_error:true, 0 cost, 1 turn).
…pulated in the USDM JSON file (see #322).

This has now been addressed in commit fc0b6dc
These USDM JSON and DDS JSON files were generated with the fixed code.
@pendingintent
pendingintent merged commit 88ae5f3 into master Sep 24, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BC Properties not getting assinged for newly assigned BC

1 participant