Skip to content

교체 가능한 메모리 백엔드 (Pluggable Memory Backend)

Status: IMPLEMENTED, 그리고 한 대목은 이 문서가 예상한 것과 다르게 실현되었다. §12 의 Step 1~6 은 트리에 있다 — 다섯 티어 SPI · PeerMemory · StoreBackedPeerMemory · 능력 기반 도구 등록 · RedactingPeerMemory · CLI 이주 · 수집 이음매 · aimon-memory-testkit. §7.2 의 수집 델타는 (a) 로 확정되었고 §15 의 6·8번(teardown 이동 안전성, 수집 델타)은 해소되었다.

Step 7~9 은 이 저장소에서 하지 않았다. 원격 백엔드는 별도 저장소의 서비스 aimon-memory 로 실현되었고 — Postgres + pgvector, 자체 파생 워커, HTTP API — 그 저장소의 aimon-memory-clientat.aimon.core.memory.PeerMemoryRemotePeerMemory 로 구현한다. 즉 다섯 티어 SPI 라는 이 문서의 본체는 그대로 맞았고, 그 SPI 를 채우는 것이 이 빌드 안의 어댑터 모듈 둘(aimon-memory-honcho · -dyad)이 아니라 저장소 하나였다. §8.1 · §12 · §15 의 어댑터 관련 서술은 그에 맞게 정정되어 있다.

그 결과 §4.3 의 판단 하나가 뒤집혔다 — 저장소 백엔드 셋은 "그대로 살지" 않았다. aimon-memory-{mongodb,postgres} 는 제거되었고 aimon-memory-fileaimon-core 안으로 들어왔다. 근거와 대체 관계는 §4.3 에 적혀 있다.

현재 구현(at.aimon.core.memory — SPI · 기본 백엔드 · at.aimon.core.memory.file)의 설계 사양은 peer-memory.md 이며, 본 문서는 그 사양의 대부분을 유지하되 그 문서의 비목표 한 줄을 철회한다 — 아래 §0.1. 사용자 노출 표면은 메모리 사용 가이드 참조.


0. 결론 먼저

  1. 가능하다. 단, 지금의 확장점으로는 불가능하다. 현재 seam 은 ObservationStore / RepresentationStore / WorkspaceStore저장소 인터페이스인데, 바꿔치기 대상인 Honcho 와 Dyad 는 저장소를 노출하지 않고 서비스(context / recall / chat / messages)를 노출한다. 저장소 고도에서는 RepresentationStore.save, ObservationStore.merge, findByConfidenceBelow, purgeSoftDeletedBefore 처럼 원격 대응물이 아예 없는 메서드가 남는다(§1.3).

  2. 그러므로 이 설계의 본체는 서비스 고도의 SPI 를 새로 긋는 것이다. 다섯 개의 능력(capability) — SNAPSHOT · SEARCH · CHAT · OBSERVE · INGEST — 을 각각 별도 인터페이스로 두고, PeerMemory 파사드가 자기가 가진 티어만 내놓는다. 능력 집합은 백엔드가 선언하는 것이 아니라 티어 접근자에서 계산되며 그것을 담는 메서드가 인터페이스에 아예 없으므로, "CHAT 을 지원한다고 말해 놓고 빈 응답을 주는" 상태는 표현할 자리가 없다(§3.2). 이 불변식이 잡지 못하는 것이 둘 있고 문서가 둘 다 따로 처분한다 — 티어 의 손실(원격 스냅샷에 개별 관찰이 없는 것, 점수를 못 내는 것)은 observationsAvailable 류의 명시 신호로(§3.3), 재료는 있는데 그 연산을 못 하는 스토어는 §4.1 의 계약과 §8.1 의 testkit 으로 막는다(§3.2). 다섯 중 둘은 이미 있다MemoryContextProviderDialecticEngine 은 이미 서비스 고도다. 즉 고도 문제는 국소적이며, 틀어진 자리는 검색·관찰·수집 세 곳뿐이다.

  3. 기존 저장소 SPI 는 살아남고, 백엔드 모듈 셋도 그대로 산다. 격하는 패키지 이동이 아니라 참조 방향으로 표현한다 — 새 SPI 의 어떤 시그니처도 ObservationStore / RepresentationStore 를 언급하지 않는다. 그 셋은 StoreBackedPeerMemory(기본 백엔드)의 내부 재료가 된다(§4). 패키지를 at.aimon.core.memory.impl 로 옮기면 aimon-memory-* 세 모듈과 CLI 가 전부 컴파일 불가가 되므로 하지 않는다 — 근거는 §4.2.

가장 큰 미해결 항목은 SPI 가 아니라 수집 경로다. 오늘 AIMON 에는 대화를 메모리로 흘려보내는 이음매가 사실상 없다 — CLI 는 REPL 프로세스가 종료될 때 딱 한 번 전사 전체를 큐에 넣고, 스타터는 memory-write-path degradation 을 올린 채 아무것도 넣지 않는다. Honcho·Dyad 는 둘 다 "메시지를 계속 먹여야 결론이 쌓이는" 모델이므로, 이 이음매 없이는 어댑터를 붙여도 영원히 빈 메모리를 읽는다(§7).

두 번째 미해결 항목은 CLI 다. 오늘 다섯 티어를 전부 배선해서 도는 유일한 조립 경로인데, 그 경로는 MemorySpecMemoryAssembly 도 지나지 않는다 — 이 설계의 교체 기계가 CLI 에 닿지 않는다는 뜻이다. 그래서 CLI 이주가 설정 표면 작업의 곁다리가 아니라 독립 단계(§12 Step 4)로 서 있다. 사실 확인은 §5.0.

0.1 peer-memory.md 의 비목표 한 줄을 철회한다

IMPORTANT: 이 설계는 기존 정본과 조용히 모순되지 않기 위해 그 모순을 여기서 먼저 적는다. peer-memory.md §1 은 두 줄의 비목표를 선언한다.

peer-memory.md §1 의 비목표 이 문서의 처분
"외부 메모리 서버를 호출하는 원격 클라이언트 통합 (MCP 경로는 별도 트랙)" 철회한다. 초안은 그 자리를 aimon-memory-honcho / aimon-memory-dyad 로 채울 생각이었고, 실제로 채운 것은 aimon-memoryRemotePeerMemory 다(§8.1). 철회의 내용은 같다. 괄호 안의 단서 — MCP 는 별도 트랙 — 는 유지된다(§14 A2)
"외부 SDK / 스키마 호환" 유지한다. AIMON 은 Honcho 의 와이어 포맷을 흉내 내지 않고 그 SDK 를 재수출하지도 않는다(§13). §2.2 의 ObservationType 4값 확장은 호환이 아니라 어휘 손실을 없애기 위한 자체 도메인 확장이다 — 값 이름이 겹치는 것은 두 시스템이 같은 개념을 부르는 이름이 같기 때문이지 스키마를 맞추기 위해서가 아니다

철회는 문장으로만 하지 않는다. peer-memory.md §1 의 그 줄을 §12 Step 1 과 같은 커밋에서 고치고, 그 자리에 이 문서로의 링크를 남긴다. Step 1 의 인수 조건이다.

0.2 peer-memory.md §10.3 은 지금 틀려 있다 — 이 문서가 그것을 부정한다

같은 규율을 한 번 더 적용한다. 정본의 ### 10.3 CLI — memory yaml 블록(peer-memory.md:772-795)에 두 가지 오류가 있고, 이 문서 §9.2 가 둘 다 반대로 적는다.

정본이 말하는 것 실제 이 문서
yaml 예제가 workspace-id · peer-id · peer-name · storage-path · reconciler-enabled (kebab) MemoryConfig@JsonProperty 가 전부 camelCase. 그대로 따라 쓰면 ConfigurationException 으로 부팅이 실패한다(§9.2 의 실측) §9.2 가 camelCase 로 적는다
"모르는 값은 시작 경고와 함께 file 로 떨어진다" 경고는 관찰 스토어에서만 나온다. createRepresentationStore:929-943 은 말없이 폴백한다 §9.2 가 그 절반을 짚는다

IMPORTANT: 이 수정은 Step 1 로 당긴다 — 지금 틀린 정본이 읽는 사람을 부팅 실패로 보내고 있고, 이 수정은 다른 어떤 단계에도 의존하지 않는다. 사용 가이드 (memory-usage-guide.md:71)는 camelCase 로 맞게 적혀 있으므로 고칠 것은 설계 정본 한 곳뿐이다.


목차


1. 문제 — 확장 seam 의 고도가 틀렸다

1.1 지금 무엇을 바꿔 끼우게 되어 있는가

MemorySpec재료로 스토어를 받는다. 조립 계층의 신호는 명확하다 — MemorySpec.Builder.representationStore(...) / .observationStore(...) 두 개가 확장점이고, MemoryAssembly 가 그 둘로부터 주입 provider·툴 컨텍스트 enricher·툴 provider 를 만든다.

MemorySpec(observationStore, representationStore)
   └─ MemoryAssembly
        ├─ RepresentationMemoryContextProvider(representationStore, …)   ← 프롬프트 주입
        ├─ MemoryToolContextEnricher(workspace, observer)                ← 툴 컨텍스트
        └─ OrcaMemoryToolProvider(representationStore, observationStore,
                                  redactionPolicy)                       ← 도구 3종

그러므로 "메모리를 다른 구현으로 바꾼다" 는 (이 문서가 쓰이던 시점에) 세 저장소 인터페이스를 구현한다는 뜻이었다. aimon-memory-file / -mongodb / -postgres 셋이 정확히 그것을 하고 있었고, 이 절이 진단하는 문제는 그 셋이 잘못 만들어졌다는 것이 아니라 그 고도가 교체 지점이 될 수 없다는 것이다. 진단이 맞았으므로 셋은 살아남지 못했다 — 무엇이 어떻게 되었는지는 §4.3.

1.2 바꿔치기 대상은 저장소가 아니다

대상 실제 노출 표면 저장소를 노출하는가
현재 구현 (in-tree) ObservationStore / RepresentationStore / WorkspaceStore + reasoning 컴포넌트 그렇다
Honcho v3.1.0 AGPL-3.0 HTTP 서비스. /peers/{p}/context, /peers/{p}/chat, /conclusions/query, /sessions/{s}/messages 아니다
Dyad (/Users/kangwoo/Workspaces/research/memory/dyad) 독립 Spring Boot HTTP 서비스 (Postgres + pgvector). Tier 0 context() / Tier 1 recall() / Tier 2 chat() 아니다

두 원격 대상은 자기 저장소를 자기가 소유한다. Honcho 는 Postgres + Redis 를, Dyad 는 Postgres + pgvector 를 자기 프로세스 뒤에 감춘다. HTTP 밖으로 나오는 것은 결론(conclusion)과 렌더된 표현 문자열뿐이다.

1.3 저장소 고도에서 어댑터를 쓰면 정확히 무엇이 막히는가

ObservationStore 11개 메서드를 HTTP 어댑터로 구현한다고 가정하면 이렇게 갈린다.

메서드 Honcho Dyad
save(Observation) POST /conclusions 가 있지만 confidence 필드가 없다 — 값이 버려진다
findById(ObservationId) 단건 조회 엔드포인트가 없다 (Dyad 는 /conclusions/{id}/chain 으로 우회 가능)
findBySubject(PeerView, int) 목록 API 있음
count(PeerView) Honcho 는 Page.total 로 근사
semanticSearch(...) 유일하게 깨끗하게 대응되는 메서드
findSubjects(Workspace, int) peer 목록 API
findByConfidenceBelow(...) 원격은 confidence 로 필터하지 않는다. Dreamer 전용 질의
delete / softDelete DELETE /conclusions/{id}
purgeSoftDeletedBefore(...) 감사 윈도 purge 는 서버 내부 사이클(Reconciler)이 한다
merge(winner, loser, merged) 병합은 서버의 3단 중복 제거가 한다. 외부에서 지시할 API 가 없다

RepresentationStore 는 더 나쁘다.

메서드 Honcho Dyad
save(Representation) — 표현은 저장하는 것이 아니라 읽을 때 계산되는 것이다
findLatestGlobal(subject) ○ (/peers/{p}/context) △ (/peer-card)
findLatestLocal(subject, observer, sessionId) ○ (/sessions/{s}/context) ○ (/sessions/{s}/context)
deleteOlderThan(...)

그리고 반환 타입 자체가 맞지 않는다. RepresentationList<Observation> + summary + tokenCount 를 든 구조화 애그리게이트인데, 두 서비스가 주는 것은 렌더된 문자열 한 덩어리다 (Honcho 의 peer_representation, Dyad 의 ContextResponse.representation). 어댑터가 Representation 을 만들려면 그 문자열을 되파싱해서 없는 구조를 지어내야 한다.

IMPORTANT: 위 표의 는 "구현하기 어렵다" 가 아니라 "구현할 방법이 없다" 이다.

그리고 여기서 논거를 정확히 골라야 한다 — "UnsupportedOperationException 을 던지면 안 된다" 는 이 인터페이스에 대해서는 참이 아니다. MongoObservationStore:126-136PostgresObservationStore:229-244semanticSearch 자리에서 항상 그 예외를 던지고, 그것은 실수가 아니라 설계다 — IndexedObservationStore 의 클래스 javadoc 이 "Some metadata stores deliberately do not implement semanticSearch and throw UnsupportedOperationException instead. Wrapping such a store here restores search" 라고 그 패턴을 명시한다. 즉 이 인터페이스에는 예외를 두고 데코레이터로 복원하는 규범이 이미 있다. (두 스토어는 이후 제거되었고 그 javadoc 도 두 이름을 빼도록 고쳐졌다 — 인용은 현재 문장이며, 규범을 세운 사례가 무엇이었는지는 위 두 줄이 기록한다.)

차이는 복원 가능성이다.

자리 예외를 둘 수 있는가
semanticSearch 있다 (기존 규범) 복원할 대상(ObservationIndex)이 코어에 있고 IndexedObservationStore 가 그것을 끼운다
merge · findByConfidenceBelow · purgeSoftDeletedBefore · RepresentationStore.save 없다 복원할 대상이 원격에 존재하지 않는다. 데코레이터를 아무리 겹쳐도 없는 API 를 만들어 내지 못한다

아래 칸의 예외는 IndexedObservationStore 로 흡수되지 않고 Dreamer·Reconciler 의 호출 지점에서 런타임에 터진다. 그것이 저장소 고도를 쓸 수 없는 이유이며, LSP 위반이라는 일반론이 아니다.


2. 가능한가 — 대상별 기능 대조

답: 서비스 고도로 seam 을 옮기면 세 대상 모두 가능하다. 다만 무엇이 되고 무엇이 안 되는지는 티어마다 다르다. 아래 두 표가 이 문서의 사실 근거다.

2.1 티어별 대응표

범례: 그대로 된다 · 되지만 손실이 있다 · 안 된다

능력 현재 구현 (기본) Honcho v3.1.0 Dyad
SNAPSHOT (프롬프트 주입 / recall) RepresentationStore — 구조화 Representation + tokenCount GET /v3/…/peers/{p}/context · GET /v3/…/sessions/{s}/context — 렌더 문자열만 GET /v1/…/sessions/{s}/context — 렌더 문자열만, 세션 필수
├ 세션 없는 실행(포크) ✓ GLOBAL 폴백 ✓ peer 스코프 context 엔드포인트가 따로 있다 GET /v1/…/peer-card 로 대체 — 요약 라인만
├ 토큰 예산 tokenCount 로 정확 계산 tokens 파라미터 있음, 응답에 총량 없음 tokens + 응답에 summaryTokens/messageTokens
└ 구조화 observation
└ "못 준다" 를 말할 수단 MemorySnapshot.observationsAvailable (§3.3) 같음
SEARCH semanticSearch (KnowledgeStore 위임) POST /v3/…/conclusions/query (query,top_k,distance) POST /v1/…/recall6신호 융합 + explain
├ 점수 △ 순위만 — ranksByScore()=false(§3.3) △ distance → score 변환 ✓ [0,1] 고정 가중 융합 점수
└ 근거 설명 explain{sem,kw,ent,reinf,rec,lvl}
CHAT (dialectic) LlmDialecticEngine POST /v3/…/peers/{p}/chat, 추론 레벨 5단 POST /v1/…/chat, 추론 레벨 5단
├ 스트리밍 default 구현이 답변 전체를 한 청크로 흘린다 (진짜 스트리밍 아님) ✓ SSE ✓ SSE (/chat/stream)
observationsConsidered ✗ 응답이 {content} ✗ (toolCalls 는 준다)
TokenUsage
OBSERVE (사실 직접 등록) ObservationStore.save, confidence 포함 POST /v3/…/conclusionsconfidence 필드 없음 POST /v1/…/conclusions — confidence·level 필드 없음(서버가 explicit 로 고정)
INGEST (대화 수집) △ 구현은 있으나 CLI 프로세스 종료 시 1회만 배선, 스타터는 미배선 POST /v3/…/sessions/{s}/messages (배치 ≤100) POST /v1/…/sessions/{s}/messages
└ read-your-writes ✗ 큐 비동기 ✗ 최대 30분 (DERIVER_FLUSH_ENABLED 는 전역 스위치라 켜면 배치 이점을 통째로 잃는다) ?wait=derive — 요청 단위 동기화
프로버넌스 (결론 → 전제 → 원문) ✗ (sourceMessageIds 자체는 여러 곳이 읽는다 — DeriverMessageLinkTool:98-112, RandomWalkDreamer:300-302, DefaultReconciler:251-252 — 그러나 그 id 로 원문 메시지를 되짚는 경로가 없다) ✗ REST 로는 없음 (dialectic 내부 도구 get_reasoning_chain 전용) GET /v1/…/recall/provenance, GET …/conclusions/{id}/chain
감사 로그 △ soft-delete 윈도만 GET …/conclusions/{id}/events
Dreamer 소유자 AIMON (DefaultDreamerEngine + Quartz) 서버 (POST /v3/…/schedule_dream) 서버 (POST /v1/…/dreams)
레닥션 ✓ 큐 진입 직전 강제 게이트 ✗ 서버에 없음 → 클라이언트가 해야 한다 ✗ 서버에 없음 → 클라이언트가 해야 한다
멀티 인스턴스 △ Postgres 백엔드에서만 ✓ 서버가 책임 ✓ 서버가 책임
워크스페이스 격리 ✓ 컴파일 타임(값 객체 강제) ✓ 서버 (JWT 스코프) ✓ 서버 (JWT pair 스코프)

2.2 도메인 모델의 손실 — 무엇을 넓혀야 하는가

