Skip to content

feat: make the external AI tool path work end to end, and ship the CLI - #269

Merged
kcrmin merged 7 commits into
developfrom
feature/479-basic-edits-controller
Aug 9, 2026
Merged

feat: make the external AI tool path work end to end, and ship the CLI#269
kcrmin merged 7 commits into
developfrom
feature/479-basic-edits-controller

Conversation

@kcrmin

@kcrmin kcrmin commented Aug 9, 2026

Copy link
Copy Markdown
Member

Root issue: Idea2Strategy/Idea2Strategy#479

무엇이 문제였나

AWS Development 에서 CLI 를 실제로 돌려 확인했다. 인증과 전략 생성은 되고, 외부 AI 가 전략
내용을 만드는 단계에서 404
였다.

명령 이 PR 전 이 PR 후
login 200 200
strategy create 201 201
delegation create 404 201
strategy edit preview 404 200 / 422 / 403

컨트롤러만 없던 것이 아니다. DelegatedAuthorizationCommandPort 는 테스트 fake 만,
DelegatedCredentialMaterialPort 는 구현이 전무했다. 저장소의 위임 코드는 전부 읽기(편집 시
권한 확인)와 만료 청소 뿐이었고, 그 읽기가 찾는 행을 만드는 코드가 없었다.

왜 아무도 몰랐나

CLI 테스트 두 클래스가 스텁 HTTP 서버를 띄운다. 스텁은 실재하지 않는 경로에 대해 자기 자신과만
합의한다. 편집 서비스는 fake 상대로, 발급은 존재하지 않았고, 각 조각은 green 인데 제품은 안 됐다.
A15·B18A·B18B 가 완료로 표시된 채 남은 이유다. §3.4 계약 fixture 드리프트와 같은 실패 방식이다.

이 PR 이 하는 일

편집 경로basic-edits/{preview,apply} 컨트롤러. 검토된 expectedEditSequence 가 왕복한다:
컨트롤러가 현재 값을 대신 읽으면 preview 와 apply 사이에 들어온 소유자 편집이 아무도 그 상태에
대해 검토하지 않은 diff 로 덮이고, 불변규칙 9.10 의 낙관적 잠금이 무력해진다.

발급 경로 — 5개 테이블에 걸친 원자적 발급·교체·회수. 이벤트 행의
(authorization_id, idempotency_key) 가 멱등 수령증이고 결정보다 먼저 재실행을 감지한다.
반대 순서는 호출자가 받지 못할 credential 을 발급한다. 원시 credential 은 발급 시 1회만 반환하고
다이제스트만 저장한다.

세 입력은 요청에서 받지 않고 읽는다. auth epoch(클라이언트가 지정하면 비밀번호 변경보다 오래
산다), 고지 문서(고를 수 있으면 다른 것에 동의한 척할 수 있다), 대상 전략의 소유자·access
epoch(발급 시점 고정이라 소유자 변경 후 위임이 자동으로 끊긴다).

고지 문안identity.policy_documents 는 비어 있었고 INSERT 하는 마이그레이션이 한 곳도
없었다. disclosure_policy_document_id 가 NOT NULL·FK 이므로 어떤 환경에서도 위임이 발급될 수
없었다.
빠진 것은 코드가 아니라 고객이 읽을 텍스트였다. 제품 권한자 kcrmin 승인
(2026-08-09, "이 문안으로 갈께"). 만료 기본값 24시간도 같은 지시다.

배포cli-v* 태그가 distZip 과 sha256 을 릴리스에 올린다. 러너의 gh 를 쓰므로 핀할
서드파티 액션이 늘지 않는다. CI 가 매 변경마다 아카이브를 빌드해 배포 경로가 썩지 않게 한다.

리뷰에서 잡힌 것

  • is_required = true 가 전 사용자 로그인을 막았다. 이 컬럼은 RequiredPolicySet
    결정한다 — 모든 계정이 인증을 마치려면 동의해야 하는 집합이다. 위임을 쓰지 않을 사용자에게까지
    동의를 요구했고 AccountOperationsFullJourneyIntegrationTest 가 그 이유로 깨졌다. CI 가 잡았다.
  • data.diff 가 diff 가 아니었다. 문서 전체가 들어가 있었다. 계약은 AI 에게 "diff 를 검토한 뒤
    적용하라"고 지시하므로, 문서를 훑고 검토했다고 판단하게 된다 — 미리보기 게이트가 막으려던 바로
    그 일이다. 실제 변경 목록으로 바꾸고 테스트로 고정했다.
  • 예외 advice 를 넓히려다 가드에 걸렸다. 이 저장소는 컨트롤러마다 advice 를 두고 목록을
    고정한다. 전용 advice 로 분리했고, 결과적으로 맞다 — 위임 응답의 code 는 외부 도구에게만 한
    약속이고 소유자용 엔드포인트가 질 이유가 없다.

