From 20f2ffc96d6828419b3aa2a8bea7b4e4bacdc4af Mon Sep 17 00:00:00 2001 From: Ryan Min Date: Mon, 10 Aug 2026 06:08:32 +0900 Subject: [PATCH] feat: let a delegated tool start from an empty strategy and find what to put in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps kept "make me an Apple RSI strategy" from working, and the first two were invisible until the CLI was run against the deployed API. A new strategy is {"groups":[],"mode":"BASIC"} with no catalogId, and every proposed document must parse as an official assembly, which requires one. ADD_GROUP shipped without that, so creating a container still produced a document that could not be read: AWS answered 422 on the first edit of every new strategy. The first edit now stamps the catalog it is already being validated against. Nothing is invented; the value was implied by the request and simply had nowhere to be written. The unit fixtures all carried a catalogId, and the HTTP journey read its 422 as "there is no group yet" — the right code for the wrong reason, so nothing caught it. Both now start from the document a new strategy actually has. `catalog elements` and `catalog instruments --symbol` close the other gap. A person says "Apple" and "RSI"; a container names instruments by id and blocks by published element code. Without a lookup a tool has to guess, and a guessed code is refused — so the tool cannot complete the request it was given. Both routes already existed; only the CLI could not reach them. The tool contract now names the discovery step and the create/delegate steps ahead of the edit workflow, because an external tool reads that file to learn the sequence. Refs #479 Co-Authored-By: Claude Opus 5 --- ...olDelegatedEditJourneyIntegrationTest.java | 25 ++++---- apps/idea2strategy-cli/README.md | 2 + .../idea2strategy/cli/Idea2StrategyCli.java | 44 ++++++++++++++ .../idea2strategy-ai-tool-contract.json | 31 ++++++++++ .../DelegatedBasicStrategyEditService.java | 8 +++ ...DelegatedBasicStrategyEditServiceTest.java | 60 ++++++++++++++++++- 6 files changed, 158 insertions(+), 12 deletions(-) diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java index 95573db3..1e8db968 100644 --- a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java +++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java @@ -122,25 +122,28 @@ void anExternalToolDelegatesThenPreviewsAndAppliesABasicEdit() throws Exception String editBody = """ {"authorizationId":"%s","credentialId":"%s","operations":[ - {"action":"ADD_BLOCK","arguments":{"groupId":"buy","blockId":"b1", - "elementCode":"PRICE_CHANGE_PERCENT"}}]} + {"action":"ADD_GROUP","arguments":{"groupId":"buy","container":"BUY", + "evaluationMode":"INDEPENDENT","allocationMode":"EQUAL", + "instrumentIds":["11111111-1111-4111-8111-111111111111"]}}]} """.formatted( grant.path("authorizationId").asText(), grant.path("credentialId").asText()); - // A freshly created strategy is {"groups":[],"mode":"BASIC"} and the four delegated - // operations cannot create a group, so this edit is refused on its merits — which is the - // assertion that matters here. EDIT_REJECTED means the delegation was accepted and the - // request reached the edit service; a delegation that did not authorize would answer 403 - // SCOPE_DENIED, and a missing route would answer 404, which is what it did before this - // change. Applying real blocks needs a valid Basic skeleton and is covered separately. - JsonNode refusal = json.readTree(mvc.perform( + // The strategy is untouched — {"groups":[],"mode":"BASIC"} — and the tool builds its + // container anyway. Before this work the same call answered 404 because the route did not + // exist, and after ADD_GROUP shipped it answered 422 because a new document carries no + // catalogId for the proposed assembly to parse against. + JsonNode preview = json.readTree(mvc.perform( post("/api/v1/strategies/" + strategyId + "/basic-edits/preview") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .content(editBody)) - .andExpect(status().isUnprocessableEntity()) + .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString()); - assertThat(refusal.path("code").asText()).isEqualTo("EDIT_REJECTED"); + assertThat(preview.path("diff").isArray()).isTrue(); + assertThat(preview.path("diff").get(0).asText()).isEqualTo("ADD_GROUP buy BUY"); + assertThat(preview.path("previewHash").asText()).isNotBlank(); + assertThat(preview.path("expectedEditSequence").isNumber()).isTrue(); + assertThat(preview.path("proposedSemanticDocument").path("catalogId").asText()).isNotBlank(); // Revoking takes effect at once: the same call now fails authorization instead of merits. mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders diff --git a/apps/idea2strategy-cli/README.md b/apps/idea2strategy-cli/README.md index 722761ac..40958842 100644 --- a/apps/idea2strategy-cli/README.md +++ b/apps/idea2strategy-cli/README.md @@ -34,6 +34,8 @@ Supported commands: ```text tool-contract +catalog elements +catalog instruments [--symbol TICKER[,TICKER...]] delegation create --name NAME --scopes STRATEGY_EDIT,STRATEGY_VALIDATE --strategy-id ID[,ID...] [--expires-at ISO_8601_INSTANT] delegation revoke --authorization-id ID diff --git a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java index 3e50096e..0ab0153d 100644 --- a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java +++ b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java @@ -26,6 +26,8 @@ public final class Idea2StrategyCli { private static final Set ALLOWED_EDIT_OPERATIONS = Set.of("ADD_GROUP", "ADD_BLOCK", "REMOVE_BLOCK", "CONNECT_BLOCKS", "SET_VALUE"); private static final Set AUTHENTICATED_COMMANDS = Set.of( + "catalog.elements", + "catalog.instruments", "delegation.create", "delegation.revoke", "strategy.list", @@ -88,6 +90,8 @@ private static JsonNode execute(Invocation invocation, InputStream stdin, Map catalogElements(arguments, api, token); + case "catalog.instruments" -> catalogInstruments(arguments, api, token); case "delegation.create" -> delegationCreate(arguments, api, token); case "delegation.revoke" -> delegationRevoke(arguments, api, token); case "strategy.list" -> strategyList(arguments, api, token); @@ -139,6 +143,45 @@ private static JsonNode login(Arguments args, ApiClient api, CredentialStore cre return result; } + /** + * The catalog an edit is validated against. + * + *