AIMON Honcho Dyad 판정
ObservationType = {EXPLICIT, DEDUCTIVE} level = {explicit, deductive, inductive, contradiction} 같음 (ConclusionLevel) 넓혀야 한다. 지금 매핑하면 원격 결론의 절반이 DEDUCTIVE 로 뭉개진다
Observation.confidence (primitive double, 필수) 없음 confidence (nullable, inductive 전용) 어댑터가 type.baseConfidence()자리 채움한다. 순위는 MemoryHit 리스트 순서가 정본이고 confidence 는 순위 신호가 아니다(§3.3) — 그런데 이 자리 채움은 모델에게 보이므로 신호가 필요하다(아래)
Observation.sourceMessageIds message_ids + source_ids(추론 트리) message_ids + source_ids(추론 트리) 대응됨. source_ids두 대상 모두 갖고 있고(Honcho honcho-java-spec.md:228,240, Dyad Dtos.java:90List<String> sourceIds) AIMON 에만 대응물이 없다 — §13 비목표
PeerView(workspace, Principal) (workspace, observer, observed) PairKey(workspace, observer, observed) 대응됨. Principal.Type 은 원격에 없으므로 peer 이름으로 평탄화된다
ReasoningLevel = {FAST, BALANCED, DEEP} 5단 minimal…max 5단 MINIMAL…MAX 3 → 5 매핑은 정보 손실 없음(단사). 역방향은 없음 — 어댑터가 정한다
Workspace + WorkspaceStore 서버 소유 (get-or-create) 서버 소유 원격에서 WorkspaceStore 는 배선하지 않는다 (§4.3)

confidence 왕복 손실 — §3.3 의 기준을 그대로 적용한다

§3.3 이 observationsAvailable 을 도입하며 세운 기준은 "모델이 보느냐" 였다. confidence 는 세 도구 전부에서 모델에게 출력된다.

도구 출력 지점
ObserveTool :202sb.append("confidence: ").append(String.format(…, saved.getConfidence()))
MemorySearchTool :179 — 히트마다 (type=…, confidence=…)
MemoryRecallTool :175 — 관찰마다 (confidence=…)

더 나쁜 것은 ObserveTool 이 confidence 를 입력으로도 받는다는 것이다(스키마 :112, [0,1] 범위 검증 :129-131, 저장 :193). 원격에는 그 자리가 없다 — Dyad 의 Requests.CreateConclusion(observer, observed, session, content, entities, expiresAt) 에 confidence 필드가 없고 서버가 ConclusionLevel.EXPLICIT, null 로 고정하며(ConclusionController:108-110), Honcho 의 documents 테이블에도 confidence 컬럼이 없다. 즉 모델이 confidence: 0.3 을 보내면 서버는 버리고, 도구는 0.30(또는 baseConfidence)을 되돌려 준다. 모델은 자기가 기록한 값이 저장된 줄 안다.

IMPORTANT: 이것은 observationsAvailable 이 막는 손실보다 나쁘다. 스냅샷은 빈 값을 주지만 confidence 는 그럴듯한 거짓값을 준다 — §5.2 가 없애겠다고 선언한 "조용히 틀린 답" 그 자체다. 그래서 두 가지를 함께 한다.

  1. 신호를 둔다. 세 도구가 confidence 를 찍으므로 신호도 이다 — ObserveToolObservationRecorder.storesConfidence(), MemorySearchToolMemoryHit.confidenceAvailable, MemoryRecallToolMemorySnapshot.confidenceAvailable 을 본다(전부 §3.3). observationsAvailable 과 같은 기계다.

셋째는 오늘은 발현하지 않는 자리를 막는다. MemoryRecallTool 은 스냅샷의 observations 에서만 confidence 를 얻는데 §2.1 이 Honcho·Dyad 둘 다 구조화 observation 을 ✗ 로 적었으므로 ("관찰은 주면서 confidence 는 가짜" 인 조합이 세 대상 어디에도 없다) 기본 백엔드에서는 언제나 true 다. 그럼에도 두는 이유는 네 번째 백엔드가 그 조합을 만들 수 있고, 그때 이 필드가 없으면 Recall 이 지어낸 숫자를 모델에게 조용히 넘기기 때문이다 — §3.3 이 observationsAvailable 을 도입할 때 쓴 것과 같은 논거다 2. 스키마를 좁힌다. storesConfidence() 가 false 면 ObserveTool 이 입력 스키마에서 confidence 파라미터를 뺀다. 없는 파라미터는 모델이 보낼 수 없으므로 왕복 손실 자체가 사라진다. 오늘 ObserveTool.createInputSchema():103 은 정적이지만, AbstractToolToolDefinitionProvider 를 받는 생성자가 이미 있어 (도구 개발 가이드의 "동적 정의") 런타임에 스키마를 정하는 것이 규약 안이다. 같은 조건에서 도구 설명에 "이 백엔드는 confidence 를 저장하지 않는다" 를 붙이고, 세 도구의 렌더도 그때는 confidence 를 찍지 않는다

IMPORTANT: 이 결정은 §11.2 의 "도구 4종의 입력 스키마 = 불변" 과 정면으로 부딪힌다. 여기가 이긴다 — 스키마를 얼리면 모델이 저장되지 않을 값을 계속 보내게 되고, 그것이 §5.2 가 없애겠다고 한 "조용히 틀린 답" 이다. §11.2 의 그 행은 "기존 사용자에게는 불변" 으로 한정했다: 기본 백엔드는 storesConfidence()=true 라 오늘 도는 배포의 스키마가 실제로 그대로이기 때문이다.

남는 것은 type(level)이다 — Dyad 는 직접 주입을 EXPLICIT 로 고정하므로 모델이 고른 DEDUCTIVE 도 버려진다. 같은 기계를 type 까지 넓힐지는 §15 에 미확인으로 올린다.

ObservationType 을 4값으로 넓히는 것은 기존 코드에 안전하다 — 트리 전체에 이 enum 을 대상으로 하는 exhaustive switch 가 하나도 없고, 사용처는 전부 valueOfbaseConfidence() 다. 대신 다운그레이드가 깨진다: 새 값이 파일/Mongo/Postgres 에 저장된 뒤 옛 jar 로 되돌리면 valueOf 가 던진다. 0.x 에서 받아들일 만한 비용이며 §11 에 명시한다.

2.3 결론 — 대상별 한 줄

대상 판정
현재 구현 다섯 티어 전부 ✓. 단 INGEST 는 배선이 없는 것이지 구현이 없는 것이 아니다(§7)
Dyad 다섯 티어 전부 ✓. SEARCH 는 현재 구현보다 낫다(융합 점수 + explain). SNAPSHOT 의 세션 없는 경로만 약하다
Honcho 다섯 티어 전부 ✓. SNAPSHOT 의 세션 없는 경로는 가장 낫다(peer 스코프 엔드포인트). SEARCH 는 시맨틱 단독

3. 새 SPI — 서비스 고도의 다섯 티어

3.1 왜 그 고도인가

seam 은 양쪽이 같은 말을 할 수 있는 가장 낮은 지점에 그어야 한다. 저장소 고도는 그 지점이 아니다 — §1.3 이 보였듯 절반이 번역되지 않는다. 반대로 한 단계 위(예: "메모리 전체를 도구 하나로") 로 올리면 프롬프트 자동 주입이 불가능해진다. 주입은 모델이 부르는 것이 아니라 실행기가 매 실행마다 무조건 하는 것이기 때문이다.

그 사이에 정확히 다섯 개의 연산이 있고, 그것이 세 대상이 모두 이름을 갖고 있는 연산이다.

능력 인터페이스 상태 소비자 Honcho Dyad
SNAPSHOT MemorySnapshotReader 신규 MemoryContextProvider 기본 구현, MemoryRecallTool …/context …/context, …/peer-card
SEARCH MemorySearcher 신규 MemorySearchTool …/conclusions/query …/recall
CHAT DialecticEngine 기존 그대로 MemoryChatTool …/chat …/chat
OBSERVE ObservationRecorder 신규 ObserveTool POST …/conclusions POST …/conclusions
INGEST MemoryIngestor 신규 실행기의 실행 종료 이음매 (§7) POST …/messages POST …/messages

다섯 중 둘은 이미 서비스 고도에 있다. 이것이 이 설계에서 가장 중요한 사실이다.

  • MemoryContextProvider.provide(MemoryContextRequest) → Optional<SystemPromptPart> 는 이미 "실행 정체성을 받아 프롬프트 조각을 돌려준다" 는 완전한 서비스 계약이다. 저장소를 한 글자도 언급하지 않으며, peer 해석은 MemoryPeerResolver 라는 별도 전략에 이미 위임되어 있다. 원격 어댑터가 이것을 직접 구현하면 실행기 쪽은 한 줄도 바뀌지 않는다.
  • DialecticEngine.query(DialecticQuery) → DialecticResponse 역시 그렇다. DialecticQuery 는 workspace · subject · observer · sessionId · question · level 을 들고 있고, 이 여섯은 Honcho 의 DialecticOptions 와 Dyad 의 ChatRequest하나도 빠짐없이 대응된다.

즉 고도 문제는 전면적이지 않고 국소적이다. 틀어진 자리는 검색·관찰·수집 세 개이며, 여기에 SNAPSHOT 티어를 하나 추가하는 이유는 MemoryRecallTool 이 아직 RepresentationStore 를 직접 쥐고 있기 때문이다(§5).

3.2 파사드와 능력 협상 — PeerMemory

package at.aimon.core.memory;

/**
 * 한 배포가 쓰는 메모리 백엔드. application-scoped.
 */
public interface PeerMemory extends ApplicationScoped {

    /** 진단·로그·degradation 문구에 쓰는 백엔드 식별자 ("default" | "honcho" | "dyad"). */
    String backendId();

    Optional<MemorySnapshotReader> snapshotReader();
    Optional<MemorySearcher>       searcher();
    Optional<DialecticEngine>      dialecticEngine();
    Optional<ObservationRecorder>  observationRecorder();
    Optional<MemoryIngestor>       ingestor();
}

public enum MemoryCapability { SNAPSHOT, SEARCH, CHAT, OBSERVE, INGEST }

/** 능력은 <b>계산되는 것이지 선언되는 것이 아니다</b>. 그래서 인터페이스 밖에 산다. */
public final class MemoryCapabilities {
    private MemoryCapabilities() { }
    public static Set<MemoryCapability> of(PeerMemory backend) { /* 다섯 접근자를 본다 */ }
}

IMPORTANT: 능력 집합이 PeerMemory 의 메서드가 아닌 것이 이 설계의 핵심 불변식이다. 백엔드가 Set.of(CHAT) 를 선언하고 dialecticEngine()Optional.empty() 를 돌려주는 상태 — "할 수 있다고 말해 놓고 못 하는" 상태 — 가 표현할 자리 자체가 없어서 만들어지지 않는다. RuntimeDegradations 는 이 계산 결과를 읽어서 기록만 한다. 두 번째 진실 원천이 생기지 않는다.

초안은 이것을 PeerMemorydefault Set<MemoryCapability> capabilities() 로 두었는데, 그 형태로는 주장이 성립하지 않는다. 자바의 default 메서드는 final 로 만들 수 없으므로 구현체가 @Override public Set<MemoryCapability> capabilities() { return Set.of(CHAT); } 를 쓸 수 있고, 그 순간 §14 A4 가 기각당한 바로 그 상태가 재현된다 — §5.2 의 등록 루프는 그 집합 하나만 보고 판단하므로 없는 도구가 등록되고 첫 호출에서 Optional.empty() 를 만난다. 그것은 A3 를 기각한 이유 ("판단 시점이 런타임으로 밀린다")와 정확히 같은 결과다.

IMPORTANT: 이 불변식에는 예외가 하나 있고, 그것은 티어 경계에 있다. 재료는 있는데 그 연산을 못 하는 스토어를 넘기면(메타데이터 전용 ObservationStore) 티어가 있는 채로 던진다 — "SEARCH 를 한다고 말해 놓고 못 하는" 상태가 이 경로로는 만들어진다. 접근자가 Optional.empty() 를 돌려주지 않으므로 능력 계산이 그것을 볼 수 없기 때문이다. §4.1 이 그것을 계약으로 막고 §8.1 의 testkit 이 강제한다. §0 의 한정("티어 경계에서만 참")은 티어 의 손실을 가리키는 것이었고, 이것은 세 번째 종류다.

정적 유틸로 옮기면 재정의할 자리가 없어져 주장이 참이 된다. abstract class 로 두고 public final 메서드를 쓰는 방법도 가능하지만, PeerMemory 를 클래스로 만들면 어댑터가 상속을 강요당하므로 고르지 않았다. §3.4 가 요청 객체 형태에 대해 "규약이지 강제가 아니다" 를 인정하고 강제 수단을 따로 만든 것과 같은 정직함을 여기에도 적용한 것이다 — 다만 여기서는 타입 배치를 바꾸는 것만으로 실제 강제가 되므로 새 ArchUnit 규칙이 필요 없다.

Optional 접근자 다섯 개가 장황해 보일 수 있으나, 대안(Set<MemoryCapability> + 캐스팅)은 타입 안전을 잃고, 또 다른 대안(하나의 뚱뚱한 인터페이스 + UnsupportedOperationException)은 §14 A3 에서 기각한다.

3.3 티어 인터페이스와 값 객체

모든 값 객체는 final class + builder + Objects.requireNonNull 이다 — record 금지 규약 (code-style.md, immutability-pattern.md).

// ── SNAPSHOT ────────────────────────────────────────────────────────────
public interface MemorySnapshotReader {
    Optional<MemorySnapshot> read(MemorySnapshotQuery query);
}

public final class MemorySnapshotQuery {   // builder
    PeerView  subject;                     // 누구에 대한 스냅샷인가. ★ 워크스페이스는 여기서 나온다
    PeerView  observer;                    // null 이면 global
    String    sessionId;                   // null 이면 세션 없는 실행
    MemorySnapshotScope scope;             // LOCAL | GLOBAL | LOCAL_THEN_GLOBAL(기본)
    MemoryInjectionMode mode;              // 기존 enum 재사용 — SUMMARY_ONLY | FULL
    int       maxTokens;                   // 0 = 상한 없음
}

public final class MemorySnapshot {        // builder
    String  renderedText;                  // ★ 정본. 원격은 이것만 준다
    MemorySnapshotScope resolvedScope;     // 실제로 무엇이 응답했는가
    Instant generatedAt;
    int     tokenCount;                    // 원격이 안 주면 TokenEstimator 로 추정 (estimated 플래그)
    boolean tokenCountEstimated;
    boolean truncated;                     // 예산 때문에 잘렸는가
    boolean observationsAvailable;         // ★ 이 백엔드가 개별 관찰을 노출하는가
    boolean confidenceAvailable;           // ★ 그 관찰의 confidence 가 진짜인가 (§2.2)
    List<Observation> observations;        // observationsAvailable=false 면 항상 빈 리스트
}

renderedText 가 정본이고 observations 가 best-effort 인 것은 §2.1 의 사실을 그대로 타입에 옮긴 것이다. MemoryInjectionMode원격에서는 힌트다 — 기본 백엔드는 정확히 지키고, 어댑터는 자기 서버의 예산 파라미터(summary / max_conclusions / tokens)로 번역한다. 이 비대칭은 숨기지 않고 MemorySnapshot.truncatedtokenCountEstimated관측 가능하게 만든다.

IMPORTANT: observationsAvailable 이 있어야 하는 이유는 §3.2 의 불변식이 티어 경계에서만 참이기 때문이다. 능력 협상은 "이 백엔드가 SNAPSHOT 을 하는가" 까지만 답하고, "그 스냅샷이 개별 관찰을 싣는가" 는 답하지 않는다. 그런데 오늘의 렌더 코드(MemoryRecallTool:170-178)는 if (!overBudget && !rep.getObservations().isEmpty()) 로 빈 리스트를 만나면 Observations: 절을 통째로 생략한다 — 모델에게 "관찰이 하나도 없다""이 백엔드는 개별 관찰을 안 준다" 가 글자 하나 다르지 않게 된다. 이 문서가 §5.2 에서 없애겠다고 한 실패 모드(조용히 빈 답) 그 자체다. 플래그가 있으면 MemoryRecallTool"이 백엔드는 개별 관찰을 노출하지 않는다 — 요약이 전부다" 를 렌더할 수 있고, renderedText 가 정본이고 손실은 관측 가능하게 둔다는 이 절의 원칙과도 일관된다.

참고로 CHAT 쪽의 같은 손실(observationsConsidered · TokenUsage 가 원격에서 빈다)에는 플래그를 두지 않는다. MemoryChatTool:110-113 이 그 둘을 로그에만 쓰고 모델에게는 getAnswer() 만 돌려주므로, 비어도 모델이 보는 것이 달라지지 않기 때문이다.

// ── SEARCH ──────────────────────────────────────────────────────────────
public interface MemorySearcher {
    /** 결과는 <b>항상</b> 관련도 내림차순이다 — 그것이 순위의 정본이다. */
    List<MemoryHit> search(MemorySearchQuery query);

    /** 이 백엔드가 순위를 <b>수치</b>로도 낼 수 있는가. 기본 백엔드는 false — 아래 참조. */
    boolean ranksByScore();

    /** 이 백엔드가 검색을 한 세션으로 좁힐 수 있는가. 기본 백엔드는 false — 아래 처분표 참조. */
    boolean narrowsBySession();
}

public final class MemorySearchQuery {     // builder
    PeerView  subject;                     // ★ 워크스페이스는 여기서 나온다
    PeerView  observer;
    String    query;
    int       topK;
    double    minScore;                    // 0 = 미적용. ranksByScore()=false 면 양수는 거절된다
    String    sessionId;                   // null = cross-session
}

public final class MemoryHit {             // builder
    Observation observation;               // 도메인 타입 재사용
    double      score;                     // ranksByScore()=false 면 0. [0,1]
    boolean     confidenceAvailable;       // ★ observation.confidence 가 진짜인가 (§2.2)
    Map<String, Double> signals;           // Dyad 의 explain 6신호. 다른 백엔드는 빈 맵
}

MemoryHitObservation 을 그대로 싣는 것은 의도다 — Dyad 의 RecallHitResponse{conclusion, score, explain} 와 모양이 같고, 도구가 렌더할 때 이미 있는 타입을 그대로 쓴다. 반환이 값 객체 래퍼가 아니라 List<MemoryHit> 인 것은 이것이 대체하는 ObservationStore.semanticSearch 와 같은 모양이기 때문이다. 대신 Dyad 의 analyzedQuery / candidatesConsidered 같은 진단 필드는 버려진다 — 필요해지면 MemorySearchResult 래퍼를 도입하는 별개 변경이다.

IMPORTANT: 순위의 정본은 score 가 아니라 리스트 순서다. 초안은 score 를 "순위의 정본" 으로 승격시켰는데, 기본 백엔드에는 점수가 아예 없다ObservationStore.semanticSearchList<Observation> 을 돌려주고(:46), 그 아래 ObservationIndex.search(:61)의 javadoc(:53-54)도 "Returns up to topK observation ids … ordered from most to least relevant"순서만 약속한다. StoreBackedPeerMemory 는 그 둘 위에 서므로 점수를 만들 재료가 없다.