검증

CI green. 새 커버리지 셋:

  • DelegatedBasicEditRouteRegistrationTest — CLI 가 부르는 경로 문자열을 고정한다
  • DelegatedAuthorizationGrantPersistenceIntegrationTest발급 어댑터로 만든 위임을 실제 권한
    검사에 통과시킨다.
    이 접합부에 커버리지가 없던 것이 문제의 뿌리다. 검사는 5개 테이블 10개
    컬럼을 읽고, 하나라도 다르게 쓰이면 모든 편집이 이유 없이 거부된다. 발급된 행만 따로 검증해서는
    잡히지 않는다. 만료 경과·대상 외 전략·회수 직후·재실행 멱등·타인 전략 거절 포함
  • ExternalToolDelegatedEditJourneyIntegrationTest — 가입부터 회수까지 HTTP 로 완주

남는 제품 결정 — AI 는 전략을 처음부터 만들 수 없다

새 전략은 {"groups":[],"mode":"BASIC"} 이고 위임 연산 4종에 그룹(컨테이너) 생성이 없다.
ADD_BLOCK 은 존재하는 그룹에만 들어간다. 즉 지금의 "AI 에게 전략 생성을 요청" 은 사용자가
매수·매도 컨테이너 골격을 먼저 만들고 AI 가 그 안을 채우는 것
을 뜻한다. 빈 전략을 주면 AI 는
아무것도 못 한다.

연산 집합을 넓힐지는 제품 결정이므로 ADD_GROUP 을 지어내지 않았다. 이것이 정해지면 실제 블록
적용까지 가는 저니 테스트도 함께 닫힌다.

🤖 Generated with Claude Code

kcrmin and others added 7 commits August 9, 2026 23:12
The CLI has always posted to /api/v1/strategies/{id}/basic-edits/{preview,apply}
and no such route existed. DelegatedBasicStrategyEditService and its jOOQ adapter
were complete but nothing outside their own package referenced them, so an
external AI tool could authenticate, create a strategy, and then fail with 404 at
the only step that writes blocks.

Both CLI test classes drive a stub HTTP server, which is why this survived: a stub
agrees with itself about a path that is not served. DelegatedBasicEditRouteRegistrationTest
pins the paths against a rename or a removed controller.

The reviewed edit sequence now makes the round trip. The service requires
expectedEditSequence and the CLI never sent one; having the controller re-read it
would have let an owner edit landing between preview and apply be overwritten by a
diff nobody reviewed against it, defeating the optimistic lock in rule 9.10. The
preview reports the sequence it read, apply must return it, and a request carrying
a preview hash without a sequence is refused.

Scope denial and preview mismatch became subtypes of the existing rejection so the
external tool contract can keep its promise of distinct exit codes (4 and 5) with
machine-readable codes; every existing catch and test is unchanged. Their advice is
a separate RestControllerAdvice because those codes are a promise to delegated
tools only, and widening the authoring advice would extend it to owner-facing
endpoints by accident.

Refs #479

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI has never been built or published by any workflow, so using it meant
cloning the superproject with submodules and running Gradle. That is not an
install, and it is why the tool has no users outside this repository.

The Gradle application plugin already produces the archive, so the release job
adds no new build. Upload uses the runner's own gh instead of a release action:
one fewer third-party action to pin and to trust with a write-scoped token. The
tool contract is parsed in the job because it is the first thing an external AI
reads, and a release whose contract does not parse is worse than no release.

CI now builds the archive on every change. A distribution can break without any
test failing, and the release path should not rot between the tags that publish it.

operator bootstrap stays in this CLI. Splitting it would drop the Postgres driver
and the backend classes from the archive, but docs/operator-auth-enablement-handoff.md
documents `idea2strategy operator bootstrap` as a live one-shot procedure and INT05
is still open on it. Four megabytes is not worth breaking a runbook in use.

