Release v0.3 - #39
Conversation
…dded pagination to dashboard
…kills - CLAUDE.md: document loinc blueprint (8 total), correct port to 8081, add Testing section (pytest command, conftest isolation, pre-commit, CI) - Rewrite mike.md PM agent for this project (was mis-templated from another repo; referenced non-existent docs/modules/ and phantom agents) - Add project-level cdisc-concept-explorer agent adopting the orphaned agent-memory (verified COSMOS v2 endpoint reference) - Fix stale cdisc-frontend-dev memory (loinc blueprint, IngestionRecord DB queue not session, cdisc_api is a full client, db imports from extensions) - New skills: run-concept-curation (smoke.sh, 14 checks, throwaway DB; verified passing) and code-review (project checklist) - (.claude/settings.json also updated locally: Stop hook narrowed to README-PROGRESS.md changelog; file is gitignored)
…cache - tests/test_export_service.py: JSON/XLSX/governance-XLSX/ODM-XML exports - tests/test_cdisc_api_cache.py: _cached() fresh/stale/expired/error states and cache-key hygiene (no raw API key in keys) - tests/test_dashboard.py: ThreadPoolExecutor fan-out with mocked client, graceful degradation on API errors, local DB stats - tests/test_specializations_routes.py: full route coverage Bug fix found by the new tests: routes/specializations.py passed 'specs=' but templates/specializations.html iterates 'specializations' — the All Specializations table never rendered. Also pointed the row Edit link at the real detail route (spec.id does not exist on the model). Suite: 202 passed.
…mapper
- app.py: configure logging once in create_app (root untouched if another
runner already installed handlers)
- services/audit.py (new): log_change() queues an AuditLog row on the
session; caller owns the commit so change + audit land atomically. All 8
inline AuditLog blocks in routes/bc.py and routes/governance.py now use it.
- routes/bc.py: _apply_bc_form() replaces the duplicated form-to-model
field mapping in create()/edit() (semantics preserved: create keeps raw
values, edit normalizes cleared ncit/parent/loinc to None)
- services/{cdisc_api,ncit_api,loinc_api}.py: narrow 'except Exception' to
requests.RequestException + parse errors and log every caught failure
with context; _cached() keeps its intentional broad catch (documented)
and now logs stale-serve and hard-failure paths
- services/ingestion.py: parser catches stay broad by design (arbitrary
user files) but now log with tracebacks
- tests: failure mocks updated to raise requests.RequestException (what
requests.get actually raises) instead of bare Exception
Suite: 202 passed; smoke.sh 14/14.
…le source of truth Previously db.create_all() in create_app() coexisted with a 4-revision Alembic chain that had no baseline (first revision only ALTERed a table) and an internally inconsistent code/loinc_code rename+re-add sequence — 'flask db upgrade' failed on any fresh database. - Delete the 4 legacy revisions; generate one autogenerated baseline (51d4a009d291) creating all six tables. Verified: fresh 'flask db upgrade' schema is byte-identical (tables + indexes) to create_all(). - Remove db.create_all() from create_app(); it is now side-effect free (also a prerequisite for the MCP server sharing the app factory). - New db_bootstrap.ensure_db(): fresh DB -> upgrade; legacy create_all DB -> stamp head; DB stamped at a pre-squash revision -> restamp head (schema verified via column check first; outdated schemas raise with recovery instructions). Wired into 'python app.py' startup. - Verified against a copy of the real instance DB (27 BCs): restamped c2d4e6f8a0b1 -> 51d4a009d291 with data intact. - tests/test_db_bootstrap.py covers all four states + idempotency. - README/CLAUDE.md: document the bootstrap and the migrate-on-model-change rule. Suite: 207 passed; smoke.sh 14/14 (fresh-DB boot path).
API clients (services/): - New services/api_cache.py — one stale-tolerant cache shared by all three clients (was: bespoke helper in cdisc_api, hand-rolled dict in ncit_api, none in loinc_api). cdisc_api re-exports the old names for compatibility. - CDISCApiClient: dual-header auth parity with soa-workbench — CDISC_SUBSCRIPTION_KEY/Ocp-Apim-Subscription-Key preferred, CDISC_API_KEY/api-key fallback (config.py gains CDISC_SUBSCRIPTION_KEY) - NCItApiClient: honors NCIT_API_BASE_URL from config/env (was a hardcoded class constant) - LoincApiClient: search results now cached (5 min TTL, stale fallback) - conftest: autouse fixture clears the shared cache between tests Hygiene: - Remove stale committed results.txt (pytest log) and empty src/ dir; drop the stray Excel lock file deletion that was already staged - app.py: debug mode gated by FLASK_DEBUG (default on for dev) - Split requirements-dev.txt (pytest, black, flake8, isort, pre-commit) out of requirements.txt - .flake8: max-line 200 (was 999); F401/F841/E501/E301/E302/F824 no longer ignored — fixed the fallout (unused imports/locals removed, conftest model imports marked as intentional metadata registration) - pre-commit: isort added (profile=black, configured in pyproject) - CI: install dev deps; run isort/black/flake8 before tests - import order normalized repo-wide by isort Suite: 207 passed; flake8 clean; smoke.sh 14/14.
New mcp_server/ package mirroring the soa-workbench server pattern (raw MCP SDK, _TOOLS list + _dispatch dict, sync handlers via run_in_executor, stdio transport, python -m mcp_server entry). Registered in .mcp.json. - Handlers run inside a Flask app context pushed per call (executor threads) via a decorator; the lazy app singleton reuses create_app() + ensure_db(), so the MCP process resolves the same instance/ SQLite file and service clients as the web app. Tests inject the in-memory test app through mcp_server.server._app. - Tools: list_bcs (q/status/pagination, capped at 200/page), get_bc (BC + DECs + specializations + governance history), search_ncit, get_ncit_concept, search_loinc, search_cdisc_library (title filter), get_library_bc, list_review_queue (board columns + pending ingestion). - mcp>=1.0.0 added to requirements. Verified end-to-end over stdio: initialize -> tools/list (8) -> tools/call list_bcs against a scratch DB (auto-bootstrapped by ensure_db). Suite: 223 passed (16 new MCP tests via direct _dispatch).
…iters
Service extraction (one write path for routes AND MCP):
- services/bc_service.py: create_bc, update_bc, apply_bc_fields,
save_decs, map_ncit_to_bc, submit_bc_for_review (+ NotFoundError)
- services/governance_service.py: advance_governance, reject_bc
(owns STATUS_ORDER; routes/governance.py re-imports it)
- routes/bc.py, routes/governance.py, routes/ncit.py are now thin
form->dict->service adapters; flash/redirect/AJAX behavior unchanged
(existing route tests were the refactor safety net — all green)
- Intentional behavior fix: /ncit/resolve now writes an AuditLog entry
('ncit_mapped'); it was the only mutation with no audit record
MCP write tools (6): create_bc (optional decs), update_bc,
map_ncit_to_bc (promotes IMPORT_ ids), submit_bc_for_review,
advance_governance, reject_bc — actor defaults to 'mcp' so agent writes
are distinguishable in the audit trail.
extensions.py: SQLite connections get PRAGMA journal_mode=WAL +
busy_timeout=15000 (verified: fresh DB reports 'wal') — required now
that Flask and the MCP server both write instance/cdisc_curation.db.
Suite: 233 passed (10 new write-path MCP tests assert AuditLog and
GovernanceRecord rows); smoke.sh 14/14.
Pulling origin/release-v0.3 brought in two "Potential fix for pull request finding" commits that left both files uncompilable: bc_service.py had a stray duplicate `for` loop, and export.py was missing the inner header-iteration loop after a partial edit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The prior fix-up commit changed the export route's query to filter BiomedicalConcept directly, leaving the GovernanceRecord import unused and failing flake8's F401 check in CI for PR #39. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 71 out of 72 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
services/bc_service.py:134
- map_ncit_to_bc() can change the BiomedicalConcept primary key (bc.bc_id) when promoting IMPORT_ ids, but it does not update dependent rows (DataElementConcept.bc_id, DatasetSpecialization.bc_id, GovernanceRecord.bc_id, or child BiomedicalConcept.parent_bc_id). This can orphan related records and/or violate FK constraints once an imported BC has DECs or governance history. If promotion is kept, update all referencing rows in the same transaction (and consider guarding against collisions when the target ncit_code already exists).
templates/library_bc_detail.html:119 - In the CDISC Library BC detail view, bc.href refers to the CDISC Library resource URL, but the label was changed to “NCI Thesaurus HREF”, which is misleading for users.
| spec = DatasetSpecialization( | ||
| vlm_group_id=vlm_group_id, | ||
| bc_id=bc_id, | ||
| domain=request.form.get('domain', 'SDTM'), | ||
| short_name=request.form.get('short_name', ''), | ||
| domain=request.form.get("domain", "SDTM"), | ||
| short_name=request.form.get("short_name", ""), |
Promoting a BC's primary key from an IMPORT_ id to its resolved NCIt code only updated the BiomedicalConcept row itself. Since SQLite FK enforcement isn't enabled (extensions.py sets WAL mode but not PRAGMA foreign_keys=ON), DataElementConcept, DatasetSpecialization, and GovernanceRecord rows kept the old bc_id and were silently orphaned; a colliding ncit_code also surfaced a raw IntegrityError instead of a clean error. Now bulk-updates all dependent bc_id/parent_bc_id columns in the same transaction and raises ValueError on collision before mutating the primary key. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 71 out of 72 changed files in this pull request and generated no new comments.
Suppressed comments (10)
templates/library_bc_detail.html:117
- The label "NCI Thesaurus HREF" is misleading here: this template is rendering
bcfrom the CDISC Library API (seeroutes/bc.py:88+), andbc.hrefis the CDISC Library link, not an NCIt link.
services/bc_service.py:134 - When promoting an
IMPORT_BC id to the resolved NCIt code, this can violate the primary-key uniqueness constraint if a BC with the targetncit_codealready exists, leading to an unhandled IntegrityError on commit.
routes/ingestion.py:147 - Rejecting an
IngestionRecordmutates its status but does not write an AuditLog entry for that change, leaving an untracked state transition.
routes/ingestion.py:178 - In
approve_all(), setting anIngestionRecordto approved is also a state transition that currently isn’t audited.
routes/ingestion.py:180 - In
approve_all(), the else-branch also marks the ingestion record approved without auditing the ingestion record status change.
services/bc_service.py:116 save_decs()deletes and recreatesDataElementConceptrows and commits immediately, but this mutation is not recorded inAuditLog. Also, committing inside the service prevents bundling BC + DEC changes into a single atomic transaction (and contradictsservices/audit.log_change()’s “caller owns the commit” contract).
routes/ingestion.py:138- Approving an
IngestionRecordmutates its status but does not write an AuditLog entry for that change, which leaves an untracked state transition in the ingestion pipeline.
This issue also appears on line 145 of the same file.
services/ncit_api.py:21
_pick_definition()assumesdefinitionsis iterable, butdict.get('definitions', [])will still returnNoneif the upstream payload includes the key with a null value. That would raise a TypeError during iteration and break concept/search rendering.
routes/ingestion.py:161- In
approve_all(), records with validation errors/duplicates are marked rejected without any AuditLog entry for the ingestion record status change.
This issue also appears in the following locations of the same file:
- line 177
- line 180
.mcp.json:7
- The PR description says the new
.mcp.jsonshould launch the server withCDISC_API_KEYin env, but the current config doesn’t define any env passthrough. If the MCP host doesn’t automatically inherit the parent environment, external API tools may fail unexpectedly.
"cdisc-curation": {
"command": ".venv/bin/python",
"args": ["-m", "mcp_server"],
"cwd": "."
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 71 out of 72 changed files in this pull request and generated no new comments.
Suppressed comments (5)
templates/library_bc_detail.html:117
- The metadata label says "NCI Thesaurus HREF", but this page is rendering a CDISC Library concept (routes/bc.py passes
bcfromCDISCApiClient().get_bc()), sobc.hrefis a CDISC Library URL/path. This label is misleading for users.
routes/specializations.py:82 - Creating a specialization doesn't check whether the VLM Group ID already exists. Submitting the form twice (or colliding with an existing ID) will raise an IntegrityError on commit and return a 500 instead of a user-facing message.
routes/dashboard.py:48 - Dashboard now renders the full CDISC Library BC/spec lists into the HTML and paginates purely client-side. If the Library returns a large list, this can significantly increase response size and slow down rendering, even though only 20 rows are shown at a time.
routes/ingestion.py:176 - This adds another inline
AuditLog(...)construction (approve_all), even though the PR introducesservices.audit.log_change()as the shared audit helper. Keeping one helper avoids drift in audit fields and makes future changes (e.g., adding more context) easier.
README.md:30 - README installation instructions only install
requirements.txt, but this PR moves pytest/flake8/black/pre-commit intorequirements-dev.txt. As written,pre-commit installwill fail (and tests/lint tools won't be available) unless users also install dev requirements.
# 3. Install dependencies
pip install -r requirements.txt
# 4. Install git hooks for code quality
pre-commit install
- routes/specializations.py: create() now upserts on an existing vlm_group_id (the edit flow) instead of always INSERTing, which raised an IntegrityError/500 when editing; also parses the variables[i][...] form rows that were previously discarded on every submission. - templates/library_bc_detail.html: relabel "NCI Thesaurus HREF" to "CDISC Library HREF" since bc.href is the CDISC Library API's own self-link, not an NCIt URL. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 71 out of 72 changed files in this pull request and generated no new comments.
Suppressed comments (3)
routes/specializations.py:150
generate()also queries DECs without anorder_by(), so the generated specialization variable list may come out in an arbitrary order. Ordering byDataElementConcept.sort_orderkeeps the generated specialization aligned with DEC ordering used elsewhere (including exports).
routes/specializations.py:83_variables_from_form()stops parsing at the first missing index (while variables[i][name] in form). Because the UI lets users delete rows (leaving non-contiguous indices), this will silently drop all later variable rows. Also, the template/JS submits the required checkbox asvalue="1", but the server only treats"on"as checked, sorequiredwill always serialize asFalsewhen submitted from the browser.
routes/specializations.py:141generate_from_dec()builds variables from DECs without anorder_by(), so the returned variable order is database-dependent. Since DECs already havesort_order, ordering here will make the UI and JSON response deterministic.
This issue also appears on line 149 of the same file.
Closes out Workstream B.1 from PR #39: app.py and the three API clients already had loggers, but routes/*.py and the rest of services/*.py had none. No behavior change yet since none of these files currently catch exceptions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add explicit brand-compliance requirements to cdisc-frontend-dev's agent instructions and CLAUDE.md's conventions so any front-end work invokes the cdisc-brand-guidelines skill before creating or updating UI, not just when styling is explicitly requested. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 72 out of 73 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
routes/specializations.py:87
- _variables_from_form() stops parsing as soon as variables[0][name] is missing, which will happen if the user deletes the first row (your JS removes rows without renumbering). Also, the checkbox posts value="1" but the backend checks for "on", so
requiredwill always be False.
templates/specializations.html:213 - The list view uses
spec.variable_count, but DatasetSpecialization has no such attribute, and this template also comparesspec.variable_count != 1which can behave unexpectedly with an undefined value. Sincespec.variablesis the source of truth, compute the count from that list.
routes/dashboard.py:52 - The dashboard now renders all CDISC Library BCs/specializations into the HTML and paginates client-side. If the Library returns hundreds/thousands of rows, this will significantly increase response size and render time (regression vs the prior server-side slice to 50).
templates/governance.html:66 - The Provisional column no longer renders a Reject button, but the backend still supports POST /governance/reject/<bc_id> (and other columns still show Reject). This makes it impossible to reject a Provisional BC via the UI.
templates/library_bc_detail.html:69 - The "Long Common Name" fallback uses
lc.system(a system URL) whenloinc_data.LONG_COMMON_NAMEis missing, so the UI can show a URL in a field labeled as a name. Better to fall back to an em dash (or another name-like field) when the LOINC lookup didn't return that attribute.
| for row_idx, bc in enumerate(bc_list, start=2): | ||
| for col_idx, field in enumerate(BC_EXPORT_FIELDS, start=1): | ||
| ws.cell(row=row_idx, column=col_idx, value=bc.get(field, '')) | ||
| ws.cell(row=row_idx, column=col_idx, value=bc.get(field, "")) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 72 out of 73 changed files in this pull request and generated no new comments.
Suppressed comments (4)
templates/specializations.html:213
spec.variable_countis not defined on DatasetSpecialization, so this cell will always fall back to 0 and the pluralization condition will also evaluate against an undefined value. Use the storedvariableslist length instead.
templates/library_bc_detail.html:70- This "Long Common Name" field falls back to
lc.system(a system URL) whenloinc_datais empty, which is misleading output for the label. Prefer rendering an em dash (or another explicit placeholder) when the long common name isn't available.
templates/specializations.html:38 - The edit flow posts back to the same upsert handler keyed by
vlm_group_id. Leaving the VLM Group ID field editable makes it easy to accidentally create a new specialization instead of updating the existing one (changing the primary key in the form changes which row is updated). Consider making this input read-only whenedit_specis set.
README.md:31 - The setup instructions run
pre-commit installbut only installrequirements.txt. In this PR, black/flake8/pytest/pre-commit were moved torequirements-dev.txt, so a fresh dev venv following these steps won't havepre-commit(or its hooks) available. Consider installing both requirements files for development (or explicitly call out the difference between runtime vs dev deps).
# 3. Install dependencies
pip install -r requirements.txt
# 4. Install git hooks for code quality
pre-commit install
</details>
export_xlsx() read bc["code"] for every row, but BiomedicalConcept.to_dict() (the shape /bc/export actually passes in) has no "code" key, only loinc_code, so the column was always blank. Now sources "code" from loinc_code, matching the pattern export_governance_xlsx() already uses. Resolves the last open Copilot comment on PR #39. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 72 out of 73 changed files in this pull request and generated no new comments.
Suppressed comments (4)
services/bc_service.py:122
save_decs()replaces allDataElementConceptrows but does not write anAuditLogentry, so DEC mutations are currently invisible in the audit trail (and callers likeroutes/bc.pyrely on this function for DEC persistence). This breaks the project requirement that every curated-entity mutation is audited. Consider also accepting anactorso MCP writes can recordactor="mcp".
routes/specializations.py:87_variables_from_form()assumes the submitted indices are contiguous starting at 0 (while variables[0][name] in form). But the UI allows deleting rows without renumbering, which will create index gaps (e.g. variables[1] exists but variables[0] doesn't) and cause the parser to return an empty/partial list. Also, the template checkboxes usevalue="1", so checking== "on"will always be false for real form submissions.
mcp_server/server.py:494_create_bccomputes anactor(defaulting to "mcp") but does not pass it through tobc_service.save_decs(). Once DEC writes are audit-logged, this will incorrectly attribute DEC changes to the default actor (or lose actor entirely).
actor = str(args.get("actor") or "mcp")
bc = bc_service.create_bc(args, actor=actor)
decs = args.get("decs") or []
if decs:
bc_service.save_decs(bc.bc_id, decs)
return _get_bc.__wrapped__({"bc_id": bc.bc_id})
routes/dashboard.py:52
- The dashboard now renders all CDISC Library BCs/specializations into the HTML (then paginates client-side). If these lists are large, this can significantly increase response size, template render time, and browser work (even though only 20 rows are shown). Consider server-side pagination and/or limiting the number of rows rendered while still showing the total counts.
cdisc-concept-curation Improvement Plan
Context
A full analysis of
/Users/dmoreland/projects/cdisc-concept-curation(Flask 3 +Flask-SQLAlchemy Biomedical Concept curation app: ingest → SME review → governance
→ publish) found a functionally complete app (branch
release-v0.2, ~164 tests,clean test isolation) with several classes of debt:
(
services/cdisc_api.py,ncit_api.py,loinc_api.py) swallow exceptions into{"error": ...}return values; dual schema management (db.create_all()inapp.pycoexisting with a 4-revision Alembic chain that has no baseline and aninconsistent
code/loinc_coderename/re-add); 6 duplicated audit-log blocks inroutes/bc.py(373 LOC); untestedservices/export.py,routes/specializations.py,routes/dashboard.py, and thecdisc_apicache;no type hints; stale committed
results.txt; doc drift (README/CLAUDE.md say 7blueprints and port 5000; reality is 8 blueprints and port 8081).
mike.mdagent is a mis-templated copy from another project("Kanojo") referencing non-existent files/agents; CLAUDE.md omits the LOINC
feature and never states the test command; zero project skills; orphaned
agent-memory/cdisc-concept-explorer/with verified API docs but no agent;cdisc-frontend-devmemory contradicts CLAUDE.md on two facts;settings.jsonhas broad
Write(*)/Edit(*)grants and a Stop hook that auto-rewritesREADME.md (conflicts with the recorded "ask before edits" preference).
(
src/soa_builder/mcp/server.py: raw MCP SDK,_TOOLS+_dispatchseam,stdio, direct-dispatch tests) is a proven pattern to mirror; this project's
services layer is callable outside the request context.
User decisions: MCP read-only tools first then writes; codebase quick wins + the
Alembic schema fix (auth/actor identity deferred); rewrite the mike agent for this
project; keep the Stop hook but narrow it.
Conventions: TDD (tests before implementation, per project memory), pre-commit
runs black/flake8/pytest, CI runs
pytest --tb=shorton Python 3.12.Workstream A — Claude config & docs (small, do first)
loincblueprint (8 total,/loinc,services/loinc_api.py,tests/test_loinc.py); correct the "7 blueprints" count.config.py).pytest --tb=short, in-memory SQLite viatests/conftest.pyTestConfig, pre-commit hooks (black/flake8/pytest), CIworkflow (
.github/workflows/ci.yml, Python 3.12).mike.mdfor this project: PM assistant anchored onREADME-PROGRESS.md(which exists); remove Kanojo references, thenon-existent
docs/modules/*paths, and the phantom teammate agents; referencethe real agent roster (
cdisc-frontend-dev, plus the new concept-curationagent below).
cdisc-concept-exploreragent (or a thin project variant referencing theuser-level one) so
agent-memory/cdisc-concept-explorer/reference_api_endpoints.md(verified COSMOS v2 endpoint docs) is actually loaded; generalize the
user-level
cdisc-concept-explorer.mdandncit-concept-resolver.mdwordingso they apply to both soa-workbench and this project.
cdisc-frontend-devmemory: fixproject_foundation.md(ingestion queue is the
IngestionRecordDB table, not session;cdisc_api.pyis a full client, not a stub; add the loinc blueprint).
.claude/settings.json: instead of rewritingREADME.md wholesale, have it update only
README-PROGRESS.md's changelogsection; keep the 120s timeout.
files.associations,emmet.includeLanguages); withWrite(*)/Edit(*)present, drop theredundant per-file grants (or narrow the broad grants if least-privilege is
preferred — default: drop redundant granular entries, keep broad).
.claude/skills/run-concept-curation/SKILL.md+smoke.sh: venv activate,python app.py(port 8081,CDISC_API_KEYenv), curl checks against/,/bc/,/governance/board,/ncit/search, teardown; use a throwawayDATABASE_URLsqlite path, neverinstance/cdisc_curation.db..claude/skills/code-review/SKILL.md: project-specific checks — route→service→model layering, audit-log coverage on mutations, API-client error
contract, TDD, Alembic migration for any model change.
Workstream B — Codebase quick wins + schema fix
services/*.py,app.py)loggingconfig inapp.py/config.py; module loggers in each serviceand route file.
{"error": ...}return contract for now (templates depend on it)but log every caught exception with context (URL, status) at error level;
narrow
except Exceptiontorequests.RequestException+ JSON/parse errorswhere evidence allows (12 sites, all in services).
routes/bc.pyde-duplicationAuditLog(...) + db.session.add + commitblocks intoa
services/audit.pylog_change(entity_type, entity_id, action, before, after, actor)helper (this also becomes the shared write path for MCPmilestone 2).
request.form.get(...)field mapping increate()/edit()into one form-to-model mapper.single source of truth = Alembic; proper baseline; existing DBs stamped; fresh
DBs created via
flask db upgrade; tests keepcreate_all()in conftest.tests/test_export_service.py(json/xlsx/odm-xml round-trips),tests/test_specializations_routes.py,tests/test_dashboard.py(ThreadPoolExecutor path with mocked client),
tests/test_cdisc_api_cache.py(fresh/stale/error states of
_cached()).services/)NCItApiClient: honorNCIT_API_BASE_URLfrom config instead of thehardcoded class constant.
CDISCApiClient(
CDISC_SUBSCRIPTION_KEY/Ocp-Apim-Subscription-Keypreferred,CDISC_API_KEY/api-keyfallback) for parity across projects.loinc_api.pymatching the shared_cached()helper; unify the bespoke_ncit_cacheonto the same helper.git rm results.txt(stale committed pytest log); remove emptysrc/dir.debug=Trueinapp.pybehind an env var (FLASK_DEBUG).requirements-dev.txt(pytest/black/flake8/pre-commit) from runtimerequirements.txt; add black/flake8 steps to CI; wire the configured isortinto pre-commit.
.flake8: dropF401,F841from the ignore list (fix fallout),reduce max-line from 999 to something enforceable (match black's configured
line length).
Workstream C — MCP server (new)
Mirror the soa-workbench pattern (raw MCP SDK,
_TOOLSlist,_dispatchdict,stdio transport, tests call
_dispatchdirectly). Handlers run insidecreate_app().app_context()and use the ORM so audit behavior stays consistent.Exact package layout/entry point per Workstream D design notes.
Milestone 1 — read-only tools (7):
list_bcs(q/status/pagination),get_bc(BC + DECs + specializations +governance history via
to_dict()),search_ncit,get_ncit_concept,search_loinc,search_cdisc_library+get_library_bc,list_review_queue(governance board + pending
IngestionRecords). Reuseservices/{ncit_api,loinc_api,cdisc_api}.pydirectly.Milestone 2 — write tools (6), after milestone 1 is validated against the
shared SQLite file:
create_bc,update_bc,map_ncit_to_bc,submit_bc_for_review,advance_governance,reject_bc. Prerequisite: extractthe write+audit logic from
routes/bc.py/routes/governance.py/routes/ncit.pyinto shared service functions (building on the
log_changehelper from B.2) soroutes and MCP share one code path.
Register in a new
.mcp.json; addmcp>=1.0.0to requirements;tests/test_mcp_server.pywith direct_dispatchcalls, test-config app context,mocked external APIs (matching existing conftest patterns).
Workstream D — Design notes (verified against both repos)
D.1 Alembic baseline — squash to a clean initial migration
Verified facts: the live DB (
instance/cdisc_curation.db) is stamped at headc2d4e6f8a0b1and its schema exactly matches current models.flask db upgradeis broken on fresh DBs in two independent ways: (1) the Flask-Migrate app factory
runs
db.create_all()insidecreate_app(), so the first revision fails with"duplicate column"; (2) no revision creates the base tables, and the
code→loinc_coderename revision assumes a historical schema unreproduciblefrom the current codebase. Tests are unaffected by removing
create_all—tests/conftest.py'sclean_dbfixture owns table creation.Approach (do this BEFORE the MCP server — it makes
create_app()side-effectfree):
db.create_all()block fromcreate_app()inapp.py; the__main__block calls a new bootstrap helper beforeapp.run(...).db_bootstrap.py(repo root) withensure_db(app):create_allDB (tables exist, noalembic_version) →stamp()tonew head;
(schema already matches);
flask_migrate.upgrade()(no-op when current; builds freshDBs). Unrecognized intermediate states raise with a clear message
(manual fallback:
flask db stamp head, documented in README).migrations/versions/.DATABASE_URL=sqlite:////tmp/baseline_gen.db FLASK_APP=app.py flask db migrate -m "baseline: initial schema"— review that it creates all sixtables and nothing else. The
code/loinc_codechurn ceases to exist..schemaof acreate_all()DB vs aflask db upgradeDB before committing (constraint-naming differences are possible).
D.2 MCP server —
python -m mcp_server, app-context handlersPackaging: do NOT add
[project]metadata (flat top-level modules makesetuptools discovery churn for zero benefit). New top-level package
mcp_server/(notmcp— would shadow the SDK):__init__.py,server.py(mirrors soa-workbench:_TOOLS,_dispatch, sync handlers viarun_in_executor, stdiomain()),__main__.py. Addmcp>=1.0.0torequirements. New
.mcp.jsonat repo root launching.venv/bin/python -m mcp_serverwithCDISC_API_KEYin env.App/DB sharing: lazy
_get_app()singleton importingcreate_app()(safe onceD.1 lands); each handler pushes
with _get_app().app_context():inside thehandler (handlers run on executor threads) — implement as a decorator so it
can't be missed.
instance_pathresolves identically in both processes, soFlask and MCP share the same SQLite file with no extra config;
DATABASE_URLstays the override. WAL: leave journal mode alone for read-only milestone 1;
at milestone 2 add a SQLAlchemy
connectlistener inextensions.pyissuingPRAGMA journal_mode=WAL; PRAGMA busy_timeout=15000(required — two writerprocesses otherwise hit "database is locked").
Milestone 1 tools (8 read-only):
list_bcs,get_bc,search_ncit,get_ncit_concept,search_loinc,search_cdisc_library,get_library_bc,list_review_queue. All three API clients verified context-safe.Milestone 2 service extraction:
services/bc_service.py(create_bc,update_bc,save_decs,map_ncit_to_bc,submit_bc_for_review) andservices/governance_service.py(advance_governance,reject_bc) — routesbecome thin form→service adapters; MCP handlers call the same functions with
actordefaulting to"mcp"so audit rows distinguish agent writes.Intentional behavior fix to call out in the PR:
routes/ncit.py:resolvecurrently writes NO AuditLog — the extracted
map_ncit_to_bcadds one.Tests:
tests/test_mcp_server.pycalls_dispatchdirectly (no transport),injects the existing in-memory-SQLite test app via
mcp_server.server._app,mocks the API clients with
monkeypatchper existing conventions, and assertsAuditLog/GovernanceRecord rows after every milestone-2 write.
Suggested ordering
A (config/docs, unblocks agents) → B.4 (tests first, TDD) → B.1–B.2 (logging,
audit helper) → B.3 = D.1 (schema fix; prerequisite for MCP) → B.5–B.6 →
C milestone 1 → C milestone 2 (service extraction + WAL).
Verification
pytest --tb=shortgreen after every step;pre-commit run --all-filesbeforeeach commit.
flask db upgradebuilds a correct fresh DB (comparesqlite_masteragainst acreate_all()DB); verify an existing DB copy isstamped and upgrades cleanly. Never test against
instance/cdisc_curation.dbitself — use copies.
run-concept-curationskill after B and C changes.tests/test_mcp_server.pyfor every tool;python -m mcp_serverstarts and waits on stdio;
claude mcp listpicks up.mcp.json; then a livesession exercising
list_bcs/get_bc.DATABASE_URL=sqlite:////tmp/fresh.db FLASK_APP=app.py flask db upgradebuilds all six tables;flask db checkreports no drift;existing-DB copy restamps to the new head via
ensure_db.