이것은 observationsAvailable(§3.3)·confidenceAvailable(§2.2)에 이은 세 번째 티어 안 손실이고, 같은 기계로 처분한다.

처분
순서 모든 백엔드가 보장한다. 티어 계약이며, 기본 백엔드의 인덱스가 이미 그렇게 약속한다
score ranksByScore()=true 인 백엔드만 채운다. false 면 0 이다 — 순위에서 역산한 가짜 점수를 지어내지 않는다
minScore ranksByScore()=false 인데 양수를 주면 거절한다(IllegalArgumentException). 조용히 무시하면 호출자는 필터가 걸린 줄 알고 안 걸린 결과를 받는다
sessionId 같은 처분. narrowsBySession()=false 인데 세션 id 를 주면 거절한다 — 기본 백엔드의 ObservationStore.semanticSearch(subject, query, topK) 에는 좁힐 세션 축이 아예 없다. 이것이 ranksByScore() · storesConfidence() 에 이은 세 번째 신호
observer 거절하지 않는다. 이 축은 "누가 묻는가" 이지 결과를 좁히는 약속이 아니다 — peer 쌍으로 스코프하는 원격 백엔드가 두 짝을 다 필요로 하기 때문에 질의에 있고, 기본 백엔드는 subject 에서 전부 파생시킨다. 좁혀 준다고 약속하지 않았으므로 안 좁혀도 거짓말이 아니다

storesConfidence()ObserveTool 의 스키마를 좁히는 것과 같은 모양이다 — 못 하는 것을 말하고, 호출자가 그것을 보고 요청을 바꾸게 한다. 모델은 이 축에 노출되지 않는다(MemorySearchTool 의 입력 스키마는 query + top_k 뿐이고 §11.2 가 그것을 불변으로 둔다). 노출면은 peerMemory.searcher() 를 직접 쓰는 애플리케이션 하나이며, 바로 그 호출자가 minScore 를 줄 수 있는 유일한 주체다.

// ── OBSERVE ─────────────────────────────────────────────────────────────
public interface ObservationRecorder {
    Observation observe(ObservationDraft draft);

    /** 이 백엔드가 {@code ObservationDraft.confidence} 를 실제로 저장하는가. 원격은 대개 false (§2.2). */
    boolean storesConfidence();
}

public final class ObservationDraft {      // builder
    PeerView  subject;                     // ★ 워크스페이스는 여기서 나온다
    PeerView  observer;
    String    sessionId;                   // nullable
    String    content;                     // ★ 이미 레닥션을 통과한 텍스트여야 한다
    ObservationType type;
    double    confidence;                  // 원격은 버린다 — §2.2 · storesConfidence() 를 먼저 볼 것
    Map<String, String> metadata;
}

// ── INGEST ──────────────────────────────────────────────────────────────
public interface MemoryIngestor {
    MemoryIngestReceipt ingest(MemoryIngestRequest request);
}

public final class MemoryIngestRequest {   // builder
    PeerView      observer;                // ★ 워크스페이스는 여기서 나온다
    String        sessionId;
    List<Message> messages;                // ★ 이미 레닥션을 통과한 메시지여야 한다
    boolean       waitForDerivation;       // Dyad 의 ?wait=derive. 못 하는 백엔드는 무시
}

public final class MemoryIngestReceipt {   // builder
    int     accepted;
    boolean derived;                       // waitForDerivation 이 실제로 지켜졌는가
}

3.4 요청 객체 형태는 규약이지, 오늘의 빌드가 강제하는 것이 아니다

초안은 MemoryArchitectureTest 가 이 형태를 강제한다고 적었는데 그것은 사실이 아니다. 두 규칙이 실제로 막는 범위는 이렇다.

규칙 실제 사정거리 search(Workspace, String, int) 를 막는가
onlyWhitelistedMethodTakesRawStringId (:50-58) .haveRawParameterTypes(String.class) — 파라미터 목록이 정확히 (String) 인 메서드만. 테스트 자신의 주석(:62-64)이 "ArchUnit's haveRawParameterTypes only matches the exact list" 라고 적어 둔다 아니다
noMemoryStoreMethodTakesStringAsFirstParameter (:68-88) {WorkspaceStore, ObservationStore, RepresentationStore} 세 개를 이름으로 열거하고, String 파라미터인 것만 아니다 (첫 파라미터가 Workspace 다)

즉 오늘의 규칙은 멀티테넌트 격리(맨 String id 금지)를 막을 뿐, 파라미터를 요청 객체로 접는 것과는 무관하다. 다섯 티어를 두 번째 규칙의 배열에 추가해도 마찬가지다.

그래도 요청 객체를 쓰는 이유는 규칙이 아니라 MemoryContextRequest 가 이미 적어 둔 것이다 — 새 정체성 축은 provide 의 파라미터가 아니라 요청 타입의 필드로 더해서 "widening the input never breaks an implementation" 을 지킨다. 원격 백엔드가 늘어날수록 질의 축(필터·임계값·스코프)이 늘어나므로 이 성질이 실제로 필요하다.

강제를 원한다면 만들어야 한다. §12 Step 1 의 인수 조건 ②·③ 이 그것이다.

  1. 다섯 티어를 noMemoryStoreMethodTakesStringAsFirstParameter 의 배열에 추가한다 (격리 규칙 확장)
  2. 새 규칙을 하나 더 쓴다. 문구는 아래와 같고, "메서드 전부" 가 아니라 "질의를 받는 메서드" 에 한정하며 신규 네 티어에만 건다.

신규 네 티어(MemorySnapshotReader · MemorySearcher · ObservationRecorder · MemoryIngestor)의 public 메서드 중 파라미터를 하나라도 받는 것은, 파라미터가 정확히 1개이고 그 타입이 at.aimon.core.memory.. 의 요청 값 객체여야 한다.

IMPORTANT: 이 한정은 문체가 아니라 필수다. 초안의 문구("다섯 티어 인터페이스의 public 메서드는 파라미터가 정확히 1개")는 이 문서 자신의 타입 둘에 대해 성립하지 않아서, 문자 그대로 구현하면 Step 1 이 빨간 빌드로 끝난다.

반례 왜 걸리나 처분
ObservationRecorder.storesConfidence() · MemorySearcher.ranksByScore() (§3.3) 파라미터 0개 — 능력 신호는 질의가 아니라 물음이므로 인자를 받지 않는다 "파라미터를 하나라도 받는 것" 한정으로 자연히 빠진다. 둘 다 §2.2·§3.3 이 손실을 관측 가능하게 만들려고 도입한 신호이므로 지울 수 없고, 앞으로 늘어날 자리이기도 하다
DialecticEngine.queryStream(DialecticQuery, LlmStreamSink) (:49) 파라미터 2개, 게다가 LlmStreamSinkat.aimon.core.llm.streaming 이다 DialecticEngine 을 규칙 대상에서 뺀다 — CHAT 티어는 기존 인터페이스이고 §11.2 가 "불변" 을 약속했으므로, 규칙을 맞추려고 그것을 고치면 약속을 깬다
(반례 아님) 패키지 범위 규칙 문구가 at.aimon.core.memory 직하로 읽히면 하위 패키지의 요청 타입이 걸린다 문구를 at.aimon.core.memory.. 로 적는다. 오늘 걸리는 반례는 없다DialecticEngine 을 뺀 뒤 규칙이 보는 요청 타입은 §3.3 의 넷뿐이고 전부 직하에 있다. 장래에 하위 패키지로 나뉠 것에 대비한 상위집합이다

둘 중 어느 쪽으로 갈지 문서가 골라 적는다LlmStreamSink 를 화이트리스트에 예외로 적는 길도 있지만 고르지 않았다. 예외를 하나 열면 다음 스트리밍 시그니처가 같은 예외를 요구하고, 규칙의 값은 예외의 수에 반비례한다. DialecticEngine 은 이 설계가 만들지 않은 유일한 티어이므로 규칙 밖에 두는 편이 경계로서 설명하기 쉽다.

이것이 없으면 요청 객체 형태는 규약일 뿐이다 — 그리고 지금 상태가 정확히 그것이다.

3.5 MemoryContextProvider 와의 관계 — 흡수가 아니라 유지

MemoryContextProvider실행기 쪽 이음매로 그대로 남는다. SystemPromptRenderer 는 지금처럼 provide(MemoryContextRequest) 를 부르고, ExecutorSpec.memoryContextProvider 도 그대로다.

바뀌는 것은 기본 구현이 무엇 위에 서는가뿐이다.

[지금]  RepresentationMemoryContextProvider(RepresentationStore, workspace, resolver, mode, maxTokens)
                                            └── 스토어 고도

[제안]  SnapshotMemoryContextProvider(MemorySnapshotReader, workspace, resolver, mode, maxTokens)
                                      └── 서비스 고도. 어떤 백엔드든 꽂힌다

이름을 Representation… 에서 Snapshot… 으로 바꾸는 것은 미용이 아니다 — 원격 백엔드에는 Representation 이라는 타입이 존재하지 않으므로, 그 이름을 유지하면 클래스 이름이 거짓말을 한다. 이 프로젝트가 두 번의 대규모 개명을 한 이유와 같은 이유다 (scope-model.md §7). 개명 항목은 §11 에 있다.

MemoryContextRequest(실행의 미해석 정체성 — sessionId + principal)와 MemorySnapshotQuery (해석된 읽기 — workspace + subject + observer + 예산)는 다른 것이다. 전자를 후자로 바꾸는 것이 MemoryPeerResolver 의 일이고, 그 경계는 이미 존재한다.

3.6 수명 · 소유권 · 소멸

scope-model.md 의 규칙 — "만든 쪽이 닫는다. 빌려온 것은 닫지 않는다." — 를 그대로 따른다.

컴포넌트 스코프 생성 소멸
PeerMemory (조립이 프로퍼티로 만든 것) Application MemoryAssembly 스택. 새 TeardownPhase.MEMORY_BACKEND
PeerMemory (호출자가 준 것 — 빈/인스턴스) Application 애플리케이션 애플리케이션. 스택은 손대지 않는다
RedactingPeerMemory (§6.2 가 필수로 만든 래퍼) Application MemoryAssembly 자기 자원이 없다. teardown 에 올리는 것은 감싼 delegate 이며, 그것이 조립이 만든 것일 때만이다
티어 구현체 (어댑터가 소유한 HTTP 클라이언트) Application PeerMemory 구현체 PeerMemoryclose()

VirtualShellownedShell 과 정확히 같은 모양이다 — 조립이 만들었을 때만 닫는다. PeerMemoryApplicationScoped 마커를 구현하되 AutoCloseable강제하지 않는다: 자원을 쥔 어댑터만 스스로 AutoCloseable 을 구현하고, 조립이 instanceof 로 확인해 teardown 에 올린다.

IMPORTANT: instanceof 는 래퍼가 아니라 delegate 에 걸어야 한다. §6.2 이후 스택에 들어가는 물건은 RedactingPeerMemory 이고 그것은 아무 자원도 쥐지 않으므로, 래퍼에 검사를 걸면 어댑터의 HTTP 클라이언트가 영원히 안 닫힌다. 이 저장소의 규약대로 닫을 대상은 명시적으로 골라야 하고 (scope-model.md §2 — "마커에 대한 fan-out 은 없다"), 누락의 증상은 Step 7(첫 어댑터) 이전에는 나타나지 않는다(자원을 쥔 어댑터가 아직 없다). 마커에 대한 fan-out 이 없다는 사실(§scope-model.md §2)과 정합하다.

TeardownPhase — 새 phase 하나로는 부족하다