Refs #479

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing edit access

identity.delegated_authorizations.disclosure_policy_document_id is NOT NULL with a
foreign key to identity.policy_documents, that table had no rows, and no migration
anywhere wrote one. No delegation could be granted, on any environment, ever. The
missing piece was never code: it was the text a customer reads before handing an
external tool the ability to change their strategy.

The text states what the delegation permits, what it can never do (orders, funds,
bot runs, release, other strategies, arbitrary code, external data), that every
change arrives as a reviewed preview first, and that it expires and can be revoked.

content_hash is computed from the stored text instead of pasted beside it, so the
two cannot drift apart here or in review. The insert is idempotent on
(policy_code, version, language_code), which is the table's own unique key.

Approved by product authority kcrmin on 2026-08-09: "이 문안으로 갈께".

Refs #479

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e diff

The tool contract tells an external AI to inspect data.diff before applying, and
the preview response was putting the entire proposed semantic document there. A
tool following the contract literally would skim a full document and call that a
review, which is the one thing the preview gate exists to prevent. The reviewable
thing is the change list the service already produces (ADD_BLOCK group/block CODE).

The proposed document stays in the response under its own name, so nothing is lost
for a caller that genuinely wants it.

Refs #479

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught this and it was a real design error, not a test detail. is_required
drives RequiredPolicySet, which every account must satisfy to finish
authenticating, so publishing the disclosure as required would have asked every
customer to consent to a delegation notice before logging in — including the
ones who never delegate anything. AccountOperationsFullJourneyIntegrationTest
failed for exactly that reason.

The disclosure belongs at the moment a delegation is granted, which is where the
delegation flow will read it. Registering the file in the two pinned migration
lists is the mechanical part of the same change.

Refs #479

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The write path for A15 did not exist. DelegatedAuthorizationCommandPort had only a
test fake and DelegatedCredentialMaterialPort had no implementation at all, so the
only delegation code in the repository read authorizations and swept expired ones.
Nothing could create the row those readers look for.

The event row is the idempotency receipt: (authorization_id, idempotency_key) is
unique, and the replay is detected before the decision runs. The other order mints
a credential for a caller who will never receive it.

Three inputs are read rather than accepted from the request. The auth epoch is what
makes a delegation die when the account re-authenticates, so a client that could
name it could outlive a password change. The disclosure document is the text the
customer was actually shown, and a request that chose it could claim consent to
something else. The target owner and access epoch are pinned from the strategy row,
so a later owner change or access-epoch bump stops honouring the delegation instead
of carrying it across the change.

A delegation with no target is refused at both ends. The authorization check
requires a pinned target, so granting one would return success and then deny every
edit, surfacing later as an unexplained scope denial.

Expiry defaults to 24 hours (delegation.default-lifetime) because the disclosure
promises the customer an expiry; expiry_mode is AT_TIME whenever an instant is
carried, which is the only mode the authorization check enforces against the clock.

The integration test grants with the production adapter and then edits with the
production check. That join had no coverage: the check reads ten columns across
five tables, and any one written differently makes every edit deny with nothing to
say which. Asserting the grant's own rows would not have caught it.

Refs #479

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up, verify, log in, create a strategy, grant a delegation for it, reach the
edit service under that delegation, and lose access the moment it is revoked. The
refusal codes are the assertions: EDIT_REJECTED means the delegation was accepted
and the request reached the edit service on its merits, SCOPE_DENIED after revoking
means authorization is what stopped it, and before this branch the same call
answered 404 because the route did not exist.

The test stops short of applying blocks. A new strategy is {"groups":[],"mode":
"BASIC"} and none of the four delegated operations creates a group, so an external
tool can only fill a skeleton the owner already made. Covering a real apply needs a
valid Basic assembly with a catalog and instruments, which belongs in a
strategy-authoring fixture rather than in this journey.

Refs #479

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kcrmin kcrmin changed the title feat: serve the delegated Basic edit route the external tool CLI calls feat: make the external AI tool path work end to end, and ship the CLI Aug 9, 2026
@kcrmin
kcrmin merged commit 3c4995f into develop Aug 9, 2026
2 checks passed
@kcrmin
kcrmin deleted the feature/479-basic-edits-controller branch August 9, 2026 15:48
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.

1 participant