From dd5150353e0dba95829c21d50a508f65244d4658 Mon Sep 17 00:00:00 2001 From: David Castro <117443013+Dcastroro@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:47:48 +0200 Subject: [PATCH] fix(api): prevent repeated idea adoption from duplicating nodes Reject another adoption while the idea has nodes in the graph, using the existing project lock. Keep original request receipt replay and allow adoption again after undo removes the path. Document the conflict and cover real isolated API transactions, including concurrent requests without a shared receipt. Fixes #113 Assisted-by: OpenAI Codex --- docs/API_REFERENCE.md | 4 ++-- packages/contracts/openapi.json | 4 ++-- services/api/contracts.py | 2 +- services/api/resources.py | 2 ++ tests/test_idea_adoption.py | 38 +++++++++++++++++++++++++++++++++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 59f62468..687dd90e 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -1556,7 +1556,7 @@ Known domain failures: `400` Scheduling/input errors; `404` NOT_FOUND. #### `POST /api/ideas/{ident}/adopt` -Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; same idea retry returns current graph. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo. +Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; replaying the same request_id for the same idea returns the current graph. An idea that already has nodes in the graph rejects a second adoption without that receipt with IDEA_ALREADY_ADOPTED (409). Undoing the adoption permits a new path. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo. | Parameter | Location | Type | Required | Default / description | | --- | --- | --- | --- | --- | @@ -1566,7 +1566,7 @@ Body: `application/json`: `IdeaAdoptRequest`; optional. Success `200`: `application/json`: `GraphState`. -Known domain failures: `404` NOT_FOUND; `409` REVISION_CONFLICT, REQUEST_ID_CONFLICT or BRANCH_INACTIVE; `422` INVALID_REQUEST_ID, INVALID_EXPERIMENT_CONFIG, INVALID_INPUTS, CROSS_PROJECT, MISSING_BRANCH, INVALID_TITLE or INVALID_INSTRUCTIONS. +Known domain failures: `404` NOT_FOUND; `409` REVISION_CONFLICT, REQUEST_ID_CONFLICT, BRANCH_INACTIVE or IDEA_ALREADY_ADOPTED; `422` INVALID_REQUEST_ID, INVALID_EXPERIMENT_CONFIG, INVALID_INPUTS, CROSS_PROJECT, MISSING_BRANCH, INVALID_TITLE or INVALID_INSTRUCTIONS. #### `PATCH /api/projects/{ident}/objective` diff --git a/packages/contracts/openapi.json b/packages/contracts/openapi.json index f8c1fd9a..f2795bf7 100644 --- a/packages/contracts/openapi.json +++ b/packages/contracts/openapi.json @@ -13274,7 +13274,7 @@ }, "/api/ideas/{ident}/adopt": { "post": { - "description": "Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; same idea retry returns current graph. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo.", + "description": "Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; replaying the same request_id for the same idea returns the current graph. An idea that already has nodes in the graph rejects a second adoption without that receipt with IDEA_ALREADY_ADOPTED (409). Undoing the adoption permits a new path. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo.", "operationId": "adopt_api_ideas__ident__adopt_post", "parameters": [ { @@ -13345,7 +13345,7 @@ } } }, - "description": "REVISION_CONFLICT, REQUEST_ID_CONFLICT or BRANCH_INACTIVE" + "description": "REVISION_CONFLICT, REQUEST_ID_CONFLICT, BRANCH_INACTIVE or IDEA_ALREADY_ADOPTED" }, "422": { "content": { diff --git a/services/api/contracts.py b/services/api/contracts.py index 20a62377..21a04489 100644 --- a/services/api/contracts.py +++ b/services/api/contracts.py @@ -659,7 +659,7 @@ def operation(method: str, path: str, tag: str, summary: str, description: str, ("/api/analysis/run", "analysis", "Enqueue an analysis task over declared real inputs; output fields depend on the selected analysis type.")): operation("post", path, "Research", f"Enqueue {kind} work", description + " Return Run, not the final scientific output.", ref("Run"), body="QueuedTaskRequest", errors={400: "Scheduling/input errors", 404: "NOT_FOUND"}) operation("post", "/api/ideas/{ident}/adopt", "Research", "Adopt an idea into an editable execution path", - "Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; same idea retry returns current graph. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo.", ref("GraphState"), body="IdeaAdoptRequest", errors={404: "NOT_FOUND", 409: "REVISION_CONFLICT, REQUEST_ID_CONFLICT or BRANCH_INACTIVE", 422: "INVALID_REQUEST_ID, INVALID_EXPERIMENT_CONFIG, INVALID_INPUTS, CROSS_PROJECT, MISSING_BRANCH, INVALID_TITLE or INVALID_INSTRUCTIONS"}) + "Create hypothesis plus configured experiment, or hypothesis/implementation/experiment nodes when implementation is needed. Optional expected_revision is the project graph revision. request_id is 1..120 characters and adoption-specific; replaying the same request_id for the same idea returns the current graph. An idea that already has nodes in the graph rejects a second adoption without that receipt with IDEA_ALREADY_ADOPTED (409). Undoing the adoption permits a new path. inputs accepts up to 50 explicit path/reference inputs in this project. config is the executable experiment configuration, not a dummy case. Return the updated GraphState as one graph edit/undo.", ref("GraphState"), body="IdeaAdoptRequest", errors={404: "NOT_FOUND", 409: "REVISION_CONFLICT, REQUEST_ID_CONFLICT, BRANCH_INACTIVE or IDEA_ALREADY_ADOPTED", 422: "INVALID_REQUEST_ID, INVALID_EXPERIMENT_CONFIG, INVALID_INPUTS, CROSS_PROJECT, MISSING_BRANCH, INVALID_TITLE or INVALID_INSTRUCTIONS"}) operation("post", "/api/theory/check", "Research", "Evaluate a restricted symbolic expression", "Require project_id/expression; variable defaults x. kind differentiate/solve/numeric selects an operation; any other/omitted kind simplifies. values supplies numeric substitutions. Restricted arithmetic grammar permits sin/cos/exp/log/sqrt/abs, bounded constants/powers and no arbitrary eval. Save a checked Derivation RecordItem, with result/latex and an explicit supplied-expression scope.", ref("RecordItem"), body="SymbolicCheckRequest", errors={400: "INVALID_EXPRESSION", 404: "NOT_FOUND", 422: "EXPRESSION_FAILED"}) operation("post", "/api/experiments/{ident}/launch", "Experiments", "Enqueue a saved experiment specification", diff --git a/services/api/resources.py b/services/api/resources.py index 59b66b75..92eb9b74 100644 --- a/services/api/resources.py +++ b/services/api/resources.py @@ -125,6 +125,8 @@ def adopt(ident:str,body:dict=Body(default={})): return g if body.get('expected_revision',p.revision)!=p.revision: error('REVISION_CONFLICT','The project changed; refresh before adopting the idea',409) + if any(node.get('adopted_idea_id')==idea.id for node in g['nodes']): + error('IDEA_ALREADY_ADOPTED','This idea has already been adopted; use its existing execution path',409) original=deepcopy(g); commands=[] config=deepcopy(body.get('config',idea.data.get('experiment_config',{}))) if not isinstance(config,dict): error('INVALID_EXPERIMENT_CONFIG','Experiment config must be an object',422) diff --git a/tests/test_idea_adoption.py b/tests/test_idea_adoption.py index aa951043..38aed421 100644 --- a/tests/test_idea_adoption.py +++ b/tests/test_idea_adoption.py @@ -93,6 +93,44 @@ def concurrent(_): return adopted(client,i,request_id='one-adoption',expected_re assert s.scalar(select(func.count()).select_from(CommandReceipt).where(CommandReceipt.project_id==p['id']))==1 +def case_repeat_adoption_rejected_without_shared_receipt(client): + p=create(client); i=idea(client,p) + g=ok(adopted(client,i)) + for body in ({},{'request_id':'different-adoption','title':'Another title'}): + rejected=adopted(client,i,**body) + assert rejected.status_code==409,rejected.text + assert rejected.json()['detail']['code']=='IDEA_ALREADY_ADOPTED' + assert graph(client,p)==g + assert ok(client.get(f"/api/ideas/{i['id']}"))['status']=='adopted' + assert ok(client.get(f"/api/projects/{p['id']}/runs"))==[] + from sqlalchemy import select,func + from services.api.db import Session,CommandReceipt + with Session() as s: + assert s.scalar(select(func.count()).select_from(CommandReceipt).where(CommandReceipt.project_id==p['id']))==1 + + +def case_adoption_after_undo_can_create_a_new_path(client): + p=create(client); i=idea(client,p) + first=ok(adopted(client,i)) + undone=ok(command(client,p,'undo'))['graph'] + assert undone['nodes']==[] + second=ok(adopted(client,i,request_id='after-undo',expected_revision=undone['revision'])) + assert second['revision']==undone['revision']+1 and len(second['nodes'])==3 + assert {n['id'] for n in first['nodes']}.isdisjoint(n['id'] for n in second['nodes']) + assert ok(client.get(f"/api/projects/{p['id']}/runs"))==[] + + +def case_concurrent_adoptions_without_shared_receipt(client): + p=create(client); i=idea(client,p) + with ThreadPoolExecutor(max_workers=5) as pool: + results=list(pool.map(lambda _:adopted(client,i),range(5))) + assert sorted(r.status_code for r in results)==[200,409,409,409,409],[(r.status_code,r.text) for r in results] + assert all(r.json()['detail']['code']=='IDEA_ALREADY_ADOPTED' for r in results if r.status_code==409) + g=graph(client,p) + assert g['revision']==1 and len(g['nodes'])==3 and len(g['edges'])==2 + assert ok(client.get(f"/api/projects/{p['id']}/runs"))==[] + + def case_completed_producer_file_reused_but_stale_rejected(client): p=create(client); i=idea(client,p) g=ok(adopted(client,i,config={'kind':'command','command':['python','pilot.py']}))