초안은 새 MEMORY_BACKEND 를 기존 메모리 phase 넷 에 놓는다고 적었는데, 그 자리는 여전히 SESSIONS 이다 — TeardownPhase 의 선언 순서가 그렇고(TeardownRegistry#closeAll() 이 선언 순서로 훑는다), SESSIONS"stops accepting submits, lets in-flight turns finish within the configured drain timeout, then releases every held session lease" 를 하는 phase 다(:79-88).

MEMORY_FINAL_DERIVATION → MEMORY_QUEUE → DREAMER → MEMORY_MAINTENANCE → SESSIONS → CHECKPOINTS → …
                                                    ↑ 초안의 MEMORY_BACKEND 자리

그러면 §7 이 도입하는 두 수집 모드가 자기가 막겠다던 유실을 그대로 일으킨다.

모드 언제 발화하나 초안 배치에서 무엇을 치나
session-end 라이브 세션이 닫힐 때 = SESSIONS 이미 닫힌 백엔드, 이미 멈춘 큐
execution-end 실행이 끝날 때. 종료 시에는 SESSIONS 드레인 중의 in-flight 실행 같음

원인은 축이 다르다는 것이다 — 기존 네 phase 는 CLI 의 MEMORY_FINAL_DERIVATION(프로세스 종료 시 전사 일괄 투입, AgentSetupFactory:640)에 맞춰 배치된 것이고, §7 의 두 모드는 세션·실행 수명에 붙는다. 따라서 phase 하나를 더하는 것이 아니라 메모리 블록 전체를 SESSIONS 뒤로 옮긴다.

… → SESSIONS → CHECKPOINTS
      → MEMORY_FINAL_DERIVATION → MEMORY_QUEUE → DREAMER → MEMORY_MAINTENANCE → MEMORY_BACKEND
      → SESSION_TRANSPORT → AGENT_RUNTIMES → …

CHECKPOINTS 인 이유: SESSIONS 의 javadoc 이 "closing a session performs the final end-of-turn save through the checkpoint mailbox" 라고 그 둘을 붙여 두었으므로 사이에 끼어들지 않는다. 그 뒤로 가면 전사가 완전히 저장된 뒤에 마지막 수집이 돌아 오히려 정확해진다. MEMORY_BACKEND 가 블록의 마지막인 것은 앞의 넷이 전부 백엔드를 통해 쓰기 때문이다.

위험과 그 근거: MEMORY_FINAL_DERIVATION 은 전사를 TranscriptManager.initialize(sessionId, null) 로 읽는데, 그 뒤에 있는 SessionRecordStore 는 application-scoped 이므로 SESSIONS 가 닫지 않는다 (scope-model.md §5.2). 따라서 이동 후에도 전사는 읽힌다 — 다만 실측하지 않았고, §12 Step 5 의 인수 조건에 "이동 전후로 CLI 최종 derivation 이 같은 수의 관찰을 만든다" 를 넣는다.

이것은 배포된 모듈의 enum 선언 순서를 바꾸는 것이므로 동작 변경이다 — §11.3 에 적는다.


4. 기존 저장소 SPI 는 어떻게 되는가

4.1 남는다. 격하는 참조 방향으로 표현한다

ObservationStore · RepresentationStore · WorkspaceStore삭제하지도, 옮기지도, 시그니처를 바꾸지도 않는다. 바뀌는 것은 지위다.

이전 이후
지위 메모리를 교체하는 확장점 기본 백엔드의 저장 계약
누가 구현하는가 메모리를 바꾸려는 모든 사람 기본 백엔드에 새 저장소를 붙이려는 사람
조립이 보는가 MemorySpec 이 직접 받는다 StoreBackedPeerMemory 의 재료로만

격하가 실재한다는 것은 한 문장으로 검증 가능하다 — 새 SPI 다섯 인터페이스의 어떤 시그니처도 ObservationStore / RepresentationStore / WorkspaceStore 를 언급하지 않는다. ArchUnit 규칙으로 못 박을 수 있으며, §12 Step 1 의 인수 조건이다.

기본 백엔드는 이렇게 조립된다.

PeerMemory backend = StoreBackedPeerMemory.builder()
        .representationStore(representationStore)    // → MemorySnapshotReader
        .observationStore(observationStore)          // → MemorySearcher, ObservationRecorder
        .dialecticEngine(dialecticEngine)            // → 그대로 통과
        .derivationQueue(derivationQueueManager)     // → MemoryIngestor
        .build();

워크스페이스는 질의의 PeerView 에서 나온다 — 필드로 두지 않는다

앞선 개정은 빌더와 질의 값 객체 넷이 각자 Workspace 를 들되 "질의가 이긴다, 다르면 거절하지 않는다" 로 정했다. 그 처분을 뒤집는다. PeerView:20private final Workspace workspace 를 자기가 들고 있으므로, 질의에 별도 필드를 두면 MemorySnapshotQuery 안에만 워크스페이스가 최대 세 벌 (필드 + subject 안 + observer 안)이 되고 빌더까지 네 벌이 된다. 이 문서가 A4 를 기각한 사유가 "진실 원천이 둘이 된다" 인데, 여기서 셋을 만들고 있었다.

그리고 "다르면 거절하지 않는다" 는 멀티테넌트 격리에서 가장 위험한 처분이다 — subject 가 A 워크스페이스인데 필드가 B 이면 B 의 저장소에서 A 의 peer 를 찾는 질의가 조용히 만들어진다. 같은 패키지의 선례는 정반대다: Observation:57-62id 의 워크스페이스와 subject/observer 의 워크스페이스가 다르면 IllegalArgumentException 을 던진다.

그래서 필드를 뺀다. 다섯 티어의 질의는 전부 PeerView 를 들고, 워크스페이스는 subject.getWorkspace() 가 정본이다. 선례가 이미 그렇다 — ObservationStore 의 시그니처는 워크스페이스를 따로 받지 않고 PeerView 에서 파생시키며(:33/35/46/53), Workspace 를 별도로 받는 메서드 findSubjects(:66)purgeSoftDeletedBefore(:115)둘 다 peer 가 없다. 같은 규칙: peer 없는 티어 질의가 생기면 그때 Workspace 파라미터를 준다.

예외는 이 설계가 만들지 않은 CHAT 티어다. DialecticQuery 는 워크스페이스 필드를 subject/ observer 와 함께 들지만(:26-28), :43-48 이 셋의 일치를 강제하므로 위에서 경계한 처분 ("다르면 거절하지 않는다")이 여기에는 없다 — 이 절이 올바른 선례로 든 Observation:57-62 와 정확히 같은 모양이다. §11.2 가 그 타입을 불변으로 두므로 손대지 않는다. §3.4 가 요청 객체 규칙에서 DialecticEngine 을 카빙한 것과 같은 카빙이며, 두 절이 같은 이유로 같은 예외를 둔다.

StoreBackedPeerMemory 의 빌더에도 .workspace(...) 를 두지 않는다. PeerMemory 는 application-scoped 이므로 한 프로세스가 여러 워크스페이스를 서빙할 수 있어야 하고, 생성 시 워크스페이스에 묶이면 그것이 불가능해진다 — 워크스페이스는 MemoryArchitectureTest 가 지키려는 것처럼 호출과 함께 온다. (원격 어댑터에서는 워크스페이스가 URL 경로 세그먼트가 되므로 질의마다 달라도 비용이 없다.)

IMPORTANT: 재료는 있는데 그 연산을 못 하는 경우가 따로 있다. MongoObservationStorePostgresObservationStoresemanticSearch 에서 항상 UnsupportedOperationException 을 던졌으므로(§1.3 이 인용한 사실 — 두 스토어는 이후 모듈과 함께 제거되었다. 아래 계약은 그 사례에서 도출되었을 뿐 그것에 의존하지 않으며, 지금도 그대로 살아 있다), 그런 스토어를 그대로 넘기면 StoreBackedPeerMemorySEARCH 티어를 있는 것으로 내놓고 매 호출이 ToolResult.error 로 떨어진다 — §5.2 의 IMPORTANT("백엔드가 못 하는 능력의 도구를 등록하지 않는다")의 정반례다. 그래서 계약을 하나 적는다.

.observationStore(...) 에 넘기는 것은 semanticSearch실제로 답하는 스토어여야 한다. 메타데이터 전용 스토어는 조립이 IndexedObservationStore 로 감싸서 넘긴다.

이것은 회귀가 아니라 현상 유지다 — 오늘의 registerCliToolsOrcaMemoryToolProvider 도 스토어 유무만 보고 MemorySearchTool 을 등록한다. 다만 이 설계가 능력 협상을 약속하는 이상 계약으로 적어야 하고, 강제는 §8.1 의 testkit 이 맡는다("SEARCH 티어가 있으면 UnsupportedOperationException 을 던지지 않는다").

빠진 재료는 그 티어를 비운다. 스타터의 현재 상태(디라이버 없음)가 정확히 그것이다 — derivationQueue 가 없으면 ingestor() 가 비고, 계산된 능력 집합에 INGEST 가 없으며, MemoryAssembly 가 지금 하드코딩으로 올리던 memory-write-path degradation 을 계산해서 올린다.

4.2 왜 at.aimon.core.memory.impl 로 옮기지 않는가

옮기면 배포된 모듈들이 내부 패키지에 의존하게 된다. api-stability.md §2*.impl 경계는 규약이고, at.aimon.core.<domain>.impl 은 그 도메인 트리 밖에서 import 하지 않는다.

정확히 해 둘 것이 둘 있다 — 초안은 이 금지를 "PackageDependencyArchitectureTest 가 강제한다" 고 적었는데 오늘의 memory 도메인에는 그 강제가 없다.

  1. 그 테스트의 .impl 규칙은 도메인마다 손으로 쓴 것이고(workflow.impl:267, filesystem.impl:345, shell.impl:358, agent.impl:318 근방) memory 용은 없다. api-stability.md:44"현재 impl 로 격리된 도메인은 일곱 개" 로 memory 를 제외한다
  2. 더 근본적으로, 그 테스트는 aimon-core 테스트 클래스패스에서 importPackages("at.aimon")(:147) 을 돌므로 형제 Gradle 모듈을 아예 보지 못한다. 증거가 트리 안에 있다 — MemoryArchitectureTest.inMemoryImplsAreInternalToMemoryPackage:92-99InMemory* 를 memory 패키지 밖에서 참조하는 것을 금지하는데, aimon-cliaimon-spring-boot-starter 가 실제로 InMemoryObservationStore 를 import 하고도 빌드가 초록이다

그러므로 옮기지 않는 이유는 "빌드가 막는다" 가 아니라 아래 표의 다섯 모듈이 전부 배포 대상이거나 애플리케이션인데 그들이 내부 패키지를 import 하게 된다는 것이다. 규약을 지키려면 그들의 코드를 먼저 옮겨야 하고, 그것이 이 설계와 독립적인 별개 리팩터다.

옮기면 깨지는 것 무엇을 import 하고 있나
aimon-memory-fileat.aimon.core.memory.file File*Store implements ObservationStore/RepresentationStore/WorkspaceStore
aimon-memory-mongodb (제거됨) 같음
aimon-memory-postgres (제거됨) 같음 + PostgresDerivationQueueManager implements DerivationQueueManager
aimon-cli LlmDeriver, DefaultDreamerEngine, RandomWalkDreamer, EmbeddingSurprisalScorer, LlmJudgeSurprisalScorer, DefaultReconciler, InMemory*Store, LlmDialecticEngine구체 클래스 13종을 직접 import 해 조립한다
aimon-spring-boot-starter InMemoryObservationStore, InMemoryRepresentationStore, DefaultRedactionPolicy, StrictRedactionPolicy

.impl 이동은 저장소 SPI 를 옮기는 작업이 아니라 CLI 의 조립 코드를 코어 안의 팩토리로 끌어들이는 작업이다. §12 에 후속 단계로 남기고 전제조건을 적는다. 옮기기로 한다면 그때 memory 용 .impl ArchUnit 규칙을 함께 쓴다 — 규칙 없는 .impl 패키지는 표식일 뿐이다.

그래서 파일 백엔드가 코어로 들어올 때도 .impl 을 쓰지 않았다

aimon-memory-file 이 모듈에서 패키지로 바뀌면서(§4.3) 위 표의 첫 줄은 사라졌다 — 그 코드는 이제 코어 안에 있으므로 "배포된 형제 모듈이 내부 패키지를 import 한다" 는 문제를 더는 만들지 않는다. 그러면 이참에 at.aimon.core.memory.impl.file 로 넣을 수 있었을 텐데, 넣지 않았다. 자리는 at.aimon.core.memory.file 이다. 이유는 셋이다.

  1. aimon-cli 가 아직 이 클래스들을 직접 import 한다. AgentSetupFactoryFileObservationStore · FileRepresentationStore · FileMemoryMaintenanceScheduler · Compactable 넷을 이름으로 조립한다. .impl 에 넣는 순간 위 표의 네 번째 줄이 그대로 되살아난다 — 옮긴 이유가 "형제 모듈이 내부 패키지를 보게 하지 않는다" 였는데, 애플리케이션이 보게 만들면 규약 위반의 주체만 바뀐다
  2. 오늘의 memory 도메인에는 .impl 분리가 아예 없다. InMemoryObservationStore · InMemoryRepresentationStore · InMemoryWorkspaceStoreStoreBackedPeerMemory 가 모두 at.aimon.core.memory 에 그대로 산다. 파일 백엔드만 .impl 로 내리면 같은 성격의 기본 백엔드 재료가 두 고도에 나뉘어 다음 사람이 어느 쪽이 관례인지 판단해야 한다
  3. .impl 은 규칙이 있을 때만 뜻이 있다. 위에 적힌 대로 PackageDependencyArchitectureTest 에는 memory 용 규칙이 없다. 규칙 없이 이름만 .impl 로 바꾸는 것은 표식이지 경계가 아니다

따라서 이 이동은 .impl 이동 대상 목록을 줄이지 않는다. 늘린다. 언젠가 memory 도메인에 .impl 경계를 긋는다면 옮길 것은 이제 넷이다 — InMemory*Store 셋, deriver/dreamer/reconciler 의 구체 클래스들, StoreBackedPeerMemory, 그리고 at.aimon.core.memory.file 의 다섯 클래스. 전제조건도 그대로다(§12 후속 표): CLI 가 직접 조립하는 구체 클래스를 코어 안의 팩토리 뒤로 먼저 옮겨야 하고, 그때 memory 용 .impl ArchUnit 규칙을 함께 쓴다.

4.3 aimon-memory-{file,mongodb,postgres} — 초안의 판단은 틀렸다

이 절은 원래 "세 모듈은 한 줄도 바뀌지 않는다" 라고 적혀 있었다. 그 예측은 빗나갔고, 빗나간 이유는 이 문서가 옳았기 때문이다 — 다섯 티어 SPI 가 서면서 저장소 셋이 채우던 자리가 없어졌다. 아래는 실제로 일어난 일이다. 남은 것은 §4.1 의 격하(저장소는 기본 백엔드의 내부 재료)뿐이며, 그 부분은 그대로 유효하다.

ObservationStore / RepresentationStore / WorkspaceStore 인터페이스는 그대로다. 시그니처도, StoreBackedPeerMemory 가 그것들을 쓰는 방식도 바뀌지 않았다. 바뀐 것은 그 인터페이스를 구현하는 모듈이 어디에 있는가다.

없어진 것 (aimon-core) 그 자리를 맡은 것
aimon-memory-postgresPostgresDerivationQueueManager (row-lock 파생 큐) aimon-memoryaimon-memory-workerWorkerLoop + Representation/Summary/Dream/Deletion 컨슈머
aimon-memory-postgresKnowledgeStoreOutboxRelay (outbox → 임베딩 색인) 같은 저장소의 pgvector 네이티브 경로 (Vectors, EmbeddingDimensionCheck, aimon-memory-embed)
Postgres/Mongo {Observation,Representation,Workspace}Store 같은 저장소의 aimon-memory-store — Flyway + JDBC repository 12종
aimon-memory-file (모듈) aimon-coreat.aimon.core.memory.file 패키지 — 코드는 그대로, 모듈만 없어졌다

IMPORTANT: 이것은 이전(migration)이 아니라 제거다. 서비스의 스키마는 (workspace, observer, observed) 복합 키 위에 선 다른 물건이고, 옛 mem_* 테이블·컬렉션의 데이터가 그리로 옮겨가지 않는다. 백엔드를 바꾸면 새 백엔드는 빈 상태에서 시작한다 — §11.4 가 애초에 그렇게 적어 둔 것이 그대로 적용된다. api-stability.md §4 의 동결 약속과의 관계도 그 문서에 정리되어 있다: 동결은 살아 있는 표면에 대한 약속이고, 표면 자체가 없어지는 것은 그 약속의 위반이 아니라 그 약속이 적용될 대상이 사라지는 것이다.

남는 그림은 두 줄이다.

  • aimon-corePeerMemory SPI + 노드 로컬 기본 백엔드(InMemory*Store, at.aimon.core.memory.file)
  • 분산 메모리 — 별도 저장소의 서비스, RemotePeerMemory 로 소비

WorkspaceStore 는 여전히 티어가 되지 않는다 — 이 판단은 바뀌지 않았고, 오히려 확인되었다. 원격 백엔드는 워크스페이스 CRUD 와 테넌시를 자기가 소유한다. aimon-memory 서비스에서 그 자리를 맡는 것은 aimon-memory-storeWorkspaceRepositoryWorkspaceSettingsService 이고, AIMON 쪽은 설정으로 받은 워크스페이스 이름을 경로에 실어 보내기만 한다. WorkspaceAccessPolicy 도 마찬가지로 기본 백엔드 전용이다.


5. 도구 4개의 재배선과 등록 판단

5.0 먼저 — 조립 경로는 둘이고, 하나만 MemoryAssembly 를 지난다

IMPORTANT: 아래 §5.2 의 능력 기반 등록도, §9.2 의 backend: honcho|dyadMemorySpecMemoryAssembly 를 전제한다. 그런데 오늘 메모리를 다섯 티어까지 배선하는 유일한 조립인 CLI 는 그 둘을 한 번도 쓰지 않는다. grep -rn "MemorySpec\|MemoryAssembly" modules/aimon-cli/src/main/java/0건.

조립 경로 MemorySpec/MemoryAssembly 를 지나는가 오늘 배선되는 능력
aimon-spring-boot-starter 지난다 (AimonMemoryAutoConfigurationMemoryContribution) SNAPSHOT · SEARCH · OBSERVE. CHAT 없음(§5.3) · INGEST 없음(§7.1)
aimon-cli (AgentSetupFactory) 지나지 않는다 다섯 전부
애플리케이션 직접 조립 선택 (AimonStackSpec.memory(...) 를 부르면 지난다) 스펙이 준 만큼

CLI 가 대신 하는 것은 손으로 쓴 메서드 아홉 개다 — registerCliTools:899 · createRepresentationStore:924 · createObservationStore:957 · buildMemoryWiring:980 · buildMemoryContextProvider:1008 · buildMemoryDeriver:1027 · buildDerivationQueue:1053 · buildMemoryFinalDerivation:1073 · buildDreamerSubsystem:1098. 전부 RepresentationStore/ObservationStore 타입을 직접 받는다. backend: honcho 에는 그 두 스토어가 존재하지 않으므로, 이 아홉 개를 그대로 두면 CLI 로는 원격 백엔드를 고를 수 없다.

거기에 규칙이 하나 더 걸려 있다. AimonStackSpec:122-130MemorySpec(with representation store)과 ExecutorSpec.memoryContextProvider상호 배타로 거절하는데, CLI 는 지금 후자를 쓴다 (AgentSetupFactory:562).

그러므로 CLI 이주는 설정 표면 작업이 아니라 독립 단계다 (§12 Step 4). 초안이 이것을 마지막 단계 ("CLI/스타터 설정 표면", 지금의 Step 9) 안에 넣은 것은 규모를 심하게 낮춰 잡은 것이었다. 이주의 모양은 이렇다.

CLI 의 오늘 이주 후
createRepresentationStore · createObservationStore · buildDerivationQueue · LlmDialecticEngine 생성 backend 값에 따라 PeerMemory 하나를 만든다 — file/in-memoryStoreBackedPeerMemory, honcho/dyad 면 어댑터
.executor(ExecutorSpec…memoryContextProvider(…)) .memory(MemorySpec…peerMemory(backend)). 상호 배타 규칙은 그대로 둔다 — CLI 가 후자를 안 쓰게 되므로 애초에 걸리지 않는다
registerCliTools 가 메모리 도구 4종 등록 MemoryAssembly 의 툴 provider 가 등록. registerCliTools 에는 ConsoleOutputTool 만 남는다
buildMemoryWiringMemoryToolContextEnricher MemoryAssembly 가 만든다 (같은 타입, 같은 인자)
buildDreamerSubsystem · buildMemoryMaintenance CLI 에 남는다. 티어가 아니라 기본 백엔드 전용 배경 작업이다(§6.1). 원격 백엔드에서는 §6.1 의 검증이 이것을 끈다

5.1 무엇이 무엇 위에 서는가

도구 지금 이후 필요한 능력
MemoryRecallTool RepresentationStore MemorySnapshotReader SNAPSHOT
MemorySearchTool ObservationStore MemorySearcher SEARCH
MemoryChatTool DialecticEngine 변경 없음 CHAT
ObserveTool ObservationStore ObservationRecorder OBSERVE

ToolContext 키 4종(memory.workspace / memory.observer / memory.subject / memory.sessionId)과 MemoryToolContextEnricher 는 바뀌지 않는다 — 도구가 누구에 대해 묻는지는 백엔드와 무관하다.

5.2 누가 등록을 결정하는가 — MemoryAssembly

조립 계층이 결정한다. 도구도 백엔드도 아니다. 판단 근거는 MemoryCapabilities.of(backend) 가 다섯 티어 접근자에서 계산한 집합 하나뿐이다 — PeerMemory 에는 그 집합을 돌려주는 메서드가 없고, 없는 것이 §3.2 의 불변식이다.

능력 하나가 도구 하나에 대응하지는 않는다 — 셋이 어긋난다. SNAPSHOT 은 소비자가 둘(주입 provider + MemoryRecallTool)이고, INGEST 는 도구가 아예 없으며(소비자는 실행기 이음매), memory-tools 는 능력이 아니라 조립 모양(고정 observer 유무)에서 나온다. 그래서 루프는 도구만 돌고 나머지는 밖에 적는다.

// MemoryAssembly (개념)
Set<MemoryCapability> caps = MemoryCapabilities.of(backend);   // 선언이 아니라 계산 (§3.2)

// (1) 능력 → 도구. 1:1 인 것은 이 셋뿐이다
registerIf(caps, SEARCH,  () -> new MemorySearchTool(backend.searcher().get(), redaction));
registerIf(caps, OBSERVE, () -> new ObserveTool(backend.observationRecorder().get(), redaction));
registerIf(caps, CHAT,    () -> new MemoryChatTool(backend.dialecticEngine().get()));

// (2) SNAPSHOT 은 소비자가 둘 — 도구 하나와 프롬프트 주입 provider 하나
if (caps.contains(SNAPSHOT)) {
    register(new MemoryRecallTool(backend.snapshotReader().get()));
    contextProvider = new SnapshotMemoryContextProvider(backend.snapshotReader().get(), ...);
}

// (3) INGEST 는 도구가 없다 — 실행기 이음매에 꽂힐 뿐이다 (§7.2)
if (caps.contains(INGEST) && !perCaller) { ingestor = backend.ingestor().get(); }

// (4) 없는 능력마다 결과 문장을 남긴다
for (MemoryCapability missing : complementOf(caps)) {
    degradations.add(degradationKey(missing), consequenceText(missing, backend.backendId()));
}

이것이 오늘의 하드코딩된 세 개의 degradation 키(memory-write-path, memory-tools, memory-redaction)를 능력 다섯 개에 대해 계산된 키로 바꾼다.

degradation 키 언제 오르는가 결과 문장이 말해야 하는 것
memory-snapshot SNAPSHOT 없음 프롬프트에 메모리 조각이 안 들어가고 MemoryRecall 이 등록되지 않는다
memory-search SEARCH 없음 MemorySearch 미등록
memory-chat CHAT 없음 MemoryChat 미등록
memory-observe OBSERVE 없음 Observe 미등록 — 모델이 사실을 남길 방법이 없다
memory-ingest INGEST 없음 또는 per-caller 모드 오늘의 memory-write-path 를 대체한다(§11.1). 대화가 메모리로 흐르지 않으므로 다른 것이 채우지 않는 한 영원히 빈다. per-caller 에서도 오르는 이유는 §7.2
memory-tools 고정 observer 가 없음 (per-caller 모드) 지금과 동일. 능력이 아니라 조립 모양의 문제다
memory-redaction 레닥션 정책 없이 OBSERVE/INGEST 가 켜짐 능력에 대한 진술이어야 한다 — "모델이 관찰하는 것이 그대로 영속된다" 가 아니라 "이 백엔드에 쓰이는 것을 마스킹하는 것이 아무것도 없다" 다. 조건이 능력 하나이므로 문장도 그 폭이어야 한다: per-caller 처럼 도구가 등록되지 않는 조합에서도 티어 접근자(§3.2)는 공개이므로 경고는 여전히 참이지만, 도구를 이름으로 부르면 거짓이 된다. 원격 백엔드에서는 더 심각하다 (§6.2)

IMPORTANT: 백엔드가 못 하는 능력의 도구를 등록하지 않는 것이 이 설계의 요구사항이다. 등록해 놓고 ToolResult.error("not supported") 를 돌려주면 모델은 매 실행마다 그 도구를 다시 시도하고, 실패는 프롬프트 예산과 iteration 을 태운다. 오늘 OrcaMemoryToolProvider 의 javadoc 이 같은 이유로 "callers that cannot supply an observer should not register this provider at all" 이라고 적어 둔 것과 같은 판단이다.

5.3 MemoryChatTool 은 조립 경로에서 빠져 있다 (기존 결함)

확인된 사실: OrcaMemoryToolProviderMemoryRecallTool · MemorySearchTool · ObserveTool 세 개만 등록한다. MemoryChatToolaimon-cliregisterCliTools 에서만 등록되며, MemorySpec 에는 DialecticEngine 을 담을 필드가 아예 없다. 즉 스타터로 부팅한 배포는 MemoryChat 을 쓸 수 없다.

능력 기반 등록으로 옮기면 이 구멍이 메워진다 — CHAT 능력이 있으면 등록되고, 없으면 degradation 이 오른다. 별도 수정 항목이 아니라 §12 Step 3 의 부산물이다.

다만 "있으면" 이 조건절이라는 것이 중요하다. 스타터의 backend=in-memory 경로는 DialecticEngine 을 만들지 않으므로(그 클래스가 발행하는 스토어 빈은 aimonInMemoryRepresentationStore:95aimonInMemoryObservationStore:112 둘뿐이고 — 세 번째 @Bean 은 스펙을 나르는 aimonMemoryContribution:137 이다 — DialecticEngine 빈은 스타터 main 소스 전체에 0건이다) Step 3 이 끝나도 그 배포에서는 여전히 등록되지 않고 memory-chat degradation 이 대신 오른다. 실제로 등록되는 것은 backend=supplied + PeerMemory 빈 경로(§9.3)와, 그 뒤의 원격 백엔드다. 구멍이 메워진다는 것은 "경로가 생긴다" 는 뜻이지 "모든 배포에서 켜진다" 는 뜻이 아니다.


6. 드리머 · 디라이버 · 리컨사일러 · 레닥션

6.1 원격 백엔드는 이것들을 자기가 한다

컴포넌트 기본 백엔드 Honcho Dyad
Deriver (메시지 → 결론) LlmDeriver / ReActLlmDeriver 서버 워커 서버 워커 (idle flush 3초)
Reconciler (충돌 조정) DefaultReconciler 서버 (2단 중복 제거) 서버 (3단 중복 제거 + 이벤트 로그)
Dreamer (통합) DefaultDreamerEngine + Quartz 서버 (schedule_dream) 서버 (POST /dreams)
Index (벡터) KnowledgeStore 위임 서버 서버 (pgvector + BM25 + 엔티티)

IMPORTANT: 원격 백엔드를 고른 배포에서 AIMON 쪽 Deriver / Dreamer / Reconciler 를 함께 켜면 안 된다. 둘 다 같은 대화에서 결론을 만들고 서로 모르므로, 같은 사실이 두 저장소에 갈라져 쌓이고 어느 쪽이 진실인지 정할 수 없게 된다. 이 배제는 설정 검증으로 강제한다 — backendhoncho/dyad 인데 memory.dreamer.enabled=true 또는 memory.reconcilerEnabled=true 이면 부팅을 실패시킨다 (조용히 무시하면 운영자가 켰다고 믿는 것이 돌지 않는다). 스타터가 redaction=supplied 인데 빈이 없으면 부팅을 실패시키는 것과 같은 판단이다.

6.2 레닥션만은 코어에 남는다 — 그리고 더 중요해진다

IMPORTANT: 원격 백엔드에서 레닥션은 선택이 아니다. 다만 그 이유를 정확히 적어야 한다 — 초안은 "기본 백엔드에서는 마스킹하지 않은 메시지가 같은 프로세스 안의 LLM 클라이언트로 갔다" 고 썼는데 그것은 사실이 아니다. LlmDialecticEngineLlmDeriverLlmClient 를 통해 외부 LLM API 로 나간다. 텍스트는 이미 프로세스 밖으로 나가고 있다.

원격 백엔드가 더하는 위험은 "처음으로 밖에 나간다" 가 아니라 목적지가 하나 늘고, 그 목적지는 LLM 벤더의 데이터 취급 약정 밖에 있다는 것이다. 메모리 서버는 받은 것을 영구화하는 것이 존재 이유이므로, 시크릿이 한 번 들어가면 되돌릴 수 없다는 peer-memory.md §6.5 의 논리가 그대로, 그리고 더 강하게 걸린다.

초안의 계획으로는 보장이 약해진다 — 그것을 먼저 인정한다

초안은 게이트를 "코어의 티어 호출 지점" 에 두고 ObservationDraft.content / MemoryIngestRequest.messagesjavadoc 이 '이미 레닥션을 통과한 텍스트'를 계약으로 명시한다 고 적은 뒤, 기존 보장이 "그대로 유효하다" 고 했다. 그대로 유효하지 않다. 오늘의 보장은 javadoc 이 아니라 구현이 만든다.

오늘 초안의 계획
강제 수단 InMemoryDerivationQueueManager:83task.withMessages(messageRedactor.redactAll(...))구현 안에서 부른다. DerivationQueueManager:9-12 javadoc: "Redaction is mandatory." 호출자가 지켜야 하는 문서화된 전제조건
우회 가능성 없다 — 큐에 넣는 모든 경로가 같은 게이트를 지난다 있다. MemoryIngestorat.aimon.core.memory 의 공개 SPI 이므로 애플리케이션이 peerMemory.ingestor().ingest(raw) 를 그냥 부를 수 있다
실패 시 결과 마스킹 안 된 텍스트가 같은 프로세스의 LLM 으로 마스킹 안 된 텍스트가 제3자 HTTP 로. 되돌릴 수 없다

peer-memory.md §14.2"레닥션 게이트를 우회하는 큐 경로를 만들지 말 것" 이라고 적어 둔 그 금지를, 새 SPI 가 우회 경로를 하나 여는 것으로 위반하게 된다.

그러므로 계약을 뒤집는다 — 게이트는 구현 안으로 들어간다

실행 종료 ─▶ ExecutionMemorySink (마스킹하지 않는다)
       RedactingPeerMemory(delegate, redactionPolicy)  ← ★ 조립이 반드시 씌우는 데코레이터.
                    │   ingest · observe · search · chat 네 티어의    유일한 게이트다
                    │   바깥으로 나가는 텍스트를 전부 마스킹
       MemoryIngestor / ObservationRecorder /
       MemorySearcher / DialecticEngine                ← 여기서부터가 어댑터

정정 (Step 5 구현 시점). 이 그림의 초안에는 실행기 이음매에도 MessageRedactor.redactAll 이 있었고 단계가 ①② 둘이었다. 구현은 하나만 만들었다 — 그리고 그것이 맞다. 이 절의 논지 자체가 "②가 새로 추가되는 것이고, 이것이 강제의 자리다" 이기 때문이다. 실행기에 게이트를 하나 더 두면 멱등이라 해롭지는 않지만, 실행기가 쓰지도 않는 RedactionPolicy 를 받아야 하고, 무엇보다 게이트가 둘이 되어 "하나뿐이므로 우회 경로가 없다" 는 보장의 문장이 약해진다. 아래 "이중 적용은 안전하다" 항목도 그에 맞춰 고쳤고, 첫 불릿의 "②" 도 다이어그램의 를 가리키도록 바꿨다 — 없는 기호를 가리키는 문장은 이 정정이 없애려던 바로 그 종류의 잔여물이다.

  • ★ 데코레이터가 새로 추가되는 것이고, 이것이 강제의 자리다. MemoryAssemblyRedactionPolicy 가 있는 한 PeerMemoryRedactingPeerMemory감싸서만 스택에 넘긴다. 감싸지 않은 백엔드가 스택에 들어가는 경로가 없으므로 게이트는 다시 하나다
  • CHAT 도 감싼다. DialecticEngine 은 기존 인터페이스이므로 데코레이터를 별도 타입 (RedactingDialecticEngine)으로 두고 RedactingPeerMemory.dialecticEngine() 이 그것을 돌려준다. SNAPSHOT 만 감싸지 않는데, 그 티어의 입력에는 호출자가 쓴 자유 텍스트가 없기 때문이다(아래 표)
  • 정책이 없으면(스타터 redaction=none) 감싸지 않고 memory-redaction degradation 이 오른다. 원격 백엔드에서는 그 조합 자체를 거절한다(§9.3)
  • 어댑터는 레닥션을 모른다. 어댑터마다 게이트를 두면 새 어댑터가 하나 빠뜨리는 순간 보장이 무너지고, 그 누락은 시크릿이 나간 뒤에야 보인다
  • 이중 적용은 안전하다RedactionPolicy.redact 는 계약상 idempotent 다. 도구가 이미 마스킹한 텍스트(ObserveTool · MemorySearchTool)가 데코레이터를 다시 지나고, 기본 백엔드에서는 DerivationQueueManager.enqueue 안에서 세 번째로 적용되는데 전부 같은 이유로 안전하다. 안전하다는 것이 더 두라는 뜻은 아니다 — 강제의 자리는 하나여야 그 하나를 지켰는지 확인할 수 있다

마스킹 대상은 넷이다 — 기준은 "밖으로 나가는 텍스트" 다

초안은 이 목록을 으로 못 박았는데 하나가 빠져 있었다. 기준을 먼저 세운다: 호출자(사람 또는 모델)가 쓴 자유 텍스트가 백엔드로 나가는 자리가 게이트가 필요한 자리다. 도구의 개수가 기준이 아니다 — 도구 중 둘(MemoryRecall·MemoryChat)이 오늘 RedactionPolicy 참조 0건인데, 그중 하나만 문제다.

티어 나가는 자유 텍스트 오늘 어디서 마스킹되나
INGEST MemoryIngestRequest.messages — 대화 전체 InMemoryDerivationQueueManager:83 (구현 안, 우회 불가)
OBSERVE ObservationDraft.content — 모델이 적어 넣은 사실 ObserveTool:152:176 (redactionPolicy.redact(content))
SEARCH MemorySearchQuery.query MemorySearchTool:158 (redactionPolicy.redact(query))
CHAT DialecticQuery.question — 모델이 대화에서 끌어와 작성한 질문 아무 데서도. MemoryChatToolredact/RedactionPolicy 참조가 0건이고 생성자가 MemoryChatTool(DialecticEngine) 하나뿐이다(:61). question(:86, input.getRequiredString("question"))이 그대로 DialecticQuery.question(:104)이 된다
SNAPSHOT 없다 해당 없음 — 입력이 peer·세션·모드·예산뿐이다

마지막 줄이 기준을 증명한다. MemoryRecallTool 도 레닥션 참조가 0건이지만 구멍이 아니다 — 그 도구의 입력 스키마는 mode(enum GLOBAL/LOCAL)와 max_tokens(number) 둘뿐이고 (MemoryRecallTool.createInputSchema), 호출자가 쓴 텍스트를 밖으로 내보내지 않는다. 읽기만 하는 티어에는 게이트가 필요 없다.

CHAT 은 반대다. Honcho 는 그 문자열을 DialecticOptions.query(필수, 최대 10000자)로, Dyad 는 ChatRequest(@NotBlank String question, …)(Requests.java:52)로 본문에 그대로 실어 원격 서버로 보낸다. 오늘 게이트가 하나도 없으므로 이 자리는 목록에 있는 넷 중 유일하게 신규 게이트가 필요한 자리다.

SEARCH 의 게이트는 오늘 MemorySearchTool(코어) 안에 있어 원격에서도 즉시 구멍은 아니다. 그래도 RedactingPeerMemorysearchchat 을 함께 감싸는 이유는 도구를 거치지 않는 호출자 (애플리케이션이 peerMemory.searcher() / peerMemory.dialecticEngine() 을 직접 쓰는 경우)가 남기 때문이다 — §3.2 가 그 둘을 공개 접근자로 올린다.

이 절의 결정은 peer-memory.md §14.2 의 금지 항목을 "레닥션 게이트를 우회하는 큐 경로나 티어 호출 경로를 만들지 말 것" 으로 넓히는 것을 함께 요구한다 — RedactingPeerMemory 가 들어가는 커밋(§12 Step 3)에서 함께 고친다. §0.1·§0.2 의 정본 수정(Step 1)과는 다른 커밋이다: 그 둘은 지금 틀려 있어서 당기는 것이고, 이것은 새 데코레이터가 생겨야 참이 되는 문장이다.

6.3 패키지는 옮기지 않는다

deriver / dreamer / reconciler / index / redaction 다섯 하위 패키지는 제자리에 남는다. §4.2 와 같은 이유이고, 추가로 이 넷은 각자 코어 밖에 실제 구현자·소비자를 갖고 있다.

패키지 왜 공개로 남아야 하는가
deriver PostgresDerivationQueueManagerDerivationQueueManager 를 구현한다 (모듈 밖)
redaction RedactionPolicyMemorySpec 필드이고 스타터의 supplied 값이 그 빈을 읽는다
index IndexedObservationStore 가 Mongo/Postgres 백엔드의 semanticSearch 복원 경로다
dialectic DialecticEngine티어 인터페이스 그 자체
dreamer, reconciler CLI 가 구체 클래스 5종을 직접 new 한다 (§4.2)

격하는 문서와 참조 방향으로 표현하고(§4.1), 물리적 이동은 CLI 조립을 코어 팩토리로 옮긴 뒤에 별개 변경으로 검토한다.


7. 수집(ingest) — 오늘 없는 이음매

7.1 사실 확인

경로 지금 무엇이 수집을 부르는가
aimon-cli TeardownPhase.MEMORY_FINAL_DERIVATIONREPL 프로세스가 끝날 때 딱 한 번, 전사 전체를 DerivationTask 하나로 큐에 넣는다
aimon-spring-boot-starter 없다. MemoryAssemblymemory-write-path degradation 을 올린다
그 외 (aimon-core 를 직접 조립하는 애플리케이션) 없다. ObserveTool 로 모델이 직접 넣는 것이 유일

즉 오늘 AIMON 의 메모리는 프로세스 수명 단위로 한 번 쓰고 계속 읽는 모양이다. 그 모양은 CLI 에서는 그럭저럭 동작하지만(세션이 하나뿐이다), 원격 백엔드에서는 성립하지 않는다 — Honcho·Dyad 는 메시지 스트림을 계속 먹는 것을 전제로 배치·게이팅·idle flush 를 설계했다.

7.2 제안 — 실행기의 실행 종료 이음매

MemoryContextProvider 가 프롬프트 조립 지점에 꽂혀 있는 것과 대칭으로, 실행이 끝나는 지점에 MemoryIngestor 를 꽂는다.

IMPORTANT: turn 이 아니라 execution 이다. 이 이음매는 세션의 턴에서도, 세션이 없는 실행 (서브에이전트 포크·스킬 포크·rewake 리플레이·스케줄 루틴)에서도 같은 자리에 선다 — 두 경로가 공유하는 것을 말할 때는 turn 이라고 쓰지 않는다 (glossary.md §4).

OrcaAgentExecutorFactory.withMemoryContextProvider(...)   ← 이미 있다 (읽기)
OrcaAgentExecutorFactory.withExecutionMemorySink(...)     ← 추가 (쓰기)

이름이 withMemoryIngestor 가 아닌 이유: 실행기는 peer 를 모른다. MemoryIngestor 를 그대로 받으면 실행기가 PeerView 를 만들어야 하고, 그것은 MemoryPeerResolver 가 이미 하는 일을 읽기 쪽과 쓰기 쪽에서 따로 하게 만든다. 그래서 실행기는 ExecutionMemorySink 를 받고, 기본 구현 (IngestingExecutionMemorySink)이 읽기와 같은 resolver 로 peer 를 풀어 MemoryIngestor 를 부른다 — 두 이음매가 서로 다른 peer 를 답하는 상태가 생기지 않는다. 실행기가 넘기는 값 (ExecutionMemoryUpdate)은 MemoryContextRequest 와 같은 모양(sessionId + principal)에 메시지가 붙은 것이다.

계약:

  • 실행이 끝난 뒤 그 실행에서 새로 추가된 메시지만 넘긴다 (전사 전체가 아니다 — 매번 전체를 보내면 같은 메시지를 N번 보낸다). 그 델타를 얻는 primitive 는 오늘 없다 — 아래를 먼저 읽는다
  • fire-and-forget. 수집 실패가 실행을 실패시키지 않는다. 도구 계약과 같은 정신이다
  • 레닥션 게이트를 반드시 경유한다 (§6.2)
  • 포크 수집은 기본 off 다. 포크에는 SessionId 가 없어 원격의 sessions/{s}/messages 에 실을 세션 이름이 없고, 포크의 대화가 부모 세션 peer 의 사실이라는 보장도 없다. 켤 수 있게 두되 켤 때는 invokingSessionId 를 세션 이름으로 쓴다

델타를 어떻게 얻는가 — 오늘 primitive 가 없고, compaction 이 인덱스를 무효화한다

IMPORTANT: 위 계약의 첫 줄("그 실행에서 새로 추가된 메시지만")은 오늘 만들 수 있는 방법이 없다.

  • OrcaAgentExecutionResult 의 접근자 열 개(:417-534)에 실행 단위 델타가 없다. getConversationHistory():441getSnapshot():429전체 이력이다
  • TranscriptBuffer 의 공개 API 에도 "이 실행이 추가한 것" 을 뽑는 메서드가 없다
  • 남는 방법은 실행 전후 size() 를 인덱스로 잡는 것인데, 실행 도중 이력이 통째로 교체된다OrcaAgentExecutor:2882scope.transcriptBuffer.replaceWith(recovered)(프롬프트 크기 복구)와 DefaultCompactionEngine:253memory.replaceWith(rebuilt)(컴팩션). 그 순간 인덱스는 무의미해진다

이 함정은 트리가 이미 만나서 풀어 둔 것이다. SessionTranscript:48-56SessionRewindPoint 를 전사 에 두는 이유를 이렇게 적는다(인용문은 :54-55) — "compaction rewrites the history through replaceWith, and a point that survived that would index into a history that no longer exists". 그래서 rewrite 가 그 점을 함께 버린다.

수집 워터마크도 같은 성질을 요구하므로 선택지는 둘이다.

방안 모양 대가
(a) SessionRewindPoint같은 기계 — 수집 워터마크를 전사 안에 두고 replaceWith 가 함께 버린다 이미 있는 패턴을 재사용. 구현이 작다 compaction 이 일어난 실행은 델타를 못 낸다. 그때 무엇을 보낼지(아무것도 / 컴팩션 요약 / 전체)를 정해야 한다
(b) 메시지 단위 id 워터마크 — 마지막으로 보낸 메시지 id 를 백엔드별로 기억한다 rewrite 에 견딘다 Message 에 안정적 id 가 필요하고, 컴팩션이 메시지를 합치면 그 id 가 사라진다

확정: (a) — 전사 안의 워터마크. (b) 는 오늘 만들 수 없다

Step 5 가 (a) 를 골랐다. 고른 이유는 취향이 아니라 (b) 가 성립하지 않기 때문이다 — at.aimon.core.llm.Message 에는 안정적인 id 가 없다(grep -n "getId" Message.javaToolUse.getId() 한 건만 반환한다). (b) 를 하려면 Message 에 id 를 더해야 하고, 그 타입은 전사에 영속되므로 세션 레코드의 와이어 포맷이 함께 넓어진다 — 메모리 설계가 건드리기로 한 범위가 아니고, frozen-names.md 가 지키는 경계 바로 옆이다.

구현은 트리의 선례를 그대로 복제한다. TranscriptBuffer.markIngestPoint() / messagesSinceIngestMark()beginTurn / rewindPoint같은 자리에서 세워지고 같은 자리에서 버려진다replaceWith 가 둘을 함께 버리고, clear() 도 그렇다. 워터마크는 영속하지 않는다: 한 실행 안에서 세워지고 한 실행 안에서 읽히므로 재시작이 복원할 것이 없고, 수명이 저장 한 번보다 짧은 값을 위해 와이어 포맷을 넓히는 것은 남는 장사가 아니다.

compaction 이 일어난 실행에서는 아무것도 보내지 않는다. 세 선택지의 대가를 나란히 놓으면 이것이 가장 싸다.

그때 보낼 것 대가
아무것도 (채택) 그 실행의 메시지가 메모리에 안 들어간다. 다음 실행이 다시 마크하고 스트림은 이어진다. compaction 은 세션당 드물게 일어나므로 손실은 실행 하나
컴팩션 요약 요약을 대화인 척 먹인다. 디라이버가 패러프레이즈의 패러프레이즈에서 사실을 뽑는다
전사 전체 이미 수집된 메시지를 다시 보낸다. 원격 백엔드가 중복 제거로 저장은 막아도 추출 LLM 호출 비용은 이미 발생한 뒤다 — 실패가 조용하고 비싸다

이 손실은 session-end 모드에는 적용되지 않는다. 그 모드는 델타를 쓰지 않고 세션이 닫힐 때 전사를 통째로 한 번 넘기므로, 델타 문제 자체가 없다. 그리고 그것이 CLI 의 기본값이다 — 델타의 대가를 지는 것은 execution-end 를 명시적으로 고른 배포뿐이다.

Step 5 의 인수 조건 ①("같은 메시지가 두 번 안 간다")은 이제 검증 가능한 문장이다: 마크는 실행마다 전진하고(연속한 두 실행의 델타가 겹치지 않는다), rewrite 된 실행은 빈 델타를 낸다.

workspace 와 observer 는 어디서 오는가 — per-caller 에서는 오지 않는다

IMPORTANT: MemoryIngestRequestworkspaceobserver 를 요구하는데, 쓰기 쪽에는 MemoryPeerResolver 의 대응물이 없다. 읽기는 MemoryContextRequest(sessionId + principal)를 MemoryPeerResolver 에 넘겨 peer 를 얻지만, 실행 종료 이음매에는 그 전략이 없다.

조립 모양 해석 방법 INGEST 능력
고정 peer (MemorySpec.forPeer) 조립 시점에 묶는다. CLI 가 오늘 하는 것과 같다 — buildMemoryFinalDerivation:1073-1081memoryWiring.workspace / .observer 를 클로저에 가둔다 켜진다
per-caller (MemorySpec.perCaller) 없다 꺼진다. memory-ingest degradation 이 오른다

per-caller 에서 불가능한 이유는 도구 쪽에서 이미 확인된 것과 같은 이유다 — MemoryAssembly:94-97"The enrichment info a ToolContextEnricher receives carries a session and an execution but no principal, so there is nothing to resolve a per-call observer from" 이라고 적어 둔 그 seam 이 실행 종료 이음매에도 그대로 없다. §5.2 가 도구에 대해 memory-tools degradation 을 올리는 자리에서 INGEST 도 같이 꺼진다고 말하지 않으면, 그 배포는 "메모리가 켜졌는데 아무것도 쌓이지 않는" 상태를 진단할 근거를 잃는다.

이 seam 을 넓히는 것 — 실행에 principal 을 실어 per-caller 쓰기를 가능하게 하는 것 — 은 이 설계의 범위 밖이다(§13).

CLI 의 종료 시점 수집은 삭제하지 않는다. 설정으로 고른다 (ingest: off | execution-end | session-end), 기본은 기존 동작인 session-end 다 — 기존 사용자의 LLM 호출 횟수가 조용히 늘지 않게 하려는 것이다. session-end 는 라이브 세션이 닫힐 때 전사를 한 번에 넘기며, CLI 에서는 그 순간이 지금의 TeardownPhase.MEMORY_FINAL_DERIVATION 과 같다. 원격 백엔드는 execution-end 를 권장값으로 문서화한다 — 그래야 Dyad 의 idle flush 와 ?wait=derive 가 비로소 의미를 갖는다.


8. 모듈 배치

8.1 새 모듈 셋 — 실현된 것은 하나뿐이다

이 절이 예상한 어댑터 모듈 둘은 이 저장소에 만들어지지 않았다. 원격 백엔드는 모듈이 아니라 저장소 하나로 실현되었다 — aimon-memory 서비스와 그 안의 aimon-memory-client(RemotePeerMemory). 아래 표는 계획과 실제를 나란히 둔 것이다.

계획한 모듈 성격 실제
aimon-memory-honcho Honcho v3 HTTP 어댑터 만들지 않았다. 원격 대상이 Honcho 가 아니라 자체 서비스가 되었다
aimon-memory-dyad Dyad v1 HTTP 어댑터 만들지 않았다. 같은 이유
aimon-memory-testkit 다섯 티어의 공유 계약 테스트 있다. 다만 배포된다 — 아래

바뀐 것은 어댑터의 소재지이지 이 문서의 본체가 아니다. 다섯 티어 SPI 는 그대로 원격 백엔드가 채우는 자리로 쓰였고, RemotePeerMemory 는 다섯 개를 전부 구현한다. 세 능력 신호가 false 인 것 (narrowsBySession · storesConfidence · 수신 확인의 derived)도 §3.3 이 그 자리를 만들어 둔 이유 그대로다 — 손실을 조용히 삼키는 대신 호출 전에 읽히게 한 것이다.

HTTP 클라이언트에 관한 아래 판단은 그 저장소에서도 유효하지만, 이제 이 빌드의 결정이 아니다 — 코어에 이미 선례가 있고(skill.hook.declarative.HttpActionExecutor), OkHttp 를 끌어오면 어댑터 하나 때문에 배포 의존성이 늘어난다. 스트리밍(SSE)에 새 reactive 스택을 도입하지 않는다는 금지 (peer-memory.md §6.2)도 그대로다.

aimon-memory-testkit 은 배포된다 — 여기서 선례가 갈렸다. 원래는 aimon-filesystem-testkit / aimon-session-testkit 을 그대로 따라 미배포였다. 그 셋의 처지가 달라진 이유는 하나다: 앞의 둘은 모든 구현이 이 저장소 안에 있는 계약을 기술하므로 미배포 모듈이 전부에 닿지만, 이 스위트의 대상은 PeerMemory 백엔드이고 계약이 가장 필요한 구현이 다른 저장소에 있다. 미배포로 두면 계약이 쓰인 바로 그 백엔드가 그것을 돌릴 수 없는 유일한 백엔드가 된다 — 스위트가 막으려던 실패 모드 그 자체다.

이 스위트가 "두 백엔드가 같은 답을 한다" 를 확인할 수 있는 유일한 장치다 — 다섯 티어의 계약 (빈 결과 vs 예외, 예산 초과 시 truncated, 세션 없는 질의의 의미, 레닥션 통과 전제)을 한 곳에 적고 세 구현이 그것을 돈다. 그중 넷은 능력 협상 자체의 계약이다.

  1. "내놓은 티어는 답한다 — UnsupportedOperationException 을 던지지 않는다." §4.1 이 semanticSearch 를 못 하는 스토어에 대해 적은 계약이 여기서 강제된다
  2. "SEARCH 결과는 언제나 관련도 내림차순이다" — 점수를 못 내는 백엔드도 이것은 지킨다(§3.3)
  3. "ranksByScore()=false 인 백엔드는 minScore>0조용히 무시하지 않는다" — 거절하거나, storesConfidence() 처럼 호출 전에 읽혀야 한다
  4. "narrowsBySession()=false 인 백엔드는 세션 id 를 조용히 무시하지 않는다" — 3번과 같은 규칙을 질의의 다른 좁힘 축에 적용한 것이다. 둘을 한 항으로 합치지 않고 나눠 적는 이유는 testkit 이 각각에 케이스를 갖고, 백엔드가 한쪽만 지키는 것이 실제로 가능하기 때문이다

at.aimon.core.memory.fileInMemory*Store 는 여기에 참여하지 않는다 — 그들은 티어가 아니라 저장소를 구현하기 때문이다. 그것들이 계약을 도는 것은 StoreBackedPeerMemory 를 통해서다.

8.2 settings.gradle.kts 와 BOM

실제로는 줄이 늘지 않고 줄었다 — 세 모듈이 빠지고 testkit 만 남았다. 아래 BOM 에 관한 설명은 방향과 무관하게 그대로 맞았고, testkit 이 aimon.publishable 을 적용하자 손대지 않은 BOM 이 자동으로 그것을 관리하기 시작했다.

  • settings.gradle.ktsinclude(...)project(...).projectDir 양쪽에 세 줄씩 추가한다
  • BOM 은 손대지 않는다. aimon-bomcom.vanniktech.maven.publish 플러그인을 적용한 서브프로젝트를 구성(configuration) 시점에 열거해서 constraint 를 만든다 (publishedProjects()evaluationDependsOn 으로 형제 프로젝트를 먼저 평가시킨다). 어댑터 둘은 aimon.publishable 을 적용하고 gradle.propertiesPOM_ARTIFACT_ID 등을 선언하면 자동으로 들어가고, testkit 은 적용하지 않으므로 자동으로 빠진다. BOM 의 자체 검증 태스크가 "플러그인만 적용하고 좌표를 선언하지 않은" 실수를 잡는다
  • 모듈 이름의 접미사가 뜻하는 것이 바뀐다 — 지금까지 aimon-memory-<X>X 는 저장소 기술 (file/postgres/mongodb)이었으나 앞으로는 저장소 기술 또는 원격 서비스다. 사용자가 고르는 축(memory.backend)이 하나이므로 접두어를 나누지 않는 편이 낫다 — 다만 이 사실을 메모리 사용 가이드 §9 의 백엔드 표에 명시한다

8.3 의존성 방향

                          aimon-core
   (at.aimon.core.memory — 다섯 티어 SPI + StoreBackedPeerMemory
    + InMemory*Store + at.aimon.core.memory.file : 저장소 구현)
                              │  at.aimon.core.memory.PeerMemory
                    ┌─────────┴──────────┐
                    │ aimon-memory-client │   RemotePeerMemory (다섯 티어 구현)
                    └─────────┬──────────┘
                              │  HTTP
                    ┌─────────┴──────────┐
                    │  aimon-memory 서비스 │   store · worker · engine · api
                    └────────────────────┘

저장소 구현(왼쪽에서 코어 안으로 들어왔다)과 원격 백엔드는 서로 다른 것을 구현하며 서로를 모른다. 이것이 §4.1 의 격하가 그림으로 보이는 자리이고, 화살표가 하나뿐인 것이 §3 의 "seam 은 PeerMemory 하나" 라는 주장이다.


9. 설정 표면

9.1 MemorySpec (aimon-bootstrap)

두 진입점(forPeer / perCaller)과 peer 결정의 강제 선택은 그대로 둔다 — 그 결정은 백엔드와 무관하고, 그것을 없애면 "식별된 요청은 A 의 메모리를, 익명 요청은 B 의 메모리를 읽는" 상태가 다시 열린다.

바뀌는 것은 재료 하나다.

// 새 경로 — 백엔드를 통째로 준다
MemorySpec.forPeer(workspace, peer)
        .peerMemory(backend)              // ← 추가
        .injectionMode(SUMMARY_ONLY)
        .maxTokens(0)
        .redactionPolicy(policy)
        .build();

// 기존 경로 — 그대로 동작한다 (내부에서 StoreBackedPeerMemory 로 접힌다)
MemorySpec.forPeer(workspace, peer)
        .representationStore(reps)
        .observationStore(obs)
        .build();

peerMemory(...) 와 스토어 setter 는 상호 배타이며 build() 가 거절한다. AimonStackSpec 이 이미 MemorySpec + ExecutorSpec.memoryContextProvider 동시 지정을 같은 방식으로 거절하고 있으므로 새 관례가 아니다.

dialecticEngine 을 담을 자리는 따로 만들지 않는다PeerMemory 가 이미 그것을 티어로 들고 있으므로 peerMemory(...) 경로에서는 CHAT 이 자동으로 들어온다. §5.3 의 구멍(스타터에서 MemoryChat 이 등록되지 않는 것)은 그렇게 메워진다.

기존 두 불변식은 넓혀야 한다. MemorySpec:79-89 는 오늘 두 가지를 던지는데, 둘 다 PeerMemory 경로를 모른다.

오늘의 검사 peerMemory(...) 경로에서 무슨 일이 나는가 처분
스토어가 하나도 없으면 "A memory spec needs at least one store" peerMemory 만 준 스펙이 여기 걸린다 조건을 "스토어 또는 PeerMemory" 로 넓힌다
per-caller 인데 representation store 가 없으면 "Per-caller memory needs a representation store" per-caller + peerMemory여기 걸린다 조건을 "per-caller 는 SNAPSHOT 능력을 요구한다" 로 바꾼다 — 뜻은 같고 표현만 티어 어휘로 옮긴 것이다
AimonStackSpec:122-130MemorySpec(representation store 있음) + ExecutorSpec.memoryContextProvider 동시 지정을 거절 가드가 getRepresentationStore().isPresent() 에 걸려 있어 peerMemory 만 준 스펙에는 발화하지 않는다. 그런데 §5.2 는 SNAPSHOT 능력이 있으면 SnapshotMemoryContextProvider 를 설치하므로 주입 provider 가 둘이 되고 하나가 조용히 버려진다 — 그 가드의 주석이 서술하는 실패 그대로다 조건을 "MemorySpec 이 주입 provider 를 만들 수 있으면(representation store 또는 SNAPSHOT 능력)" 으로 넓힌다

세 번째 행은 CLI 에는 닿지 않는다 — §5.0 대로 CLI 가 ExecutorSpec.memoryContextProvider 를 안 쓰게 되기 때문이다. 노출되는 것은 AimonStackSpec 을 직접 조립하는 애플리케이션이다. 그래서 Step 4 의 인수 조건 ③("상호 배타 규칙을 고치지 않고 통과한다")은 CLI 에 대해서만 참이며, 가드 자체를 넓히는 것은 Step 3 의 일이다.

세 메시지의 의도는 그대로 유지된다 — "설정된 것처럼 보이는데 아무것도 배선하지 않는 스펙" 을 거절하는 것. Step 3 의 인수 조건 "기존 조립 경로 동작 불변" 은 이 변경을 덮지 못하므로 별도 조건으로 적는다.

9.2 CLI — memory 블록

IMPORTANT: CLI 의 키는 camelCase 다. MemoryConfig 의 필드가 전부 @JsonProperty("workspaceId") · ("peerId") · ("peerName") · ("storagePath") · ("backend") · ("reconcilerEnabled") 로 선언되어 있고 네이밍 전략 설정은 없다. kebab-case 는 스타터 프로퍼티 (aimon.memory.workspace-id)의 규약이며 두 표면은 섞이지 않는다.

이 문서의 앞선 개정은 kebab 을 적은 것을 정정하면서 그 정정의 근거를 또 틀렸다. 초안 2 는 "세 필드가 null 이 되어 MemoryConfig.isEnabled() 가 false 를 돌려주고 메모리가 경고 없이 꺼진다" 고 적었는데, 실제로는 부팅이 실패한다. 프로젝트가 쓰는 jackson 2.22.2 + snakeyaml 로 같은 모양을 실행해 확인했다.

Unrecognized field "workspace-id" (class T$M), not marked as ignorable
  (3 known properties: "workspaceId", "peerId", "storagePath")
→ com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException

CliConfigLoader:36new ObjectMapper(new YAMLFactory()) 를 그대로 쓰고 FAIL_ON_UNKNOWN_PROPERTIES 를 끄지 않으며, CLI 설정 클래스 어디에도 @JsonIgnoreProperties 가 없다(0건). UnrecognizedPropertyExceptionJsonMappingException 의 하위이므로 CliConfigLoader:64-65 가 그것을 ConfigurationException("Invalid configuration structure in: …") 로 감싸 던진다 (:63JsonParseException 쪽의 "Invalid YAML syntax" 다). 즉 실패 모드는 조용한 축소가 아니라 시끄러운 정지다.

결론("CLI 는 camelCase 다")은 두 번 다 옳았고, 틀린 것은 두 번 다 근거였다. 이번에는 돌려서 확인했다.

memory:
  workspaceId: ops
  peerId: alice
  peerName: Alice
  backend: file              # file(기본) | in-memory | honcho | dyad
  storagePath: ~/.aimon/memory/representations.jsonl   # 필수. file 백엔드가 쓴다
  reconcilerEnabled: true                              # 기본 백엔드 전용
  dreamer:                                             # 기본 백엔드 전용
    enabled: true
  ingest: session-end        # off | session-end(기본, 기존 동작) | execution-end
  remote:                    # backend 가 honcho|dyad 일 때만 읽는다
    baseUrl: http://localhost:8000
    token: ${MEMORY_TOKEN}
    timeout: 10s
    observed: alice          # 관찰 대상 peer 이름. 미지정 시 peerId
    waitForDerivation: false # dyad 전용 (?wait=derive)

새로 더하는 키(ingest, remote.*)도 CLI 규약인 camelCase 로 맞춘다 — 이 문서는 CLI 설정 키의 개명을 제안하지 않는다. workspaceId · peerId · storagePath 세 필드가 모두 채워져야 메모리가 켜지는 현재 동작(MemoryConfig.isEnabled())도 그대로다. 원격 백엔드에서도 storagePath 가 필수로 남는 것은 오늘의 활성화 조건이 그렇기 때문이며, 그것을 푸는 것(원격일 때 storagePath 없이도 켜지게 하는 것)은 Step 9 에서 함께 처리한다 — isEnabled() 를 백엔드별로 갈라야 하므로 설정 표면 변경이다.

  • backend 의 기본값은 file 그대로다. 모르는 값이 file 로 떨어지는 현재 동작도 그대로 — 다만 오늘 그 경고는 관찰 스토어에서만 나온다(createObservationStore:967-970"unknown backend '...', falling back to file for observations" 를 내지만 createRepresentationStore:929-943in-memory 가 아니면 말없이 FileRepresentationStore 로 간다). 원격 값이 오타 났을 때 절반만 경고하는 것은 §5.2 가 없애려는 실패 모드이므로, Step 9 에서 양쪽을 맞춘다
  • remote 블록이 비었는데 backend 가 원격이면 부팅 실패. baseUrl 없는 HTTP 백엔드는 조용히 실패할 방법이 없다
  • 반대로 backendfile/in-memory 인데 remote 가 채워져 있으면 부팅 실패 — 스타터의 refuseOrphanedStores 와 같은 이유다. 설정했는데 안 읽히는 것이 가장 나쁘다
  • dreamer.enabled / reconcilerEnabled 가 원격 백엔드와 함께 켜져 있으면 부팅 실패 (§6.1). 두 키 모두 CLI 표면에만 있다 — 스타터의 AimonProperties.Memory 필드는 일곱 개(backend · workspaceId · peerMode · peerId · injectionMode · maxTokens · redaction)뿐이고 reconciler 프로퍼티는 아예 없다

9.3 스타터 — aimon.memory.*

변화
backend none(기본) · in-memory · supplied · honcho · dyad 값 두 개 추가. 기본값 불변
workspace-id 문자열 불변 — 백엔드 지정 시 필수
peer-mode / peer-id 불변
injection-mode / max-tokens 불변
redaction 불변 (기본 default) 원격 백엔드에서 none거절한다 (§6.2)
ingest off(기본) · execution-end · session-end 신규. 기본 off 가 오늘의 동작이다
honcho.base-url / .token / .timeout 신규
dyad.base-url / .token / .timeout / .wait-for-derivation 신규

supplied 는 뜻이 하나 늘어난다 — 애플리케이션이 PeerMemory 빈을 선언하거나, 기존처럼 스토어 빈을 선언한다. 둘 다 선언하면 거절한다. 스타터가 이미 갖고 있는 거절 규칙 셋(고아 스토어, 정책 선언 불일치, per-caller + observation store 단독)과 같은 문장 형태로 쓴다.

9.4 기존 사용자가 아무것도 안 고쳐도 그대로 도는가

경로
CLI yaml 그대로 돈다. 키는 camelCase 그대로(§9.2), 기본값 file, ingest 기본이 기존 동작(session-end)
스타터 프로퍼티 그대로 돈다. 기본값 none, ingest 기본 off
MemorySpec 프로그램 조립 그대로 돈다. 스토어 setter 유지
at.aimon.core.memory 를 직접 쓰는 코드 한 곳 깨진다RepresentationMemoryContextProvider 개명 (§11.1)
at.aimon.bootstrap.assemble 을 직접 쓰는 코드 한 곳 깨진다MemoryAssembly.CAPABILITY_WRITE_PATH 상수 (§11.1)
aimon-memory-file 사용자 그대로 돈다. JSONL 저장 포맷과 파일 레이아웃은 불변이고, 바뀐 것은 좌표(aimon-core 에 흡수)와 패키지 이름뿐이다 (§4.3)
aimon-memory-{mongodb,postgres} 사용자 깨진다 — 이전이 아니라 제거다. 두 모듈은 더 없고, 쌓인 데이터는 아무 데로도 옮겨가지 않는다 (§4.3, §11.4)
종료 순서에 의존하는 배포 바뀐다TeardownPhase 의 메모리 블록이 SESSIONS 뒤로 옮겨진다 (§3.6, §11.3)

10. 라이선스 경계

이 절은 가정법으로 읽는다. Honcho 어댑터는 만들어지지 않았고(§8.1), 원격 백엔드는 우리가 쓴 저장소다 — 그쪽에는 AGPL 원본이 아예 없다(그 저장소의 docs/adr/0005-agpl-boundary.md). 아래 경계는 언젠가 AGPL 서버용 어댑터를 쓸 때 다시 적용될 규칙으로 남긴다. 마지막 문단의 "aimon-memory-honchopackage-info.java 에 요약한다" 는 적용 대상이 없으므로 지금 할 일이 아니다.

대상 라이선스 이 설계에서의 취급
Honcho 서버 (src/, FastAPI) AGPL-3.0 이 저장소에 들어오지 않는다. 운영자가 자기 인프라에 띄운다
Honcho SDK (sdks/) · OpenAPI 스펙 Apache-2.0 aimon-memory-honcho 가 참조하는 유일한 자료
Dyad 별도 저장소(/Users/kangwoo/Workspaces/research/memory/dyad), 자체 클린룸 구현 서버는 그쪽에 남고 여기에는 HTTP 어댑터만
aimon-memory-honcho 이 저장소의 라이선스 아래 근거로 AGPL 전염되지 않는다

HTTP 클라이언트는 파생저작물이 아니다. AGPL-3.0 §13(네트워크 상호작용 조항)은 개작된 프로그램을 네트워크로 서비스하는 자에게 소스 제공 의무를 지운다. 그 프로그램을 이용하는 클라이언트는 개작물이 아니며 §13 의 대상도 아니다. 어댑터가 참조하는 것은 인터페이스 명세(OpenAPI)와 Apache-2.0 SDK 뿐이다.

IMPORTANT: 다음을 하면 경계가 깨진다.

  • Honcho 서버의 프롬프트 문자열을 옮기는 것 — dialectic 시스템 프롬프트, deriver 추출 프롬프트, 표현 렌더 포맷의 문구
  • 서버의 알고리즘 코드를 옮기는 것 — context() 의 토큰 예산 분배식, 중복 제거 유사도 임계값 로직
  • 어댑터 개발 중 서버 소스를 열어 놓고 대조하는 것 (Dyad 의 ADR 0005 가 같은 이유로 원본 저장소를 작업 트리에서 배제했다)

어댑터가 해야 하는 것은 요청을 만들고 응답을 파싱하는 것뿐이며, 그 둘은 OpenAPI 스펙이 전부 기술한다. 이 문단을 aimon-memory-honchopackage-info.java 에 요약해 둔다 — 문서에만 적힌 경계는 다음 사람이 읽지 않는다.

배포 형태의 함의도 적어 둔다: AIMON 을 SaaS 로 제공하면서 그 뒤에 Honcho 서버를 띄우면, Honcho 를 개작하지 않는 한 §13 의무는 발생하지 않는다. 개작하면 발생한다 — 그 판단은 운영자의 것이고 이 문서는 경계만 표시한다.


11. 호환성과 마이그레이션

api-stability.md0.x 약속: 마이너 올림은 깰 수 있고, 변경은 CHANGELOG.md 에 전부 기록하며, 개명은 rename-maps.md 에 표를 더한다.

11.1 깨는 이름 변경 — 둘이다

초안은 "정확히 하나" 라고 적었으나 공개 상수 하나를 빠뜨렸다.

모듈 옛 이름 새 이름 이유
aimon-core RepresentationMemoryContextProvider SnapshotMemoryContextProvider 원격 백엔드에는 Representation 이 없다. 이름이 그 위에 선 것을 잘못 말한다
aimon-bootstrap MemoryAssembly.CAPABILITY_WRITE_PATH (값 "memory-write-path") MemoryAssembly.CAPABILITY_INGEST (값 "memory-ingest") 쓰기 경로가 하나의 능력(INGEST)으로 이름을 얻었다(§5.2)

둘 다 rename-maps.md 에 들어간다. 두 번째가 공개 API 인 근거: aimon-bootstrap/build.gradle.ktsid("aimon.publishable") 을 적용하므로 at.aimon.bootstrap.assemble 은 배포 모듈의 공개 패키지다 (api-stability.md §2"그 외 모듈의 at.aimon.<module> | 해당 모듈의 공개 API"). 상수 이름뿐 아니라 도 바뀐다. 그 값이 로그 문구가 아니라 stack.degradations().has(...)조회되는 키이기 때문에 공개 API 로 취급한다 — 다만 근거를 정확히 적어야 한다: 트리 안에서 "memory-write-path" 리터럴은 선언부(MemoryAssembly:37) 한 곳뿐이고, 인용되는 두 테스트 (AimonStackBuilderTest:658,685 · MemoryAssemblyTest:101,139)는 전부 상수 MemoryAssembly.CAPABILITY_WRITE_PATH 를 참조한다. 즉 트리 안에서는 이름과 값을 함께 바꿔도 소스 호환이며, 깨지는 것은 리터럴로 조회하는 외부 코드다. 그 가능성 때문에 공개 API 로 세는 것이지, 트리 안에 증거가 있어서가 아니다.

degradation 키는 동결 대상인가 — 아니다

api-stability.md §4 의 동결 대상은 영속 필드·테이블·채널·와이어 키다. degradation 키는 프로세스가 살아 있는 동안만 존재하고 저장되지 않으므로 그 목록에 들지 않는다. 다만 프로그램이 조회하는 값이므로 로그 문구와 달리 공개 API 로 취급하고 여기에 적는다.

새로 생기는 키는 넷이다 — memory-snapshot · memory-search · memory-chat · memory-observe (§5.2). 기존 memory-toolsmemory-redaction 은 이름과 값이 그대로다.

이 프로젝트의 관례대로 어댑터를 남기지 않고 한 번에 옮긴다 — 0.x 에서는 지키지 않을 유예 기간을 표시하는 것보다 정직하다(§5 of api-stability).

11.2 깨지지 않는 것 — 그리고 왜

표면 판정
ObservationStore / RepresentationStore / WorkspaceStore 불변. 시그니처 한 줄도 안 바뀐다
aimon-memory-{file,mongodb,postgres} 불변. DDL·컬렉션·키 prefix 전부 동결 (§4 of api-stability)
MemoryContextProvider / MemoryContextRequest / MemoryPeerResolver 불변
DialecticEngine / DialecticQuery / DialecticResponse 불변
도구 4종의 TOOL_NAME · ToolContext 불변
도구 4종의 입력 스키마 기존 사용자에게는 불변. 단 한 자리가 백엔드에 따라 달라진다 — ObserveToolconfidence 파라미터는 백엔드가 storesConfidence()=false 일 때 스키마에서 빠진다(§2.2). 기본 백엔드는 true 이므로 오늘 도는 배포의 스키마는 그대로다
도구 3종생성자 바뀐다. 스토어를 받는 생성자가 사라지고 남는 생성자는 전부 티어를 받는다. 스토어 경로는 이름 있는 팩토리(MemoryRecallTool.overStore(...) 등)다. RedactionPolicy 유무 오버로드는 그대로 — 모호했던 축이 아니다. MemoryChatToolDialecticEngine 을 받으므로 애초에 잃을 스토어 생성자가 없다 — 아래 참조
OrcaMemoryToolProvider 생성자 바뀐다. 내부 패키지(agent.impl.orca.tool)이므로 공개 API 가 아니다 (§2 of api-stability)
MemorySpec 순수 추가

스토어 생성자를 편의 생성자로 남기려던 계획은 폐기했다. 남기면 스토어와 티어가 서로의 오버로드가 되는데, 이것은 두 가지를 뜻한다. 첫째로 null 리터럴을 넘기는 호출자에게 컴파일이 모호해진다. 둘째가 더 중요하다 — 오버로드는 "이 둘은 바꿔 넣을 수 있는 것" 이라고 읽히고, 그것이 정확히 이 이음매가 부정하려는 명제다. SnapshotMemoryContextProvider.readerOver(...) 는 같은 이유로 이미 이름 있는 팩토리를 골랐으므로(§11.1), 스토어 생성자를 가진 도구 셋만 반대 선택을 하면 한 PR 안에서 규칙이 갈린다. 고도는 오버로드 해석이 아니라 이름이 말한다.

11.3 이름이 아니라 동작이 바뀌는 것

TeardownPhase 선언 순서

§3.6 이 메모리 phase 블록을 CHECKPOINTS 뒤로 옮긴다. TeardownPhaseat.aimon.bootstrap 의 공개 enum 이고 TeardownRegistry#closeAll()선언 순서로 훑으므로, 이것은 개명이 아니라 종료 순서의 동작 변경이다 — rename-maps.md 가 아니라 CHANGELOG.md 항목이다. ordinal() 에 의존하는 코드는 트리 안에 없으나(TeardownPhase 는 키로만 쓰인다) 외부 코드가 그럴 수 있으므로 적어 둔다.

바뀌는 관찰 가능한 동작: 종료 시 세션 드레인과 체크포인트 저장이 메모리 최종 derivation 보다 먼저 끝난다. CLI 에서는 마지막 관찰이 더 완전한 전사 위에서 만들어지므로 개선이고, 그 외 배포에서는 §7 의 두 수집 모드가 비로소 살아 있는 백엔드를 본다.

ObservationType 확장

ObservationType 을 4값으로 넓히면(§2.2) 다운그레이드가 깨진다INDUCTIVE/CONTRADICTION 이 저장된 뒤 옛 jar 로 되돌리면 valueOf 가 던진다. 이것은 개명이 아니라 값 추가이므로 rename-maps.md 가 아니라 CHANGELOG.md 항목이다. 완화책은 두 가지이며 둘 다 하지 않는다:

  • 저장 시점에 옛 두 값으로 접기 → 그러면 넓힌 의미가 없다
  • 미지의 값을 DEDUCTIVE 로 읽는 관대한 valueOf → 옛 jar 를 고쳐야 하므로 불가능하다

11.4 데이터 마이그레이션은 하지 않는다

기본 백엔드 ↔ Honcho ↔ Dyad 사이의 데이터 이전은 이 설계의 범위 밖이다(§13). 백엔드를 바꾸면 새 백엔드는 비어 있는 상태에서 시작하고, 그 사실을 기동 시점에 말한다. 이전이 필요해지면 Observe 티어를 쓰는 일회성 도구를 별도로 만든다 — 세 대상 모두 결론 직접 주입 API 를 갖고 있으므로 가능은 하다.


12. 단계별 구현 계획

각 단계는 독립적으로 머지 가능하며, 단계가 끝난 시점에 트리가 초록이고 사용자 관찰 동작이 명시된 것 외에는 바뀌지 않는다.

Step 내용 끝났을 때 관찰되는 변화 인수 조건
1 다섯 티어 인터페이스 + 값 객체 + PeerMemory + MemoryCapability + StoreBackedPeerMemoryat.aimon.core.memory 에 추가 없음 (새 타입만) ① ArchUnit: 티어 SPI 가 *Store 를 시그니처에 언급하지 않는다 ② MemoryArchitectureTest 격리 규칙 배열에 다섯 개 추가 ③ 요청 객체 형태를 강제하는 새 ArchUnit 규칙 — 신규 네 티어에 한정하고 DialecticEngine 은 제외한다(§3.4) ③′ PeerMemorycapabilities() 류의 능력 집합 메서드가 없다(§3.2) ④ peer-memory.md §1 의 비목표 첫 줄을 같은 커밋에서 철회하고 이 문서를 링크한다(§0.1) ⑤ 같은 커밋에서 peer-memory.md §10.3 의 kebab yaml 예제를 camelCase 로 고치고 "경고와 함께 폴백" 문장을 정정한다(§0.2) — 다른 단계에 의존하지 않고, 지금 그 예제가 읽는 사람을 부팅 실패로 보낸다
2 ObservationType 4값 확장 + 도구 4종·컨텍스트 provider 를 티어 위로 재배선. RepresentationMemoryContextProviderSnapshotMemoryContextProvider 개명 동작 동일. 이름 하나 바뀜 (기본 백엔드는 storesConfidence()=true 이므로 스키마도 그대로다) ① 기존 메모리 테스트 전부 통과 ② MemoryRecallToolobservationsAvailable=false 를 렌더한다(§3.3) ③ storesConfidence()=false 일 때 ObserveTool 의 스키마에서 confidence 가 빠지고 세 도구가 그 값을 찍지 않는다(§2.2) ④ MemoryHit.confidenceAvailableMemorySnapshot.confidenceAvailable 이 각각 MemorySearchTool · MemoryRecallTool 의 렌더를 가른다(§2.2) ⑤ rename-maps.md · CHANGELOG.md 갱신
3 MemorySpec.peerMemory(...) + MemoryAssembly 의 능력 기반 등록 + degradation 키 재편 + RedactingPeerMemory(§6.2) 스타터의 supplied + PeerMemory 빈 경로에서 MemoryChat처음으로 등록된다. in-memoryDialecticEngine 빈이 없으므로 memory-chat degradation 이 오른다 (§5.3) ① degradation 문구가 백엔드 id 를 포함 ② 기존 조립 경로 동작 불변 ③ MemorySpec 의 두 불변식을 PeerMemory 경로까지 넓힌다(§9.1) ④ CAPABILITY_WRITE_PATHCAPABILITY_INGESTrename-maps.md 에(§11.1) ⑤ 감싸지 않은 PeerMemory 가 스택에 들어가는 경로가 없고, 감싸는 대상은 INGEST·OBSERVE·SEARCH·CHAT 넷이다(SNAPSHOT 제외 — 나가는 자유 텍스트가 없다, §6.2. Step 1 ③ 의 "신규 네 티어" 와는 다른 넷이다 — 그쪽은 DialecticEngine 을 뺀 집합이다) ⑥ peer-memory.md §14.2 의 레닥션 금지 항목을 넓힌다(§6.2) ⑦ AimonStackSpec:122-130 의 상호 배타 가드를 SNAPSHOT 능력까지 넓힌다(§9.1 표의 세 번째 행) — 넓히지 않으면 peerMemory + ExecutorSpec.memoryContextProvider 조합에서 주입 provider 가 둘이 되고 하나가 조용히 버려진다
4 CLI 이주AgentSetupFactory 의 메모리 배선 아홉 개를 MemorySpec/MemoryAssembly 경로로 옮긴다 (§5.0) CLI 관찰 동작 불변 (아직 file/in-memory 뿐) grep MemorySpec modules/aimon-cli/src/main 이 0건이 아니게 된다 ② registerCliToolsConsoleOutputTool 만 남는다 ③ AimonStackSpec:122-130 상호 배타 규칙을 CLI 측에서는 고치지 않고 통과한다 (가드 자체를 넓히는 것은 Step 3 이다 — §9.1) ④ dreamer·maintenance 는 CLI 소유로 남는다
5 MemoryIngestor 실행기 이음매 + ingest 설정 + TeardownPhase 메모리 블록 이동(§3.6) CLI 기본은 session-end = 기존 동작 §7.2 의 델타 방안 (a)/(b) 중 하나를 먼저 확정한다(§15-8) — 이것 없이는 "같은 메시지가 두 번 안 간다" 가 검증 불가다 ② 실행기 이음매가 받는 MemoryIngestorStep 3 의 데코레이터를 지난 것이다 (감싸지 않은 것을 직접 꺼내 쓰는 경로가 없다) ③ phase 이동 전후로 CLI 최종 derivation 이 같은 수의 관찰을 만든다 ④ per-caller 에서 INGEST 가 꺼지고 degradation 이 오른다(§7.2)
6 aimon-memory-testkit — 다섯 티어 계약 스위트. 기본 백엔드를 통과시킨다 없음 (테스트 전용) 계약이 문서가 아니라 코드로 존재. 특히 능력 협상 4계약(§8.1) — ① 내놓은 티어는 UnsupportedOperationException 을 던지지 않는다 ② SEARCH 결과는 언제나 관련도 내림차순이다 ③ ranksByScore()=falseminScore>0 을 조용히 무시하지 않는다 ④ narrowsBySession()=false 가 세션 id 를 조용히 무시하지 않는다
7 aimon-memory-dyad 하지 않았다. 원격 백엔드는 이 빌드의 어댑터 모듈이 아니라 별도 저장소의 서비스로 실현되었다 — aimon-memoryaimon-memory-client(RemotePeerMemory)가 다섯 티어를 채운다. ?wait=derive 는 그 서비스의 수집 엔드포인트에 그대로 있다
8 aimon-memory-honcho 하지 않았다. 같은 이유. Honcho 는 소비 대상이 아니라 §2 의 비교 대상으로만 남았다
9 CLI/스타터 설정 표면 + 문서 갱신 yaml/프로퍼티로 원격 선택 가능 ① §9 의 거절 규칙 전부 테스트 ② 원격 백엔드에서 storagePath 를 요구하지 않도록 MemoryConfig.isEnabled() 를 가른다(§9.2) ③ 미지 backend 경고를 표상·관찰 양쪽에서 낸다(§9.2) ④ memory-usage-guide.mdmemory-usage-guide.en.md 를 같은 커밋에서 갱신하고 source_commit 을 맞춘다CLAUDE.md 의 Module Structure 목록에 두 모듈(aimon-memory-honcho · -dyad) 추가반대로 되었다. 그 목록에서 aimon-memory-{file,mongodb,postgres} 세 줄이 빠지고 aimon-memory-testkit 한 줄이 들어갔다. testkit 을 싣지 않는다던 관례는 여기서 깨지는데, 이유가 있다: 다른 두 testkit 과 달리 이것은 배포되는 모듈이고, 목록이 답하는 질문("이 빌드가 무엇을 내놓는가")에 대해 이제 답의 일부다

Step 4(CLI 이주)를 Step 5(수집) 앞에 두는 이유: 수집 이음매는 MemoryIngestor 를 스택에 꽂아야 하는데, CLI 가 MemorySpec 을 지나지 않는 동안에는 꽂을 자리가 CLI 에 없다. 순서를 뒤집으면 Step 5 가 CLI 전용 배선을 한 번 만들고 Step 4 에서 다시 지우게 된다.

RedactingPeerMemory 는 Step 5 가 아니라 Step 3 에 둔다. Step 3 이 peerMemory(...) 를 여는 순간부터 감싸지지 않은 백엔드가 스택에 들어갈 수 있으므로, 데코레이터가 그보다 뒤에 오면 Step 3~4 구간에서 §6.2 의 보장이 성립하지 않는다. 그 구간에 원격 어댑터가 아직 없다는 것은 사실이지만 (Step 7 부터다), 각 단계가 독립적으로 머지 가능하다는 이 절의 약속에 기대면 "다음 단계가 곧 채운다" 를 근거로 쓸 수 없다. 데코레이터는 PeerMemoryRedactionPolicy 외에 의존이 없으므로 당기는 데 비용도 없다.

Step 7 을 8 보다 먼저 둔 이유는 이랬다: Dyad 는 우리가 소스를 갖고 있어 계약이 어긋났을 때 어느 쪽이 틀렸는지 확인할 수 있다. Honcho 는 그럴 수 없으므로 testkit 이 안정된 뒤에 붙이는 편이 싸다. 그 논증은 실현된 경로에서 더 강하게 성립했다 — 원격 백엔드의 소스를 갖고 있는 정도가 아니라 같은 사람이 쓴 저장소이므로, 계약이 어긋나면 어느 쪽이든 고칠 수 있다. 그래서 Step 9 의 인수 조건 중 남은 것은 설정 표면 쪽이지 어댑터 쪽이 아니다.

문서 갱신은 Step 9 에만 있는 것이 아니다. peer-memory.md 는 세 곳이 고쳐진다 — Step 1 에서 두 곳 (§1 비목표 철회, §10.3 의 kebab yaml 과 "경고와 함께 폴백" 정정), Step 3 에서 한 곳(§14.2 레닥션 금지 확장). rename-maps.md 는 Step 2 와 Step 3 에서 각각 한 행씩 늘어난다. 두 파일 모두 .en.md 가 없지만 이유는 다르다peer-memory.md 가 사는 design/ 은 번역 대상 아님(결정)이고, rename-maps.md 가 사는 migration/아직 아님(우선순위로 미룬 것)이다 (docs/README.md 의 번역 대상 표). 결론은 같다: .en.md 갱신이 필요한 것은 features/memory/ 하나뿐이다.

후속 (이 설계의 범위 밖, 전제조건이 붙는 것)

항목 전제조건
deriver/dreamer/reconcilerat.aimon.core.memory.impl 로 이동 CLI 가 직접 조립하는 구체 클래스 13종을 코어 조립 팩토리 뒤로 먼저 옮겨야 한다 (§4.2)
프로버넌스 티어 (source_ids → 원문 역추적) 기본 백엔드에 대응 구현이 없다. Dyad 전용 능력을 SPI 로 올릴지는 소비자가 생긴 뒤
DialecticEngine.queryStream 진짜 스트리밍 지금 default 구현은 답변을 한 청크로 흘린다. 원격 SSE 를 붙일 때 함께 본다
per-caller 쓰기 (ToolContextEnricher/실행 이음매에 principal 을 싣는 것) 실행 정체성 전달 경로를 넓히는 것이므로 메모리 밖의 변경이다. 그때 memory-toolsmemory-ingest 두 degradation 이 함께 사라진다 (§7.2)
백엔드 간 데이터 이전 도구 위 전부

13. 비목표

  • 외부 SDK / 스키마 호환. peer-memory.md §1 의 두 번째 비목표는 그대로 유지된다(§0.1). AIMON 은 Honcho 의 와이어 포맷을 흉내 내지 않고, 그 SDK 를 재수출하지도 않으며, Observation 을 원격 스키마에 맞추지도 않는다 — §2.2 의 ObservationType 확장은 우리 어휘의 손실을 없애는 것이지 스키마를 맞추는 것이 아니다
  • per-caller 배포에서의 쓰기. 실행 이음매에 principal 이 실리지 않는 한 INGEST 는 per-caller 에서 꺼진다(§7.2). 그 seam 을 넓히는 것은 메모리 밖의 변경이다
  • 데이터 마이그레이션. 백엔드를 바꾸면 새 백엔드는 빈 상태에서 시작한다 (§11.4)
  • Honcho 와의 와이어 호환. AIMON 이 Honcho 의 API 를 흉내 내는 서버가 되지 않는다. 우리는 클라이언트다
  • Dyad 서버를 이 저장소로 가져오는 것. 어댑터만 온다. Dyad 는 자기 저장소에서 자기 수명으로 산다
  • Honcho 의 scope · webhook · peer card 를 1급으로 올리는 것. 세 대상 중 하나에만 있는 것을 SPI 에 올리면 나머지 둘의 구현이 전부 빈 구현이 된다. 필요해지면 PeerMemory 에서 백엔드별 확장 인터페이스로 캐스팅하는 별도 경로를 연다
  • source_ids 추론 트리를 AIMON 도메인에 도입하는 것. Observation.sourceMessageIds 는 메시지까지만 가리키고 결론 간 전제 관계를 갖지 않는다. 넓히려면 Reconciler·Dreamer 가 함께 바뀐다
  • 기본 백엔드의 KnowledgeStore 위임을 바꾸는 것. D2 는 그대로다
  • MemoryChatTool 의 스트리밍 노출. 도구는 한 번에 한 결과를 돌려준다. SSE 는 어댑터 내부에서 소비되어 하나의 답변으로 접힌다
  • 관측성 배선. peer-memory.md §14.1 의 미해결 항목이며 백엔드 교체와 독립이다

14. 기각한 대안

# 대안 왜 기각했나
A1 ObservationStore / RepresentationStore 를 HTTP 로 구현한다 (가장 뻔한 길) §1.3 의 표가 답이며, 기각 사유는 LSP 일반론이 아니다 — 이 인터페이스는 semanticSearch 자리에서 이미 UnsupportedOperationException 을 규범으로 허용한다(당시 사례: MongoObservationStore, PostgresObservationStore둘 다 이후 제거되었고, 규범은 IndexedObservationStore 의 javadoc 에 남아 있다). 차이는 복원 가능성이다: 그 예외는 IndexedObservationStore 가 흡수하지만, merge · findByConfidenceBelow · purgeSoftDeletedBefore · RepresentationStore.save복원할 대상이 원격에 없어서 데코레이터로도 되살아나지 않고 Dreamer·Reconciler 의 호출 지점에서 터진다. 게다가 Representation(구조화 애그리게이트)을 만들려면 원격이 준 렌더 문자열을 되파싱해야 한다
A2 MCP 서버로 붙인다 — Honcho/Dyad 를 MCP 도구로 노출 프롬프트 자동 주입이 불가능하다. 주입은 모델이 부를지 말지 정하는 것이 아니라 실행기가 매 실행마다 무조건 하는 것이고, 그게 MemoryContextProvider 가 존재하는 이유다. 또 능력 협상이 없고(도구는 있거나 없거나뿐), 레닥션 게이트가 MCP 경계 밖에 남는다. CHAT 티어에 한해서는 유효한 보완 경로이며 배제하지 않는다
A3 뚱뚱한 인터페이스 하나 + default 메서드가 UnsupportedOperationException A1 과 같은 이유로 "예외 자체가 금지" 라고는 말하지 않는다. 기각 사유는 판단 시점이다 — 능력을 예외로만 표현하면 도구 등록 결정이 조립 시점에서 런타임 실패로 밀리고, 모델은 매 실행마다 없는 도구를 다시 부르며 실패에 iteration 과 프롬프트 예산을 태운다. Optional 접근자는 그 결정을 조립 시점으로 되돌린다. semanticSearch 의 선례가 성립한 것은 그 예외를 데코레이터가 조립 시점에 흡수했기 때문이지, 예외가 좋은 표현이어서가 아니다
A4 백엔드가 Set<MemoryCapability> 를 선언한다 진실 원천이 둘이 된다. 선언과 실제 구현이 어긋난 백엔드가 만들어질 수 있고, 그 어긋남은 조립이 아니라 첫 호출에서 드러난다. 티어 접근자에서 계산하면 그 상태가 표현 불가능해진다 — 단 그것이 참이려면 계산이 PeerMemory 에 있어야 한다. 인터페이스의 default 메서드로 두면 재정의로 이 기각이 무효화되므로 MemoryCapabilities.of(...) 정적 유틸이다(§3.2)
A5 Honcho 의 도메인 모델을 AIMON 의 것으로 채택한다 (4단 level · source_ids · times_derived) 전제로 삼으면 이 작업이 도메인 재설계가 된다 — Deriver·Reconciler·Dreamer·세 저장소 백엔드가 전부 따라 바뀐다. 실제로 필요한 것은 ObservationType 하나를 넓히는 것뿐이고(§2.2), 나머지는 원격이 자기 안에서 쓰면 된다
A6 원격 백엔드와 AIMON 의 Dreamer/Deriver 를 함께 돌린다 같은 대화에서 두 시스템이 서로 모르는 채 결론을 만든다. 어느 쪽이 진실인지 정할 수 없고, 비용은 두 배다. §6.1 에서 설정 검증으로 금지한다
A7 새 SPI 를 at.aimon.core.memory.spi 하위에 둔다 패키지 규약이 이미 at.aimon.core.<domain> 을 SPI 표면으로 정하고 있다(api-stability.md §2). spi 하위 패키지는 그 규약과 경쟁하는 두 번째 규약을 만든다. 덤으로 MemoryArchitectureTest 의 워크스페이스 격리 규칙이 정확히 at.aimon.core.memory 패키지를 대상으로 하므로, 하위로 내리면 그 규칙의 사정거리 밖으로 나간다
A8 MemoryRecallToolMemoryContextProvider 위에 세운다 (SNAPSHOT 티어를 만들지 않고 MemoryContextRequest 를 넓혀서) 티어 하나를 아낄 수 있지만 도구가 SystemPromptPart 를 받아 풀어 쓰게 되고(프롬프트 조각을 도구 결과로 렌더하는 것은 층 위반), MemoryRecall 이 보고하던 GLOBAL/LOCAL 해석 결과가 사라진다. 타입 하나를 아끼려고 두 가지를 잃는다

15. 아직 확인되지 않은 것

실측하지 않은 것은 실측하지 않았다고 적는다.

  1. Honcho 서버를 실제로 띄워 응답을 확인하지 않았다. §2 의 Honcho 칸은 honcho-java-spec.md(v3.1.0 기준 리버스 스펙)와 그것이 인용한 OpenAPI 를 근거로 한다. 이 항목은 이제 닫히지 않고 무의미해졌다 — Step 8 이 취소되었으므로 확인할 시점이 없다. §2 의 Honcho 칸은 SPI 의 고도를 정할 때 쓴 비교 자료로 남는다
  2. Dyad 서버를 띄워 응답을 확인하지 않았다. 1번과 같다. 다만 그 자리가 비어 있지는 않다 — 실제 원격 백엔드(aimon-memory)는 응답 본문과 인증 흐름을 자기 저장소에서 실서버 테스트로 확인한다(RemotePeerMemoryWireTest 가 임시 포트에 실제 서버를 띄워 다섯 티어의 바디와 세 능력 신호를 검사한다)
  3. 주입 지연. 원격 백엔드에서는 MemoryContextProvider.provide프롬프트 조립 경로에서 HTTP 를 탄다. 매 실행마다 붙는 지연이며, 실측하지 않았다. 완화책(짧은 TTL 캐시, 비동기 프리페치)은 Step 7(첫 원격 어댑터)에서 수치를 본 뒤 정한다 — 지금 넣으면 최적화할 대상 없이 캐시 무효화 문제만 산다 3′. 수집 지연. §15-3 의 대칭 자리이며 같은 이유로 미실측이다 — OrcaAgentExecutor.feedExecutionMemoryexecute() 의 finally 에서 동기로 ExecutionMemorySink.afterExecution 을 부르고, IngestingExecutionMemorySink 가 곧바로 MemoryIngestor.ingest 를 부른다. 원격 어댑터라면 대화 델타 전체를 실은 HTTP POST 가 실행 스레드 위에서 일어난다(waitForDerivation=false 여도 왕복은 남는다). 기본 백엔드에서는 무해하다 — QueueIngestor 가 큐에 넣고 즉시 돌아온다. 완화책(비동기 오프로드, 배치)은 §15-3 과 마찬가지로 Step 7 에서 수치를 본 뒤 정한다. 지금 넣으면 최적화할 대상 없이 순서 보장만 잃는다

  4. MemoryInjectionMode 의 원격 매핑 품질. FULL 을 Honcho 의 max_conclusions 로, Dyad 의 tokens 로 옮기는 것이 실제로 비슷한 양을 내놓는지는 픽스처로 비교해야 안다 — Step 7·8 의 인수 조건에 붙일 것

  5. ObservationType 확장의 소비자 영향. 트리 안에 exhaustive switch 가 없다는 것은 확인했으나, 프롬프트 문구(LlmDeriver 의 type 분류 지시)가 4값을 어떻게 다룰지는 정하지 않았다 — 기본 백엔드의 Deriver 는 계속 2값만 만들게 두는 것이 §12 Step 2 의 전제다
  6. TeardownPhase 블록 이동의 안전성.해소됨 (Step 5). §3.6 이 근거로 든 것 (SessionRecordStore 가 application-scoped 라 SESSIONS 가 닫지 않는다)은 그대로 맞고, 이제 추론이 아니라 측정이다 — AimonStackBuilderTestmemoryFinalDerivationStillReadsTheTranscriptAfterTheMoveSESSIONS·CHECKPOINTS 뒤에 등록된 MEMORY_FINAL_DERIVATION 항목이 전사에서 메시지 2건을 읽는 것을 세고, 그 항목이 정말 두 phase 뒤에 돌았다는 것도 teardown plan 인덱스로 확인한다. 옛 순서는 트리에 남아 있지 않으므로 "이동 전후 관찰 개수 비교" 라는 문자 그대로의 형태는 불가능하고, 이동이 위협한 유일한 것(전사를 못 읽게 되는 것)을 직접 재는 것이 그 자리를 대신한다
  7. Honcho conclusions/query 를 peer 쌍으로 좁힐 수 있는가. §2.1 은 그 티어를 로 놓았지만, honcho-java-spec.md:343 이 기술하는 파라미터는 query · top_k · distance 셋뿐이고 observer/observed 로 좁히는 방법이 명시되어 있지 않다. honcho-java-spec.md§8.3 필터 DSL(그 파일 :654)이 기술하는 범용 filters 로 되리라 보이지만, 그것이 이 엔드포인트에 걸리는지는 스펙에 없다. MemorySearchQuerysubject/observer 를 요구하므로, 좁힐 수 없으면 그 티어가 워크스페이스 전역 결과를 subject 의 것처럼 돌려준다 — §5.2 가 없애려는 실패 모드다. Step 8 착수 시 첫 번째로 확인할 것
  8. 수집 델타를 어떻게 잡을지 정하지 않았다.해소됨 (Step 5). (a) 전사 내 워터마크를 골랐다. (b) 는 선택지가 아니었다 — Message 에 안정적인 id 가 없고, 더하는 것은 세션 레코드의 와이어 포맷을 넓히는 일이다. compaction 이 일어난 실행은 아무것도 보내지 않는다. 근거와 세 선택지의 대가 비교는 §7.2 의 "확정: (a)" 절에 있다. 남은 미확정은 없다
  9. ObservationDraft.type 의 왕복 손실 처리. confidence 는 §2.2 에서 신호 + 스키마 축소로 정했지만, Dyad 가 직접 주입을 EXPLICIT 로 고정하는 것(ConclusionController:108-110) 때문에 모델이 고른 DEDUCTIVE 도 버려진다. 같은 기계를 type 까지 넓힐지는 정하지 않았다
  10. CLI 이주의 실제 크기. §5.0 은 아홉 개 메서드와 상호 배타 규칙을 짚었지만, buildDreamerSubsystemInMemoryWorkspaceStore 를 자기 안에서 만드는 것처럼 MemorySpec 이 담지 않는 재료가 더 있는지는 전수 확인하지 않았다. Step 4 를 시작할 때 AgentSetupFactory구체 클래스 13종(§4.2 의 수 — at.aimon.core.memory* import 는 전부 30개이고 그중 구체 클래스가 13개다)을 다시 센다

관련 문서