Without this an external tool cannot turn "use RSI" into an operation: element codes and + * their declared parameters live in the published catalog, and guessing a code produces an + * edit the server refuses. Reading beats guessing. + */ + private static JsonNode catalogElements(Arguments args, ApiClient api, String token) { + args.rejectUnknown(); + return api.get("/api/v1/strategy-catalogs/basic", token); + } + + /** + * Symbol to instrument id. + * + *

A container names the instruments it trades by id, and a person asks for "Apple". Without + * a lookup the tool has no way to cross that gap. + */ + private static JsonNode catalogInstruments(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--symbol"); + JsonNode instruments = api.get("/api/v1/strategy-catalogs/basic/instruments", token); + String symbol = args.optional("--symbol"); + if (symbol == null || symbol.isBlank()) { + return instruments; + } + ArrayNode matches = JSON.createArrayNode(); + for (String requested : symbol.split(",")) { + String wanted = requested.trim(); + for (JsonNode instrument : instruments.path("instruments")) { + if (instrument.path("symbol").asText().equalsIgnoreCase(wanted)) { + matches.add(instrument); + } + } + } + ObjectNode filtered = JSON.createObjectNode(); + filtered.set("instruments", matches); + return filtered; + } + private static JsonNode delegationCreate(Arguments args, ApiClient api, String token) { args.rejectUnknown("--name", "--scopes", "--strategy-id", "--expires-at"); ArrayNode scopes = JSON.createArrayNode(); @@ -359,6 +402,7 @@ static Invocation parse(String[] raw, Map environment) { List words = values.stream().takeWhile(value -> !value.startsWith("--")).toList(); int commandWordCount = switch (words.getFirst()) { case "login" -> 1; + case "catalog" -> 2; case "delegation" -> 2; case "strategy" -> words.size() >= 2 && "edit".equals(words.get(1)) ? 3 : 2; case "operator" -> 2; diff --git a/apps/idea2strategy-cli/src/main/resources/idea2strategy-ai-tool-contract.json b/apps/idea2strategy-cli/src/main/resources/idea2strategy-ai-tool-contract.json index 504123e9..ee4fd1de 100644 --- a/apps/idea2strategy-cli/src/main/resources/idea2strategy-ai-tool-contract.json +++ b/apps/idea2strategy-cli/src/main/resources/idea2strategy-ai-tool-contract.json @@ -3,7 +3,38 @@ "tool": "idea2strategy", "outputMode": "JSON", "contractCommand": ["tool-contract"], + "discovery": [ + { + "name": "resolve-instruments", + "purpose": "Turn a company or ticker a person named into the instrument ids a container needs.", + "command": ["catalog", "instruments", "--symbol", ""], + "requiredOutputFields": ["data.instruments"] + }, + { + "name": "read-element-catalog", + "purpose": "Read the official element codes and their declared parameters. Never guess a code; an unpublished code is refused.", + "command": ["catalog", "elements"], + "requiredOutputFields": ["data.elements"] + } + ], "workflow": [ + { + "name": "create-strategy", + "purpose": "A new strategy starts empty; the container and blocks are built by the operations below.", + "command": ["strategy", "create", "--name", ""], + "requiredOutputFields": ["data.id"] + }, + { + "name": "delegate-editing", + "purpose": "Grant this tool scoped, expiring permission to edit that strategy. The credential is returned once.", + "command": [ + "delegation", "create", + "--name", "", + "--scopes", "STRATEGY_EDIT", + "--strategy-id", "" + ], + "requiredOutputFields": ["data.authorizationId", "data.credentialId"] + }, { "name": "preview-basic-edit", "command": [ diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java index 431fa990..ea4abc23 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java @@ -122,6 +122,14 @@ private DelegatedBasicEditPreview prepare( throw new StrategyDraftConflictException(); } ObjectNode root = parseRoot(current.semanticDocument()); + // A strategy starts as {"groups":[],"mode":"BASIC"} with no catalogId, and every proposed + // document has to parse as an official assembly, which requires one. Without this a + // delegated tool could create a container and still never produce a readable document — + // the first edit would fail on a field no delegated operation can set. The value is not + // invented: it is the catalog this very edit is being validated against. + if (!root.hasNonNull("catalogId") || root.path("catalogId").asText().isBlank()) { + root.put("catalogId", catalog.version().id().toString()); + } Map definitions = catalog.elements().stream() .collect(Collectors.toMap(StrategyElementDefinition::elementCode, Function.identity())); var changes = new ArrayList(); diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditServiceTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditServiceTest.java index abac9f2b..c7534a49 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditServiceTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditServiceTest.java @@ -120,6 +120,48 @@ void refusesApplyWhenTheReviewedPreviewHashDoesNotMatch() { assertThat(commandPort.saved).isNull(); } + /** + * The whole point of delegated container creation: hand a tool an untouched strategy and it + * builds one. This failed on AWS after ADD_GROUP shipped, because a new document carries no + * catalogId and every proposed document must parse as an official assembly. The unit fixtures + * all had a catalogId already, so nothing here noticed. + */ + @Test + void buildsAWholeStrategyFromTheDocumentANewStrategyActuallyStartsWith() { + var commandPort = new RecordingCommandPort(); + var service = service(new RecordingAuthorizer(), commandPort, emptyDocument()); + var operations = List.of( + new DelegatedBasicEditOperation("ADD_GROUP", Map.of( + "groupId", "buy", + "container", "BUY", + "evaluationMode", "INDEPENDENT", + "allocationMode", "EQUAL", + "instrumentIds", List.of(INSTRUMENT_ID.toString()))), + new DelegatedBasicEditOperation("ADD_BLOCK", Map.of( + "groupId", "buy", "blockId", "trigger", "elementCode", "MARKET_OPEN")), + new DelegatedBasicEditOperation("ADD_BLOCK", Map.of( + "groupId", "buy", "blockId", "condition", "elementCode", "RSI", + "parameters", Map.of("period", 14))), + new DelegatedBasicEditOperation("ADD_BLOCK", Map.of( + "groupId", "buy", "blockId", "order", "elementCode", "BUY_ORDER")), + new DelegatedBasicEditOperation("CONNECT_BLOCKS", Map.of( + "groupId", "buy", "fromBlockId", "trigger", "outputPort", "signal", + "toBlockId", "condition", "inputPort", "input")), + new DelegatedBasicEditOperation("CONNECT_BLOCKS", Map.of( + "groupId", "buy", "fromBlockId", "condition", "outputPort", "result", + "toBlockId", "order", "inputPort", "input"))); + + var preview = service.preview(editor(), STRATEGY_ID, 7, catalog(), operations); + + assertThat(preview.proposedSemanticDocument()).contains("\"catalogId\":\"" + CATALOG_ID + "\""); + assertThat(preview.valid()).isTrue(); + + var applied = service.apply(editor(), STRATEGY_ID, 7, catalog(), operations, preview.previewHash()); + + assertThat(applied.semanticHash()).isEqualTo(preview.previewHash()); + assertThat(commandPort.saved).isEqualTo(applied); + } + @Test void createsATradeContainerSoADelegatedToolCanStartFromNothing() { var service = service(new RecordingAuthorizer(), new RecordingCommandPort()); @@ -197,8 +239,14 @@ void validatesTheCurrentDraftOnlyWithTheDedicatedDelegatedScope() { private static DelegatedBasicStrategyEditService service( DelegatedStrategyAuthorizationPort authorizer, DelegatedBasicEditCommandPort commandPort) { + return service(authorizer, commandPort, document()); + } + + private static DelegatedBasicStrategyEditService service( + DelegatedStrategyAuthorizationPort authorizer, + DelegatedBasicEditCommandPort commandPort, + StrategyDocument document) { Strategy strategy = Strategy.createBasic(STRATEGY_ID, ACCOUNT_ID, "Momentum", null, NOW.minusSeconds(60)); - StrategyDocument document = document(); StrategyQueryPort strategies = (id, owner) -> Optional.of(strategy) .filter(value -> id.equals(STRATEGY_ID) && owner.equals(ACCOUNT_ID)); StrategyDocumentQueryPort documents = (id, owner) -> Optional.of(document) @@ -211,6 +259,16 @@ private static DelegatedStrategyEditor editor() { return new DelegatedStrategyEditor(ACCOUNT_ID, AUTHORIZATION_ID, CREDENTIAL_ID); } + /** Exactly what BasicStrategyDraftCommandService writes for a newly created strategy. */ + private static StrategyDocument emptyDocument() { + String semantic = StrategyDocumentJson.canonicalize("{\"groups\":[],\"mode\":\"BASIC\"}"); + String presentation = "{\"positions\":{}}"; + return new StrategyDocument( + STRATEGY_ID, semantic, presentation, "basic-semantic/v1", "basic-presentation/v1", + StrategyDocumentJson.sha256(semantic), StrategyDocumentJson.sha256(presentation), 7, + NOW.minusSeconds(60), NOW.minusSeconds(1)); + } + private static StrategyDocument document() { String semantic = StrategyDocumentJson.canonicalize("{\"catalogId\":\"" + CATALOG_ID + "\",\"groups\":[{" + "\"id\":\"buy\",\"container\":\"BUY\",\"evaluationMode\":\"INDEPENDENT\","