시스템 구조와 데이터 모델
- 문서 성격: 병렬 코딩 라운드(10/18-10/21)의 공유 계약서. 여기 적힌 테이블, 컬럼, 함수 시그니처, 토픽, 큐 이름을 바꾸려면 메인 세션 승인 후 이 문서를 먼저 고친다.
- 전제: Elixir 1.20.4 / OTP 29 / Phoenix 1.8.15 / LiveView 1.2.12 / Ecto 3.14 / PostgreSQL 16.
mix phx.new . --app tickmark --binary-id와mix phx.gen.auth Accounts User users --live가 이미 실행되어 있다 (users, users_tokens 마이그레이션20261017093257,Tickmark.Accounts.Scope, bcrypt_elixir). users 테이블은 재설계하지 않는다. - 관련 문서: 분개 테스트 룰과 감사 용어 (룰 판정식, 가중치, AI 입력 명세는 도메인 문서가 원본), MVP 범위와 일정 5장 GL 계약
- 표기: "확인 필요" = 문서/소스로 검증하지 못한 항목. 구현 담당자가 첫 사용 시 검증하고 이 문서에 결과를 남긴다.
1. 시스템 구조 개요
Browser (LiveView JS)
| HTTP + WebSocket
v
TickmarkWeb.Endpoint (Bandit) -> Router -> UserAuth (current_scope)
|
+-- LiveViews (live_session :require_authenticated_user)
| EngagementLive.Index/Show, ImportLive, TestRunLive,
| FindingLive.Show (triage/conclusion/review), AuditLive
| | (1) 호출: 항상 scope 가 첫 인자 ^ (4) handle_info
v v |
Contexts ----------------------------------------> Phoenix.PubSub (Tickmark.PubSub)
Accounts | Engagements | Ledger | Testing ^ ^ Presence (TickmarkWeb.Presence)
Triage | Review | AuditTrail | (3) 커밋 후 broadcast
| Ecto.Multi + Repo.transact (변경 + audit_event + Oban job 을 한 트랜잭션에)
v
Tickmark.Repo ---> PostgreSQL 16
domain tables, audit_events(append-only trigger), oban_jobs
^ (2) 폴링/LISTEN 으로 job fetch
Oban (Basic engine, OSS)
ingest(1): Ledger.ImportWorker
rules(4): Testing.RuleWorker (test_run 당 rule 개수만큼 fan-out)
llm(2): Triage.TriageWorker -> Tickmark.AI (behaviour)
+-- AI.Anthropic (Req -> api.anthropic.com)
+-- AI.Fake (결정적, 키 없을 때/테스트)
1.1 흐름: CSV 업로드부터 리뷰 승인까지
- preparer 가 ImportLive 에서 CSV 업로드 ->
consume_uploaded_entries콜백 안에서Ledger.create_import/3호출. 파일을 import 디렉터리로 복사하고, 같은 Multi 로ledger_imports(pending) + audit_event +ImportWorkerjob 을 insert. ImportWorker(ingest 큐) 가 파일을 스트리밍 파싱, 검증, chunk 단위insert_all. 성공/실패를ledger_imports에 기록하고"engagement:#{id}"로 broadcast.- preparer 가 "테스트 실행" ->
Testing.start_test_run/3. 한 트랜잭션에서test_runs(running), 룰별test_run_rules(queued), audit_event, 룰별RuleWorkerjob N개를 insert. RuleWorker들이 병렬 실행. 각자 hit 를rule_results에 넣고, 같은 트랜잭션에서 완료 감지(5.4). 진행률은"test_run:#{id}"로 broadcast. 마지막 룰이 끝나면findings를 집계 생성하고 run 을 completed 로 바꾼다.- preparer 가 "AI 트리아지" ->
Triage.request_triage/2가 findings 를 queued 로 전이시키고TriageWorkerjob 을 넣는다. 결과는ai_triages(불변) 에 저장, 제안만 하고 상태를 결정하지 않는다. - preparer 가 finding 별 결론 제출 (
Review.submit_conclusion/3) -> finding.status = concluded. - reviewer 가 승인/반려 (
Review.decide/4). 본인 결론은 컨텍스트와 DB 양쪽에서 차단(8장). - 모든 상태 변경은 같은 트랜잭션에서
audit_events에 기록되고 AuditLive 에서 시간순으로 보인다.
2. Context 경계
| Context | 책임 | 소유 테이블 | 디렉터리 |
|---|---|---|---|
| Accounts | 인증, User, Scope (생성됨, 수정 최소) | users, users_tokens | lib/tickmark/accounts* |
| Engagements | engagement CRUD, 멤버/역할, 권한 정책, engagement 토픽 | engagements, engagement_members | lib/tickmark/engagements* |
| Ledger | CSV 파싱/검증/적재, 합성 GL 생성기, 분개 조회 | ledger_imports, journal_entries, journal_lines | lib/tickmark/ledger* |
| Testing | 룰 behaviour/레지스트리, 실행 fan-out, 완료 감지, findings 생성 | test_runs, test_run_rules, rule_results, findings | lib/tickmark/testing* |
| Triage | AI 입력 구성, input_hash, 트리아지 job, 결과 저장 | ai_triages (+ findings.triage_status 전이) | lib/tickmark/triage* |
| Review | preparer 결론, reviewer 결정, 직무분리 | conclusions, review_decisions (+ findings.status 전이) | lib/tickmark/review* |
| AuditTrail | 감사 이벤트 append, 조회. update/delete 함수는 존재하지 않음 | audit_events | lib/tickmark/audit_trail* |
| (infra) AI | LLM 클라이언트 behaviour 와 구현, 프롬프트 버전 | 없음 | lib/tickmark/ai* |
규칙
- 웹 계층에서 호출하는 공개 함수는
%Scope{}를 첫 인자로 받는다 (phx.gen.auth 1.8 관례). Worker 가 호출하는 함수는 id 만 받고 이름에 의도를 드러낸다 (process_import/1,run_rule/2). - Scope 구조체는
user만 유지한다. engagement 역할은 호출마다Engagements.fetch_role/2로 조회한다 (확장은 2단계). Finding스키마는 Testing 소유.status는 Review 만,triage_status는 Triage 만 changeset 함수로 바꾼다.- 상태 필드는
Ecto.Enum(DB 는 string + CHECK). 금액은:decimal. - 다른 컨텍스트의 Repo 쿼리를 직접 쓰지 않고 공개 함수를 쓴다. 단 Multi 조립을 위해
AuditTrail.append/3와 changeset 함수는 공유한다.
2.1 공개 함수 시그니처
# Tickmark.Engagements
@type role :: :preparer | :reviewer
@spec list_engagements(Scope.t()) :: [Engagement.t()]
@spec get_engagement!(Scope.t(), Ecto.UUID.t()) :: Engagement.t() # 멤버가 아니면 Ecto.NoResultsError
@spec create_engagement(Scope.t(), map()) :: {:ok, Engagement.t()} | {:error, Ecto.Changeset.t()} # 생성자 = preparer 멤버
@spec change_engagement(Engagement.t(), map()) :: Ecto.Changeset.t()
@spec add_member(Scope.t(), Engagement.t(), email :: String.t(), role() | String.t()) ::
{:ok, Member.t()} | {:error, :forbidden | :user_not_found | Ecto.Changeset.t()}
# role 은 폼에서 온 문자열도 받는다 (Member.changeset/2 가 cast). 이미 멤버면 :user_id changeset 오류
@spec list_members(Scope.t(), Engagement.t()) :: [Member.t()] # 멤버가 아니면 [] (authorize :view 실패)
@spec fetch_role(Scope.t(), Ecto.UUID.t()) :: {:ok, role()} | {:error, :forbidden}
@spec authorize(Scope.t(), Ecto.UUID.t(), Policy.action()) :: {:ok, role()} | {:error, :forbidden}
@spec subscribe(Scope.t(), Ecto.UUID.t()) :: :ok | {:error, :forbidden}
@spec broadcast(Ecto.UUID.t(), tuple()) :: :ok # 다른 컨텍스트가 커밋 후 호출
# Tickmark.Engagements.Policy (순수 함수, DB 접근 없음)
@type action :: :view | :import_ledger | :start_test_run | :request_triage
| :submit_conclusion | :review_conclusion | :manage_members
@spec allowed?(role(), action()) :: boolean()
# Tickmark.Ledger
@spec create_import(Scope.t(), Engagement.t(), %{path: String.t(), filename: String.t()}) ::
{:ok, LedgerImport.t()} | {:error, :forbidden | :file_too_large | Ecto.Changeset.t()}
@spec generate_sample_import(Scope.t(), Engagement.t(), keyword()) ::
{:ok, LedgerImport.t()} | {:error, :forbidden | term()}
@spec list_imports(Scope.t(), Ecto.UUID.t()) :: [LedgerImport.t()]
@spec get_import!(Scope.t(), Ecto.UUID.t()) :: LedgerImport.t()
@spec get_entry!(Scope.t(), Ecto.UUID.t()) :: JournalEntry.t() # lines preload
@spec process_import(Ecto.UUID.t()) :: {:ok, LedgerImport.t()} | {:error, term()} # ImportWorker 전용
# {:ok, import} 는 succeeded, failed (검증 실패, 파일 없음), 이미 끝난 import 모두. 알 수 없는 id 는
# {:error, :not_found}. DB 오류는 raise 해서 job 이 재시도한다. ImportWorker: succeeded -> :ok,
# failed -> {:cancel, :import_failed}, :not_found -> {:cancel, :not_found}, 마지막 시도의 오류 ->
# mark_import_failed 후 {:cancel, reason}. max_attempts 3, timeout 10분
@spec mark_import_failed(Ecto.UUID.t(), term()) :: :ok # 최종 실패 핸들러
# Tickmark.Ledger.Generator (순수 함수. DB, Repo, 파일 쓰기 없음)
@spec default_seed() :: integer()
@spec generate(keyword()) :: %{gl_csv: binary(), labels_csv: binary(),
entry_count: pos_integer(), line_count: pos_integer()}
# opts: seed (기본 default_seed/0). 같은 seed -> 같은 바이트. gl_csv 는 9장 GL 계약 헤더,
# labels_csv 는 도메인 문서 7.6 필드. generate_sample_import/3 는 gl_csv 를 import 디렉터리에
# <uuid>.csv 로, labels_csv 를 <uuid>.labels.csv 로 쓰고 create_import 와 같은 경로로 행을 만든다.
# generate_sample_import/3 opts 는 seed 만 받는다 (Keyword.validate!, target_lines 등 그 밖의 키는
# ArgumentError). original_filename 은 "synthetic-gl-seed-<seed>.csv". ledger_imports 에 seed 컬럼이
# 없으므로 화면 (EngagementLive.Components.import_source/1) 은 이 이름에서 시드를 읽는다.
# 행 생성이 {:error, _} 이면 GL 과 labels 파일을 지운다 (raise 면 labels 파일이 남을 수 있음).
# Tickmark.Testing.Holidays (순수 함수, 코드 상수. lib/tickmark/testing/holidays.ex)
@spec all() :: [Date.t()] # 오름차순
@spec holiday?(Date.t()) :: boolean() # 주말 여부는 보지 않는다
# 2025년 한국 공휴일 19일 (도메인 문서 2.10). 주말과 겹치는 공휴일도 포함, 근로자의 날 제외,
# 2025년 밖의 날짜는 false. weekend_holiday_posting 룰과 Generator 가 함께 쓴다.
# Tickmark.Testing (읽기 함수는 testing.ex, 실행 경로는 Testing.Runner 에 두고 defdelegate 로 노출.
# 호출하는 쪽은 Tickmark.Testing 만 쓴다)
@spec list_rules() :: [module()] # 레지스트리 순서 (도메인 2.1)
@spec run_topic(Ecto.UUID.t()) :: String.t() # "test_run:#{id}"
@spec start_test_run(Scope.t(), LedgerImport.t(), [String.t()] | :all) ::
{:ok, TestRun.t()} | {:error, :forbidden | :import_not_ready | :run_in_progress | Ecto.Changeset.t()}
# 알 수 없는 rule id 나 빈 목록은 호출하는 쪽 버그라 ArgumentError (화면은 :all 만 보낸다)
@spec list_test_runs(Scope.t(), Ecto.UUID.t()) :: [TestRun.t()] # 최신순, test_run_rules preload (레지스트리 순). :view 없으면 []
@spec get_test_run!(Scope.t(), Ecto.UUID.t()) :: TestRun.t() # test_run_rules preload (레지스트리 순). 없음/잘못된 id/권한 없음 모두 Ecto.NoResultsError
@spec subscribe_test_run(Scope.t(), Ecto.UUID.t()) :: :ok | {:error, :forbidden}
@spec list_findings(Scope.t(), Ecto.UUID.t(), map()) :: [Finding.t()]
# journal_entry preload, score 내림차순 후 entry_number. :view 없으면 [].
# filters (atom 키, 모두 선택): queue (:review 기본 = score >= run.triage_threshold, :minor, :all), status, rule_id
@spec count_findings(Scope.t(), Ecto.UUID.t()) ::
%{review_queue: non_neg_integer(), minor: non_neg_integer(), approved: non_neg_integer()}
# 쿼리 1번. S2 실행 목록과 통계, S4 탭 숫자 (화면설계서 C-07). approved 는 검토 큐 안에서 승인된 수.
# :view 없으면 모두 0
@spec latest_run_summaries(Scope.t()) :: %{Ecto.UUID.t() => %{status: atom(), review_queue: non_neg_integer() | nil}}
# S1 목록 (WBS 2.5). 내가 멤버인 감사 대상마다 가장 최근 실행 (started_at, id 내림차순) 의 상태와 검토 큐 수.
# 검토 큐는 그 실행의 triage_threshold 기준이고 running 이면 nil. 실행이 없는 감사 대상은 키가 없다. 쿼리 2번
@spec get_finding!(Scope.t(), Ecto.UUID.t()) :: Finding.t()
# M3 (라운드 4 W4). preload: journal_entry 와 lines (line_number 순), test_run, ai_triages (inserted_at 내림차순),
# conclusions (inserted_at 오름차순, preparer), review_decisions (reviewer). 비멤버, 없는 id, 형식이 틀린 id 는 모두 Ecto.NoResultsError
@spec list_rule_results(Scope.t(), Finding.t()) :: [RuleResult.t()]
# M3 (W4). 같은 run, 같은 전표의 rule_results 를 레지스트리 순서로. Finding 에는 rule_results association 이 없다
# (test_run_id + journal_entry_id 로 잇는다). 비멤버는 []
@spec run_rule(Ecto.UUID.t(), String.t()) :: {:ok, :done | :skipped | :noop} | {:error, term()} # RuleWorker 전용. :skipped = 룰이 실행 조건 미달, :noop = 이미 terminal 이거나 run 이 running 아님
@spec fail_rule(Ecto.UUID.t(), String.t(), term()) :: :ok # RuleWorker/핸들러 전용. 이미 terminal 이면 아무것도 안 함
# Tickmark.Testing.Rules (레지스트리, 2.2)
@spec all() :: [module()]
@spec ids() :: [String.t()]
@spec fetch(String.t()) :: {:ok, module()} | :error # 테스트 전용 룰 (config/test.exs extra_rules) 도 찾는다
# Tickmark.Triage (M3, 라운드 4 W1). 순서, 이벤트, 워커 반환은 5.5
@spec request_finding_triage(Scope.t(), finding_id :: Ecto.UUID.t()) ::
{:ok, Finding.t()} | {:error, :forbidden | :below_threshold | :already_queued}
# 없는 id, 형식이 틀린 id, 비멤버, 검토자는 모두 :forbidden. score < run.triage_threshold 면 :below_threshold (C-09).
# 재요청은 failed, succeeded 에서 가능 (queued 만 :already_queued)
@spec request_triage(Scope.t(), test_run_id :: Ecto.UUID.t()) ::
{:ok, %{enqueued: non_neg_integer()}} | {:error, :forbidden}
# 일괄. 기준 이상이고 triage_status :none 인 finding 만 고른다 (실패 건은 건별 재요청). 없는 run, 비멤버, 검토자는 :forbidden
@spec triage_finding(Ecto.UUID.t()) :: {:ok, AiTriage.t() | :noop} | {:error, Tickmark.AI.Client.error()} # TriageWorker 전용
@spec mark_failed(Ecto.UUID.t(), reason :: term()) :: :ok # TriageWorker/FailureHandler 전용. queued 일 때만 failed 로 (멱등)
@spec build_input(Finding.t(), [RuleResult.t()]) :: map() # 순수 함수. finding 은 journal_entry (lines), test_run (engagement) preload. 도메인 4.1
@spec latest_triages(Scope.t(), [Ecto.UUID.t()]) :: %{Ecto.UUID.t() => AiTriage.t()} # S4 AI 열 (라운드 4b). 비멤버 %{}
# input_hash/1 은 Tickmark.AI 로 옮겼다 (7.4). Fake 가 Triage 보다 먼저 필요로 한다
# Tickmark.Review (M4, 라운드 4 W2). 순서와 이벤트는 8장
@spec submit_conclusion(Scope.t(), Finding.t(), attrs :: map()) ::
{:ok, Conclusion.t()} | {:error, :forbidden | :invalid_state | Ecto.Changeset.t()}
# attrs: "outcome", "comment", "ai_triage_id" (선택. 같은 finding 의 제안이 아니면 changeset 오류). approved 이면 :invalid_state
@spec decide(Scope.t(), Finding.t(), conclusion_id :: Ecto.UUID.t(), attrs :: map()) ::
{:ok, ReviewDecision.t()}
| {:error, :forbidden | :self_review | :invalid_state | :stale | Ecto.Changeset.t()}
# attrs: "decision" ("approved" / "rejected"), "comment" (반려 시 필수). conclusion_id 는 검토자가 화면에서 본 결론 (C-11)
# Tickmark.AuditTrail
@type actor :: {:user, Ecto.UUID.t()} | {:agent, String.t()} | {:system, String.t()}
@spec actor(Scope.t()) :: actor()
@spec append(Ecto.Multi.t(), Ecto.Multi.name(), (map() -> map())) :: Ecto.Multi.t()
@spec record(map()) :: {:ok, AuditEvent.t()} | {:error, Ecto.Changeset.t()} # 단독 기록 (예: review.denied)
@spec broadcast_appended(AuditEvent.t()) :: :ok # 라운드 4 선행 작업. 커밋 뒤에 부른다 (6장). engagement_id 가 nil 이면 보내지 않는다
@spec list_events(Scope.t(), engagement_id :: Ecto.UUID.t(), keyword()) :: [AuditEvent.t()]
# 라운드 4 W3. seq 내림차순. 비멤버 []. opts (모두 선택, 함께 쓰면 AND):
# limit: 기본 50, 1..200 으로 자른다 (C-08 과 같이 MVP 는 커서 하나)
# before_seq: 정수. 그보다 작은 seq 만 ("더 보기")
# actor_type: :user | :agent | :system
# subject: {subject_type :: String.t(), subject_id :: Ecto.UUID.t()}
# entry_number: String.t(). payload->>'entry_number' 일치 (C-13. finding 이 대상인 이벤트는 모두 이 키를 갖는다, 4.2)
- 표기: 스키마 모듈에는
@type t를 두지 않았다 (Ledger.Generator.Random만 있음). 위 시그니처의X.t()는 "X struct" 를 뜻하는 표기이고, Dialyzer 를 도입할 때 스키마마다@type t를 더한다.
2.2 룰 플러그인 계약 (domain-writer 와 공유)
defmodule Tickmark.Testing.Rule do
@type hit :: %{
journal_entry_id: Ecto.UUID.t(),
reason: String.t(), # 사람이 읽는 한 줄 (한국어)
details: map() # 도메인 문서의 evidence. jsonb, 문자열 키
}
@callback id() :: String.t() # snake_case 영어, ^[a-z][a-z0-9_]{1,39}$
@callback name() :: String.t()
@callback description() :: String.t()
@callback weight() :: pos_integer() # 도메인 문서 2.1 가중치. hit 의 점수는 Testing 이 이 값으로 채운다
@callback default_params() :: map() # 문자열 키 (jsonb 왕복 후에도 동일하게)
@callback run(Tickmark.Testing.RuleContext.t()) :: {:ok, [hit()]} | {:skip, reason :: String.t()} | {:error, term()}
end
defmodule Tickmark.Testing.RuleContext do
@enforce_keys [:test_run_id, :ledger_import_id, :engagement, :params]
defstruct [:test_run_id, :ledger_import_id, :engagement, :params]
end
- 룰 1개 = 모듈 1개 (
lib/tickmark/testing/rules/<rule_id>.ex). 등록은Tickmark.Testing.Rules의 명시적 리스트@rules [{"rule_id", Module}, ...](매크로/런타임 탐색 없음). 맵이 아닌 리스트인 이유: 화면이 도메인 2.1 순서로 룰을 보여 준다. - 테스트 전용 룰:
config :tickmark, Tickmark.Testing.Rules, extra_rules: [...](config/test.exs) 를Application.compile_env로 읽는다.fetch/1만 찾고all/0,ids/0에는 나오지 않는다. 모듈은test/support/test_rules.ex(test_skip,test_error,test_raise). - 참조 구현:
Tickmark.Testing.Rules.ApprovalControlGap(메인 선행 작업). 나머지 룰은 이 모양을 따른다. run/1은ledger_import_id로 범위를 좁힌 Ecto 쿼리로 구현하고 DB 를 쓰지 않는다 (읽기 전용). 결과 저장은 Testing 이 한다.- 한 룰은 한 전표를 최대 한 번 돌려준다 (
rule_resultsunique(test_run_id, rule_id, journal_entry_id)). hit 순서는entry_number오름차순. details값: 금액은Rule.amount_string/1(원 단위 정수 문자열. 금액 컬럼이 scale 2 라Decimal.to_string/1만 쓰면"1000000.00"), 날짜는 ISO 8601 문자열, 키는 문자열.reason은 사람이 읽는 한국어 한 줄이고 화면에 그대로 나온다.params는 실행 시점 스냅샷이test_run_rules.params에 저장된다. jsonb 에서 읽으면 키가 문자열이 되므로 룰은 처음부터 문자열 키로 쓴다 (학습 포인트).- 금액 비교는
Decimal함수 또는 SQL 에서만 한다. float 금지. {:skip, reason}은 실행 조건 미달 (예:rare_account_pair의 모집단 전표 수 <rare_min_population).test_run_rules.status = skipped로 기록하고 0점으로 본다.- MVP 의 룰 파라미터는
default_params/0이 전부다. engagement 별 덮어쓰기는 2단계. 휴일표(도메인 2.10)는Tickmark.Testing.Holidays모듈 상수로 두고extra_holidays파라미터로 추가한다.
3. 데이터 모델
공통 규칙
- PK: 모든 테이블에
id :binary_id(UUID, NOT NULL). 표에는 특이사항이 있을 때만 적는다. 스키마는@primary_key {:id, :binary_id, autogenerate: true},@foreign_key_type :binary_id. - 시각: 제목의 [T] =
timestamps(type: :utc_datetime_usec), [I] = 같은 타입에updated_at: false(불변 테이블). users 는 생성된 대로:utc_datetime. - 금액:
:decimal, precision: 18, scale: 2(numeric(18,2)), Elixir 에서는Decimal. - 도메인 FK 는 명시적으로
on_delete: :restrict(감사 증적은 연쇄 삭제하지 않는다). 예외만 표에 표시. - 상태/역할 컬럼은 string + CHECK, 스키마는
Ecto.Enum. insert_all은 리스트 입력이면 binary_id PK 를 Ecto 가 생성하지만 timestamps 는 넣지 않으므로 직접 넣는다 (ecto 3.14.2 소스 확인). 쿼리 입력이면 id 도 생성하지 않으므로 DB default 를 둔다.
3.1 engagements [T]
| 컬럼 | 타입 | null | default | 비고 |
|---|---|---|---|---|
| id | uuid | N | PK | |
| client_name | string | N | 회사명, 1-200자 | |
| fiscal_year | integer | N | CHECK 2000-2100 | |
| period_end_date | date | N | 결산일 | |
| performance_materiality | numeric(18,2) | N | CHECK > 0 | |
| created_by_id | uuid FK users | N | 멤버 관리 권한자 |
인덱스: created_by_id.
3.2 engagement_members [T]
| 컬럼 | 타입 | null | default | 비고 |
|---|---|---|---|---|
| engagement_id | uuid FK engagements | N | on_delete :delete_all | |
| user_id | uuid FK users | N | ||
| role | string | N | CHECK in ('preparer','reviewer') |
인덱스: unique (engagement_id, user_id) (한 engagement 에서 한 사람은 역할 하나), user_id.
3.3 ledger_imports [T]
| 컬럼 | 타입 | null | default | 비고 |
|---|---|---|---|---|
| engagement_id | uuid FK | N | ||
| uploaded_by_id | uuid FK users | N | ||
| source_kind | string | N | CHECK in ('upload','generated') | |
| original_filename | string | N | 표시용, 255자 제한 | |
| stored_filename | string | N | <uuid>.csv, import 디렉터리 기준 상대 이름 |
|
| byte_size | bigint | N | ||
| sha256 | string | N | 파일 해시 hex 64 (증적) | |
| status | string | N | 'pending' | CHECK in ('pending','processing','succeeded','failed') |
| entry_count / line_count | integer | N | 0 | |
| total_debit / total_credit | numeric(18,2) | N | 0 | |
| error_count | integer | N | 0 | |
| errors | jsonb[] ({:array, :map}) |
N | '{}' | 최대 100건 %{"line" => n, "field" => f, "code" => c} |
| warning_count | integer | N | 0 | 적재는 허용하는 품질 경고 수 (예: period_mismatch) |
| warnings | jsonb[] ({:array, :map}) |
N | '{}' | errors 와 같은 모양, 최대 100건 |
| started_at / finished_at | timestamp(usec) | Y |
인덱스: (engagement_id, inserted_at).
3.4 journal_entries (전표 헤더, 불변) [I]
| 컬럼 | 타입 | null | 비고 |
|---|---|---|---|
| id | uuid | N | 적재 시 Elixir 에서 Ecto.UUID.generate() (라인이 참조) |
| engagement_id | uuid FK | N | 범위 조회용 비정규화 |
| ledger_import_id | uuid FK | N | |
| entry_number | string | N | |
| posting_date / document_date | date | N | |
| entered_at | naive timestamp | N | KST 벽시계 시각, 타임존 변환 없음 (KRW 단일 국가 MVP) |
| fiscal_year | integer | N | |
| fiscal_period | integer | N | CHECK 1-13 (13 = 결산조정) |
| entered_by / approved_by | string | N / Y | |
| counterparty | string | Y | 전표 헤더 값 (GL 계약 검증 규칙). duplicate_entry 가 사용 |
| source | string | N | CHECK in ('manual','system') |
| line_count | integer | N | |
| total_debit / total_credit | numeric(18,2) | N | CHECK total_debit = total_credit |
인덱스: unique (ledger_import_id, entry_number), (ledger_import_id, posting_date), engagement_id.
3.5 journal_lines (전표 라인, 불변) [I]
| 컬럼 | 타입 | null | default | 비고 |
|---|---|---|---|---|
| id | uuid | N | insert_all 리스트 입력이라 Ecto 가 생성 | |
| journal_entry_id | uuid FK | N | ||
| ledger_import_id | uuid FK | N | 룰 쿼리용 비정규화 | |
| line_number | integer | N | CHECK > 0 | |
| account_code / account_name | string | N | ||
| debit / credit | numeric(18,2) | N | 0 | CHECK >= 0 |
| description | text | Y | 적요. AI 입력에서는 신뢰하지 않는 텍스트로 감싼다 | |
| inserted_at | timestamp(usec) | N |
CHECK one_side_positive: (debit > 0 AND credit = 0) OR (debit = 0 AND credit > 0). 인덱스: unique (journal_entry_id, line_number), (ledger_import_id, account_code).
3.6 test_runs [T]
| 컬럼 | 타입 | null | default | 비고 |
|---|---|---|---|---|
| engagement_id / ledger_import_id | uuid FK | N | ||
| started_by_id | uuid FK users | N | ||
| status | string | N | 'running' | CHECK in ('running','completed','completed_with_errors') |
| rule_count | integer | N | ||
| triage_threshold | integer | N | 25 | 실행 시점 스냅샷 (도메인 3.2). 검토 큐와 일괄 트리아지 기준 |
| finding_count | integer | N | 0 | |
| started_at | timestamp(usec) | N | ||
| finished_at | timestamp(usec) | Y |
인덱스: partial unique (ledger_import_id) WHERE status = 'running' (같은 import 에 실행 중 run 1개, 더블클릭 방지), (engagement_id, inserted_at).
3.7 test_run_rules [T]
| 컬럼 | 타입 | null | default | 비고 |
|---|---|---|---|---|
| test_run_id | uuid FK | N | ||
| rule_id | string | N | ||
| params | jsonb | N | '{}' | 실행 시점 스냅샷 |
| status | string | N | 'queued' | CHECK in ('queued','running','succeeded','skipped','failed') |
| hit_count | integer | N | 0 | |
| error | text | Y | 내부 사유 (UI 에는 일반 문구) | |
| started_at / finished_at | timestamp(usec) | Y |
인덱스: unique (test_run_id, rule_id).
3.8 rule_results (불변) [I]
| 컬럼 | 타입 | null | 비고 |
|---|---|---|---|
| test_run_id | uuid FK | N | |
| rule_id | string | N | |
| journal_entry_id | uuid FK | N | |
| score | integer | N | CHECK 1-100. 해당 룰의 가중치 (weight/0) |
| reason | text | N | |
| details | jsonb | N | default '{}' |
인덱스: unique (test_run_id, rule_id, journal_entry_id), (test_run_id, journal_entry_id).
3.9 findings (작업 단위 = run x 전표) [T]
| 컬럼 | 타입 | null | default | 비고 |
|---|---|---|---|---|
| id | uuid | N | gen_random_uuid() |
쿼리 기반 insert_all 이라 DB default (PG13+ 내장) |
| engagement_id / test_run_id / journal_entry_id | uuid FK | N | ||
| hit_count | integer | N | 걸린 룰 수 | |
| score | integer | N | LEAST(100, SUM(rule_results.score)) (도메인 3.1), CHECK 0-100 |
|
| rule_ids | text[] | N | array_agg |
|
| status | string | N | 'open' | CHECK in ('open','concluded','approved','rejected') |
| triage_status | string | N | 'none' | CHECK in ('none','queued','succeeded','failed') |
인덱스: unique (test_run_id, journal_entry_id), (test_run_id, status), (test_run_id, score).
상태 전이 (Review 만 수행): open -> concluded -> approved, concluded -> rejected -> concluded, concluded -> concluded (검토 전 결론 재제출). approved 는 종결.
- finding 은 룰이 하나라도 걸린 전표마다 만든다. 검토 큐는
score >= test_runs.triage_threshold인 finding 이고 (list_findings기본 필터), 그 미만은 경미 적중 목록으로만 보인다. 결론 제출은 둘 다 허용한다. - 결론 제출이 곧 검토 요청이다 (별도 in_review 상태 없음). 도메인 문서 5.2 가 이 모델을 따른다.
3.10 ai_triages (불변, 제안 전용) [I]
| 컬럼 | 타입 | null | 비고 |
|---|---|---|---|
| finding_id | uuid FK | N | |
| model | string | N | 요청한 모델 id (config 값) |
| response_model | string | N | 응답의 model (fallback 시 다를 수 있음) |
| prompt_version | string | N | 예: "triage-v1" |
| input_hash | string | N | sha256 hex, CHECK length = 64 |
| input | jsonb | N | 해시 대상 정규화 입력 (재검증용) |
| risk_level | string | N | CHECK in ('low','medium','high') |
| rationale | text | N | |
| suggested_procedures | jsonb | N | [%{"procedure" => s, "evidence_to_obtain" => s}], 1-4개 |
| missing_information | text[] | N | 0-5개 |
| stop_reason | string | N | |
| input_tokens / output_tokens / latency_ms | integer | N |
인덱스: unique (finding_id, prompt_version, model, input_hash) (on_conflict: :nothing 로 재시도 멱등). 별도 finding_id 인덱스는 두지 않는다 (unique 인덱스의 선두 컬럼이 같은 조회를 처리).
3.11 conclusions (불변, 제출마다 새 행) [I]
| 컬럼 | 타입 | null | 비고 |
|---|---|---|---|
| finding_id | uuid FK | N | |
| preparer_id | uuid FK users | N | |
| outcome | string | N | CHECK in ('no_exception','needs_follow_up','exception') = 이상 없음 / 추가 확인 필요 / 예외 |
| comment | text | N | 1-2000자 (changeset) |
| ai_triage_id | uuid FK ai_triages | Y | 결론 시 참고한 AI 제안 (AI 제안 vs 사람 결정 추적) |
인덱스: unique (id, preparer_id) (복합 FK 대상), (finding_id, inserted_at).
3.12 review_decisions (불변) [I]
| 컬럼 | 타입 | null | 비고 |
|---|---|---|---|
| finding_id | uuid FK | N | |
| conclusion_id | uuid | N | 복합 FK (conclusion_id, preparer_id) -> conclusions(id, preparer_id) |
| preparer_id | uuid FK users | N | 결론 작성자 복사본 (복합 FK 로 진실성 보장) |
| reviewer_id | uuid FK users | N | |
| decision | string | N | CHECK in ('approved','rejected') |
| comment | text | Y | CHECK comment_required_on_reject: decision = 'approved' OR (comment IS NOT NULL AND length(btrim(comment)) > 0) |
CHECK reviewer_not_preparer: reviewer_id <> preparer_id. 인덱스: unique conclusion_id (결론 1건당 결정 1건), finding_id.
add :preparer_id, references(:users, type: :binary_id, on_delete: :restrict), null: false
add :conclusion_id,
references(:conclusions, type: :binary_id, with: [preparer_id: :preparer_id], on_delete: :restrict),
null: false
(references/2 의 :with 는 ecto_sql 3.14.0 문서로 확인. 생성되는 FK 이름 review_decisions_conclusion_id_fkey 는 psql \d review_decisions 로 확인함.)
3.13 audit_events (append-only) [I]
| 컬럼 | 타입 | null | 비고 |
|---|---|---|---|
| seq | bigint | N | generated: "ALWAYS AS IDENTITY", 정렬 기준 (같은 트랜잭션 내 순서 보장) |
| engagement_id | uuid FK | Y | 사용자 단위 이벤트면 null |
| actor_type | string | N | CHECK in ('user','agent','system') |
| actor_id | string | N | user 면 UUID 문자열, agent 면 "triage:", system 이면 worker 이름. FK 없음 |
| action | string | N | <subject>.<verb> 예: "conclusion.submitted" |
| subject_type | string | N | "engagement", "ledger_import", "test_run", "finding" 등 |
| subject_id | uuid | N | FK 없음 (다형) |
| payload | jsonb | N | default '{}', 시크릿/토큰 금지, 변경 요지만 |
인덱스: (engagement_id, seq), (subject_type, subject_id). 스키마 필드 seq 는 read_after_writes: true, writable: :never (앱이 seq 를 보내지 않음).
4. Append-only 감사 로그
4.1 쓰기 규칙
- 상태를 바꾸는 모든 컨텍스트 함수는
Ecto.Multi로 변경과 이벤트를 함께 커밋한다.Repo.transaction/2는 deprecated 이므로Repo.transact/2를 쓴다 (Multi 를 그대로 받음). - PubSub broadcast 는
Repo.transact가{:ok, _}를 돌려준 뒤에만 한다 (롤백된 변경을 알리지 않기 위해, 학습 포인트).
Ecto.Multi.new()
|> Ecto.Multi.insert(:conclusion, Conclusion.changeset(%Conclusion{}, attrs))
|> Ecto.Multi.update(:finding, Finding.status_changeset(finding, :concluded))
|> AuditTrail.append(:audit, fn %{conclusion: c} ->
%{engagement_id: finding.engagement_id, actor: AuditTrail.actor(scope),
action: "conclusion.submitted", subject_type: "finding", subject_id: finding.id,
payload: %{"conclusion_id" => c.id, "outcome" => to_string(c.outcome)}}
end)
|> Repo.transact()
append/3 는 Ecto.Multi.insert(multi, name, fn changes -> AuditEvent.changeset(fun.(changes)) end) 이다. AuditTrail 모듈에는 update/delete 함수를 만들지 않는다.
4.2 이벤트 목록 (초기)
payload 키는 문자열이다 (jsonb 왕복 뒤에도 같은 모양). 아래 payload 는 10/18 구현 기준이고, 빈 칸은 구현하면서 채운다.
| action | actor | subject | payload |
|---|---|---|---|
| engagement.created | user | engagement | client_name, fiscal_year, period_end_date (ISO 8601), performance_materiality (Decimal 문자열) |
| engagement.member_added | user | engagement | user_id, role |
| ledger_import.created | user | ledger_import | source_kind, original_filename, byte_size, sha256 |
| ledger_import.succeeded | system ("ledger.import_worker") | ledger_import | entry_count, line_count, total_debit, total_credit (Decimal 문자열), warning_count |
| ledger_import.failed | system ("ledger.import_worker") | ledger_import | reason (validation_failed, file_missing, worker_failed), error_count |
| test_run.started | user | test_run | ledger_import_id, rule_ids (실행 순서), triage_threshold |
| test_run_rule.failed | system ("testing.rule_worker") | test_run | rule_id. 원인 원문은 넣지 않는다 (내부 오류 문구가 감사 로그 화면에 나가지 않게) |
| test_run.completed | system ("testing.rule_worker") | test_run | status (completed / completed_with_errors), finding_count |
| ai_triage.requested | user | finding (건별) 또는 test_run (일괄) | 건별: entry_number. 일괄: enqueued (0건이면 이벤트를 남기지 않는다) |
| ai_triage.completed | agent ("triage:<response_model>") | finding | ai_triage_id, risk_level, reused (같은 입력의 기존 제안을 다시 붙였으면 true), entry_number |
| ai_triage.failed | system ("triage.triage_worker") | finding | reason (rate_limited, transient, invalid_output, refusal, not_configured, request_rejected, worker_crashed), entry_number. 오류 원문, HTTP 본문, 키는 넣지 않는다 |
| conclusion.submitted | user | finding | conclusion_id, outcome, ai_triage_id (없으면 null), entry_number |
| review.approved | user | finding | conclusion_id, review_decision_id, entry_number |
| review.rejected | user | finding | conclusion_id, review_decision_id, comment, entry_number |
| review.denied | user | finding (단독 record/1) |
reason (role = 작성자 역할의 검토 시도, self_review = 자기 결론 검토), attempted (approved / rejected / null), entry_number |
- 라운드 4 확정 (화면설계서 부록 B 제안 키 + entry_number). finding 이 대상인 이벤트는 모두
entry_number를 갖는다. S6 의 전표번호 필터 (C-13) 가 이 키로 거른다. 값은 컨텍스트가 DB 에서 읽는다. ai_triage.failed의 행위자는 항상 system 이다. 실패를 판정하고 기록하는 쪽은 워커이고, 재시도 소진이나 크래시에는 모델 응답이 없다 (화면설계서 S6 의 "agent 인지 system 인지 확인 필요" 해결).review.denied는 거부 결과와 같은 트랜잭션에 넣을 변경이 없으므로record/1로 따로 남긴다 (self_review 는 롤백 뒤).
4.3 DB 강제: 트리거
CREATE FUNCTION tickmark_reject_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'append-only table %: % is not allowed', TG_TABLE_NAME, TG_OP;
END $$;
CREATE TRIGGER audit_events_no_update_delete BEFORE UPDATE OR DELETE ON audit_events
FOR EACH ROW EXECUTE FUNCTION tickmark_reject_mutation();
CREATE TRIGGER audit_events_no_truncate BEFORE TRUNCATE ON audit_events
FOR EACH STATEMENT EXECUTE FUNCTION tickmark_reject_mutation();
- 같은 함수로
ai_triages,conclusions,review_decisions에도 UPDATE/DELETE 트리거를 건다 (설계상 insert-only 인 증적 테이블). - 마이그레이션은
execute(up_sql, down_sql)로 작성해 rollback 가능하게 한다. - 테스트: Ecto SQL Sandbox 는 롤백으로 정리하므로 트리거와 충돌하지 않는다. "UPDATE 시도 시
Postgrex.Error발생" 테스트를 하나 둔다.
4.4 대안 비교
| 방식 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 앱 규율만 (함수 미제공) | 0 비용 | Repo.update_all 한 줄로 우회, 증명 불가 |
기본으로 깔고 단독으론 불충분 |
| 트리거로 거부 | 마이그레이션 하나, 모든 클라이언트에 적용, 테스트로 증명 가능 | 슈퍼유저/소유자는 트리거 drop 가능 | 채택 |
| 권한 분리 (REVOKE UPDATE, DELETE) | 표준 SQL 권한 모델 | 앱 DB 롤과 마이그레이션 롤 분리 필요, 배포 설정 복잡 | 2단계 (운영 DB 롤 분리 시) |
| 해시 체인 (prev_hash) | 사후 변조 탐지 | 동시 insert 직렬화 필요, 검증 도구 필요 | 2단계 |
5. Oban 설계
5.1 설정 (config 는 메인 세션 소유)
# config/config.exs
config :tickmark, Oban,
engine: Oban.Engines.Basic,
repo: Tickmark.Repo,
queues: [ingest: 1, rules: 4, llm: 2],
pruner: [max_age: {7, :days}],
lifeline: [rescue_after: {30, :minutes}]
# config/test.exs
config :tickmark, Oban, testing: :manual
# application.ex children (Repo 다음)
{Oban, Application.fetch_env!(:tickmark, Oban)}
- Oban 2.24 는
Oban.Plugins.*대신 최상위pruner:/lifeline:키를 쓴다 (2.24 문서 기준). 키 이름이 컴파일 경고 없이 동작하는지는 첫 부팅에서 확인 필요. - 마이그레이션:
Oban.Migration.up(version: 14)/down(version: 1). - rules 동시성 4: dev Repo
pool_size: 10에서 웹 요청 몫을 남긴다. OSS 동시성은 노드 단위 제한이다 (글로벌 제한은 Pro). 단일 노드 배포이므로 사실상 전역 제한.
5.2 Worker
| Worker | queue | args | max_attempts | backoff | timeout | unique |
|---|---|---|---|---|---|---|
Tickmark.Ledger.ImportWorker |
ingest | %{"ledger_import_id" => id} |
3 | 기본 (지수, 최소 15s + jitter) | 10분 | 미사용 |
Tickmark.Testing.RuleWorker |
rules | %{"test_run_id" => id, "rule_id" => rid} |
3 | 기본 | 5분 | 미사용 |
Tickmark.Triage.TriageWorker |
llm | %{"finding_id" => id} |
5 | 10 * 2^attempt 초 |
3분 | 미사용 |
unique 를 쓰지 않는 이유: OSS uniqueness 는 DB 제약이 아닌 조회 기반이라 경합에 약하고, Basic 엔진에서 unique job 은 insert/3 로 하나씩 넣어야 한다 (bulk unique 는 Pro). 대신 job 을 만드는 트랜잭션 안에서 DB 상태 전이로 중복을 막는다: import 는 행 생성과 같은 Multi, run 은 partial unique index, triage 는 triage_status 조건부 update 로 전이된 행에만 job 생성. 워커는 시작 시 상태를 보고 이미 끝났으면 :ok (멱등).
perform 반환 규약 (Oban 2.24)
- 성공
:ok, 재시도 가능한 실패{:error, reason}, 영구 실패{:cancel, reason}(먼저 도메인 상태를 failed 로 기록), 대기{:snooze, seconds}.:discard는 deprecated 라 쓰지 않는다. - 2.24 부터 snooze 는 attempt 를 소모하지 않고
job.meta["snoozed"]를 증가시킨다.
5.3 Fan-out
jobs = Enum.map(rule_ids, &RuleWorker.new(%{"test_run_id" => run_id, "rule_id" => &1}))
Ecto.Multi.new()
|> Ecto.Multi.insert(:test_run, TestRun.create_changeset(...)) # run_id 는 미리 Ecto.UUID.generate()
|> Ecto.Multi.insert_all(:rules, TestRunRule, rule_rows)
|> AuditTrail.append(:audit, fn _ -> ... "test_run.started" ... end)
|> Oban.insert_all(:jobs, jobs)
|> Repo.transact()
job 단위는 (run, rule) 하나. 룰 6-8개면 job 6-8개. partial unique index 위반은 {:error, :run_in_progress} 로 변환.
start_test_run/3 순서 (라운드 3 확정)
Engagements.authorize(scope, import.engagement_id, :start_test_run). 실패면{:error, :forbidden}.- import 를 DB 에서 다시 읽는다.
status != :succeeded면{:error, :import_not_ready}(화면이 들고 있던 struct 는 낡았을 수 있다). rule_ids확인::all은Rules.ids(). 리스트는 각 id 가Rules.fetch/1로 찾아져야 하고 비어 있으면 안 된다. 어기면ArgumentError.- 위 Multi.
test_runs:rule_count,triage_threshold(스키마 기본값 25, 도메인 3.2),started_at.test_run_rules: 룰마다params = module.default_params(),status: :queued. 감사 이벤트 payload 는 4.2. - 커밋 뒤
Engagements.broadcast(engagement_id, {:test_run_updated, ...})(6장).
5.4 완료 감지 (Pro Batch 없이)
Testing.run_rule/2 의 성공 경로, 한 트랜잭션:
Repo.transact(fn ->
run = Repo.one!(from r in TestRun, where: r.id == ^run_id, lock: "FOR UPDATE")
{1, _} = mark_rule(run_id, rule_id, from: [:queued, :running], to: :succeeded, hit_count: length(hits))
insert_hits_in_chunks(run_id, rule_id, hits) # rule_results, 1000행 chunk
pending = Repo.aggregate(from(x in TestRunRule,
where: x.test_run_id == ^run_id and x.status in [:queued, :running]), :count)
if pending == 0 and run.status == :running, do: finalize!(run), else: {:ok, :progress}
end)
- 락을 먼저 잡는다 (10/20 수정, 워커 A 질문). 전에는 rule_results 를 넣은 뒤 락을 잡았다. PostgreSQL 은 자식 행을 넣을 때 FK 검사로 부모
test_runs행에FOR KEY SHARE를 잡고,FOR UPDATE는 이것과 충돌한다. 두 룰이 동시에 끝나면 서로의 KEY SHARE 를 기다려 교착된다. 락-마크-insert 순서면 두 번째 트랜잭션은 처음부터 기다린다.mark_rule이 0행이면 hits 를 넣기 전에 롤백한다.FOR NO KEY UPDATE(KEY SHARE 와 충돌하지 않아 insert 를 병렬로 둘 수 있다) 도 검토했지만, 설명하기 쉬운 "락 먼저" 규칙을 택했다. MVP 규모에서 룰 결과 insert 를 직렬화하는 비용은 작다. FOR UPDATE로 같은 run 을 끝내는 트랜잭션들이 직렬화된다. READ COMMITTED 에서 뒤에 락을 얻은 쪽의 count 는 앞선 커밋을 보므로 정확히 하나만 pending == 0 을 본다.run.status == :running가드로 재시도 시 중복 finalize 도 막는다 (학습 포인트).mark_rule이 0행을 바꾸면 (이미 terminal) 롤백 후{:ok, :noop}. (10/19 수정: 전에는:skipped로 적혀 있었는데 2.1 의:skipped는 "룰이 실행 조건 미달" 이라 뜻이 겹쳤다)finalize!/1:insert_all(Finding, query)로 rule_results 를(journal_entry_id)기준 집계 (count,LEAST(100, sum(score)),array_agg(rule_id ORDER BY rule_id)),test_runs를 completed (failed 룰 0개) 또는 completed_with_errors 로,finding_count,finished_at,test_run.completed이벤트. finding 의rule_ids는 알파벳 순이고 화면 표시 순서 (레지스트리 순) 는TickmarkWeb.Format이 정한다.- 실패 경로
fail_rule/3도 같은 락-마크-카운트-finalize 순서를 쓴다.
run_rule/2 전체 흐름 (라운드 3 확정)
test_run_rules행과 run 을 읽는다. 행이 terminal 이거나 run 이 running 이 아니면{:ok, :noop}.queued -> running으로 바꾸고started_at을 채운 뒤 (재시도로 이미 running 이면 그대로) 커밋하고:rule_progress를 보낸다. 룰 실행은 이 트랜잭션 밖이다 (긴 쿼리 동안 run 행을 잠그지 않는다).RuleContext(engagement preload,params는 저장된 스냅샷) 로module.run/1호출.{:ok, hits}: 위 성공 경로.rule_results.score = module.weight(). 반환{:ok, :done}.{:skip, reason}: 같은 락-마크-카운트-finalize 순서로skipped,hit_count 0,error에 reason. 반환{:ok, :skipped}.{:error, reason}: DB 를 바꾸지 않고{:error, reason}. 재시도할지는 RuleWorker 가 정한다. raise 는 잡지 않는다 (Oban 이 재시도하고 마지막 시도는 FailureHandler 가 처리).- 상태가 바뀐 트랜잭션이 커밋된 뒤에만 broadcast (6장). 롤백된 변경을 화면이 보지 않게 한다.
test_run_rules.error 컬럼: skipped 면 룰이 준 한국어 reason (화면에 보여도 된다), failed 면 inspect(reason) 을 500자로 자른 개발자용 문자열 (화면에는 고정 문구만 보이고 이 값은 내보내지 않는다).
최종 실패 처리
- 룰이
{:error, _}를 반환하고job.attempt >= job.max_attempts이면 워커가fail_rule/3후{:cancel, reason}. - raise / timeout 으로 마지막 시도가 끝나는 경우:
Tickmark.Jobs.FailureHandler가[:oban, :job, :exception]telemetry 를 구독해attempt >= max_attempts이면 worker 문자열별로Ledger.mark_import_failed/2,Testing.fail_rule/3,Triage.mark_failed/2(M3 에 추가) 를 호출. 핸들러가 raise 하면 telemetry 가 핸들러를 떼어내므로 내부에서 에러를 로깅만 한다. - 붙이는 위치:
Tickmark.Application.start/2가Supervisor.start_link전에:ok = Tickmark.Jobs.FailureHandler.attach()를 부른다 (메인 선행 작업, application.ex 는 메인 소유).attach/0은 핸들러 id 를 고정해 두 번 불려도:ok({:error, :already_exists}를:ok로). 워커 A 는failure_handler.ex만 고친다. {:error, _}를 돌려준 마지막 시도도 exception 이벤트를 낼 수 있어 워커와 핸들러가 둘 다fail_rule/3을 부를 수 있다. 그래서fail_rule/3과mark_import_failed/2는 멱등이어야 한다 (이미 terminal 이면 아무것도 하지 않음).- 확인 필요: exception 이벤트의
state메타데이터 값 (:failure/:discard) 정확한 매핑. 판단은 state 대신attempt >= max_attempts로 한다. - 알려진 한계: Lifeline 이 attempts 소진된 고아 job 을 discard 하면 이벤트가 없어 run 이 running 으로 남을 수 있다. MVP 는 단일 노드라 드물다. 2단계에
Testing.reconcile_run/1.
5.5 LLM 큐와 429
- Req 는
retry: false로 두고 재시도 정책은 Oban 하나로 통일 (Req 기본:safe_transient은 GET/HEAD 만 재시도하고 529 를 포함하지 않는다). receive_timeout: 120_000(Req 기본 15_000ms 는 thinking 응답에 짧다, req 0.7.5 문서 확인).
| 응답 | AI.Client 에러 | Worker 반환 |
|---|---|---|
| 429 rate_limit_error | {:rate_limited, retry_after_s | nil} |
{:snooze, retry_after_s || 30}; meta["snoozed"] >= 10 이면 {:error, :rate_limited} |
500, 529 overloaded, 전송 오류, stop_reason: "max_tokens" |
{:retryable, reason} |
{:error, reason} (backoff) |
400, 401, 404, 413, stop_reason: "refusal", 스키마 불일치 |
{:fatal, reason} |
Triage.mark_failed/2 후 {:cancel, reason} |
- 동시성 2 x 노드 1 = 동시 호출 최대 2. 일괄 요청은
score >= triage_threshold인 finding 중 최대max_findings_per_request(기본 100, 도메인 3.2triage_max_entries) 건을 score 내림차순, 전표 금액 내림차순으로 고른다. 트리아지는 자동 실행하지 않고 preparer 가 버튼으로 요청한다 (비용 통제, 요청자가 감사 로그에 남음). - 출력이 스키마를 어기거나 도메인 4.3 의 금지 표현 (결론 단정 등) 이 있으면
{:fatal, :invalid_output}으로 실패 처리한다. 자동 재시도는 없고 사용자가 건별 재요청한다 (실패는ai_triages행을 남기지 않으므로 재요청이 성공하면 새 행이 생긴다).
5.5.1 Triage 순서 (라운드 4 확정)
공통: finding 행을 FOR UPDATE 로 먼저 잠근 뒤 자식 행 (ai_triages, Oban job) 을 넣는다 (5.4 와 같은 이유. 자식 insert 의 FK 검사가 부모에 FOR KEY SHARE 를 건다). broadcast 는 커밋 뒤에만 한다 (finding_updated, AuditTrail.broadcast_appended/1).
request_finding_triage/2
- finding 과 run 조회 (형식이 틀린 id 는 조회 전에
:forbidden).Engagements.authorize(scope, engagement_id, :request_triage). - 트랜잭션: finding
FOR UPDATE->score < run.triage_threshold면:below_threshold->triage_status == :queued면:already_queued->Finding.triage_status_changeset(:queued)->Oban.insert(TriageWorker.new(%{"finding_id" => id}))->ai_triage.requested(append). - 커밋 뒤
{:finding_updated, ...},audit_appended. 반환은 갱신된 finding.
request_triage/2 (일괄)
- run 조회, authorize. run 이 아직 running 이면 finding 이 없으므로 결과는 0건이다 (따로 오류를 두지 않는다).
- 트랜잭션: 후보 id (
score >= triage_threshold,triage_status == :none, score 내림차순,journal_entries.total_debit내림차순,entry_number오름차순,max_findings_per_request건) ->update_all에where triage_status == :none를 다시 걸고select: f.id로 실제로 바뀐 id 만 받는다 (동시 요청이 같은 finding 을 두 번 넣지 않는다. READ COMMITTED 는 잠금을 기다린 뒤 WHERE 를 다시 평가한다) -> 그 id 로Oban.insert_all-> 1건 이상이면ai_triage.requested(subject test_run,enqueued). - 커밋 뒤
{:triage_requested, %{test_run_id, enqueued}}(0건이어도 보낸다) 와 이벤트가 있으면audit_appended. finding 마다finding_updated를 보내지 않는다 (100건이면 화면이 100번 다시 읽는다. S4 는triage_requested에 한 번 다시 읽는다).
triage_finding/1 (TriageWorker 전용)
- finding (journal_entry 와 lines, test_run 과 engagement) 과 그 전표의 rule_results 를 읽는다.
triage_status != :queued면 API 를 부르지 않고{:ok, :noop}. input = build_input(finding, rule_results),input_hash = AI.input_hash(input),model = AI.model(),prompt_version = Tickmark.AI.Prompts.Triage.version().- 같은
(finding_id, prompt_version, model, input_hash)행이 있으면 API 를 부르지 않고 그 행을 다시 붙인다 (reused: true). 없으면 트랜잭션 밖에서AI.triage(input)을 부르고System.monotonic_time/1로latency_ms를 잰다.{:error, e}면 DB 를 바꾸지 않고 그대로 돌려준다. - 트랜잭션: finding
FOR UPDATE-> queued 가 아니면:noop-> 새 결과면ai_triagesinsert (on_conflict: :nothing, 위 4열 conflict_target. 충돌하면 기존 행을 다시 읽는다) ->triage_status :succeeded->ai_triage.completed(actor{:agent, "triage:" <> response_model}). - 커밋 뒤
finding_updated,audit_appended.
mark_failed/2: 트랜잭션에서 finding FOR UPDATE -> queued 일 때만 triage_status :failed + ai_triage.failed. 아니면 아무것도 하지 않는다 (워커와 FailureHandler 가 둘 다 부를 수 있다). 바뀐 경우에만 커밋 뒤 broadcast. reason 분류 (payload 에는 이 문자열만 남긴다):
| 받은 reason | payload reason |
|---|---|
{:rate_limited, _}, :rate_limited |
rate_limited |
{:retryable, _} |
transient |
{:fatal, :invalid_output} |
invalid_output |
{:fatal, :refusal} |
refusal |
{:fatal, :missing_api_key} |
not_configured |
그 밖의 {:fatal, _} |
request_rejected |
| 그 밖 (raise, timeout, FailureHandler) | worker_crashed |
Tickmark.Triage.TriageWorker: use Oban.Worker, queue: :llm, max_attempts: 5, timeout/1 3분 (receive_timeout 120초보다 길게), backoff/1 는 10 * 2 ** attempt 초, args %{"finding_id" => id}.
triage_finding/1 결과 |
반환 |
|---|---|
{:ok, _} |
:ok |
{:error, {:rate_limited, s}}, meta["snoozed"] 10 미만 |
{:snooze, s || 30} |
{:error, {:rate_limited, _}}, snooze 10회 이상 |
아래 retryable 과 같게 (reason :rate_limited) |
{:error, {:retryable, r}}, 마지막 시도가 아님 |
{:error, r} (backoff) |
{:error, {:retryable, r}}, attempt >= max_attempts |
mark_failed/2 후 {:cancel, r} |
{:error, {:fatal, r}} |
mark_failed/2 후 {:cancel, r} |
- raise 나 timeout 으로 끝난 마지막 시도는 FailureHandler 의
"Tickmark.Triage.TriageWorker"절이Triage.mark_failed(id, :worker_crashed)를 부른다 (W1 이failure_handler.ex에 추가). - snooze 동작 (10/20 W1 이
deps/obanBasic 엔진snooze_job으로 확인):attempt를 1 줄이고meta.snoozed를 1 늘리며 state 를scheduled로 돌린다.max_attempts는 그대로라 snooze 는 시도 횟수를 소진하지 않는다. 그래서 rate limit 이 job 을 붙잡는 상한은meta["snoozed"]10회로 둔다.triage_worker_test.exs가Oban.Engine.snooze_job을 직접 불러 세 값을 고정한다.
6. PubSub 토픽과 메시지
PubSub 서버: Tickmark.PubSub. 토픽 문자열은 아래 함수에서만 만든다.
| 토픽 | 생성 함수 | 구독자 | 메시지 |
|---|---|---|---|
"engagement:#{engagement_id}" |
Engagements.topic/1 |
EngagementLive.Show, ImportLive, AuditLive | 아래 표 |
"test_run:#{test_run_id}" |
Testing.run_topic/1 |
TestRunLive | 아래 표 |
"engagement:#{engagement_id}:presence" |
TickmarkWeb.Presence.topic/1 |
모든 engagement 하위 LiveView | %Phoenix.Socket.Broadcast{event: "presence_diff"} |
# "engagement:#{id}"
{:import_updated, %{import_id: id, status: atom, entry_count: int, error_count: int}}
{:test_run_updated, %{test_run_id: id, status: atom, finding_count: int}}
{:finding_updated, %{finding_id: id, status: atom, triage_status: atom}}
{:audit_appended, %{seq: int, action: String.t(), actor_type: atom, subject_type: String.t(),
subject_id: id, entry_number: String.t() | nil}} # AuditTrail.broadcast_appended/1 로만 보낸다 (C-13)
{:triage_requested, %{test_run_id: id, enqueued: non_neg_integer()}} # 일괄 트리아지 요청 (5.5.1). 0건이어도 보낸다
# "test_run:#{id}"
{:rule_progress, %{test_run_id: id, rule_id: String.t(), status: atom, hit_count: int,
done: int, total: int}}
{:run_completed, %{test_run_id: id, status: atom, finding_count: int}}
- 보내는 시점 (라운드 3 확정):
:test_run_updated는 run 시작, 룰 하나가 running 이나 terminal 이 될 때, run 완료 때 (S2 의 "룰 완료 수" 를 갱신).:rule_progress는 룰이 running 이 될 때와 terminal 이 될 때.done은 terminal 룰 수,total은rule_count.:run_completed는 finalize 커밋 뒤 한 번. - 보내는 시점 (라운드 4 확정):
:audit_appended는 감사 이벤트를 넣은 트랜잭션이 커밋된 뒤 이벤트마다 한 번. 기존 호출 지점 (engagements, ledger, runner) 도 라운드 4 W5 가 맞춘다. runner 처럼 트랜잭션 안에서record/1을 부르는 곳은 이벤트를 트랜잭션 결과로 꺼내 커밋 뒤에 보낸다.:finding_updated는 finding 의 status 나 triage_status 를 바꾼 트랜잭션이 커밋된 뒤. - engagement 토픽 구독자는 위 메시지를 모두 처리해야 한다. 처리하지 않는 메시지가 오면 LiveView 프로세스가 죽는다. 라운드 4 선행 작업에서 S2, S3, S4 에
:triage_requested무시 절을 넣었고, S5 와 S6 은 처음부터 모든 메시지를 처리한다. - 메시지는 구조체가 아닌 작은 맵 (노드 간 직렬화와 스키마 결합을 피함). LiveView 는 필요하면 컨텍스트로 다시 읽는다.
- 구독은
subscribe/2류 함수로만 하며 멤버십을 확인한다. - Presence:
TickmarkWeb.Presence(use Phoenix.Presence, otp_app: :tickmark, pubsub_server: Tickmark.PubSub, application.ex 에 child 추가). key = user_id, meta =%{email, role, view}.mix phx.gen.presence사용 여부는 메인 세션 판단.
7. AI 클라이언트
defmodule Tickmark.AI.Client do
@type input :: map() # Triage.build_input/1 결과 (정규화됨)
@type result :: %{risk_level: :low | :medium | :high, rationale: String.t(),
suggested_procedures: [%{procedure: String.t(), evidence_to_obtain: String.t()}],
missing_information: [String.t()], response_model: String.t(),
stop_reason: String.t(), input_tokens: integer(), output_tokens: integer()}
@type error :: {:rate_limited, pos_integer() | nil} | {:retryable, term()} | {:fatal, term()}
@callback triage(input(), keyword()) :: {:ok, result()} | {:error, error()}
@callback model() :: String.t() # 라운드 4 선행 작업. 요청할 모델 id
end
defmodule Tickmark.AI do
def client, do: Application.fetch_env!(:tickmark, __MODULE__) |> Keyword.fetch!(:client)
def config, do: Application.fetch_env!(:tickmark, __MODULE__)
def triage(input, opts \\ []), do: client().triage(input, opts)
def model, do: client().model() # Fake: "fake-triage-v1", Anthropic: config :model
@spec input_hash(map()) :: String.t() # 소문자 hex 64자. 7.4
end
ai_triages.model에는AI.model/0(요청한 모델) 을,response_model에는 API 가 돌려준 모델을 넣는다. Fake 의model/0은 실제 모델 id 가 아니므로 Fake 로 만든 제안이 유일 키(finding_id, prompt_version, model, input_hash)로 실제 모델 제안 자리에 다시 붙지 않는다.
파일 (라운드 3, 워커 D): lib/tickmark/ai.ex, lib/tickmark/ai/client.ex, lib/tickmark/ai/anthropic.ex, lib/tickmark/ai/fake.ex, lib/tickmark/ai/prompts/triage.ex. 출력 검증 (enum, 필수 키, 추가 키 금지, 도메인 4.3 금지 표현, 길이 자르기) 은 Anthropic 응답에만 적용하고 위반이면 {:error, {:fatal, :invalid_output}}. Fake 출력은 항상 유효하게 만든다.
7.1 Anthropic 구현 (Req, 공식 Elixir SDK 없음 -> raw HTTP)
def triage(input, _opts) do
cfg = Tickmark.AI.config()
body = %{
model: cfg[:model], max_tokens: cfg[:max_tokens],
system: Prompts.Triage.system(),
messages: [%{role: "user", content: Prompts.Triage.user_message(input)}],
output_config: %{effort: cfg[:effort],
format: %{type: "json_schema", schema: Prompts.Triage.output_schema()}}
}
|> maybe_put(:fallbacks, cfg[:server_side_fallbacks] && "default")
[url: "https://api.anthropic.com/v1/messages", json: body, retry: false,
receive_timeout: 120_000,
headers: [{"x-api-key", cfg[:api_key]}, {"anthropic-version", "2023-06-01"}] ++ beta_headers(cfg)]
|> Keyword.merge(cfg[:req_options] || [])
|> Req.post()
|> handle_response()
end
handle_response/1: 200 이면stop_reason먼저 검사 ("end_turn"만 정상),content에서type: "text"블록만 골라Jason.decode/1, enum 검증. fallback 블록 등 다른 타입은 무시.model필드를response_model로 저장. 429 는retry-after헤더(초) 파싱.- 모델 id 는 코드에 쓰지 않는다. config 기본값
"claude-opus-5-5",ANTHROPIC_MODEL로 교체. - Opus 5.5 주의: thinking 이 항상 켜져 있어
thinking파라미터를 보내지 않는다 (budget_tokens는 400). assistant prefill 금지. thinking 토큰이 max_tokens 에 포함되므로max_tokens: 8000, 분류 작업이라effort: "low". server_side_fallbacks: true이면fallbacks: "default"+ 헤더anthropic-beta: server-side-fallback-2026-07-01. 정책 거절에만 동작하고 429/529 에는 동작하지 않는다. 기본값은 false (opt-in).- 확인 필요:
output_config안에서effort와format을 함께 보내는 조합, low effort 의 분류 품질, 위 fallback 파라미터와 beta 헤더 이름, thinking 기본 동작. 첫 실호출에서 확인하고 이 문서에 결과를 남긴다. - 출력 스키마 (도메인 4.2,
confidence는 제외):{risk_level: enum[low,medium,high], rationale: string, suggested_procedures: array<{procedure: string, evidence_to_obtain: string}>, missing_information: array<string>},required전부,additionalProperties: false. LLM 이 스스로 매긴 확신도는 보정되지 않아 감사인을 오도할 수 있으므로 받지 않는다. 길이 제약은 스키마가 지원하지 않으므로 앱에서 자른다. - 프롬프트 캐싱은 2단계 (최소 프리픽스 길이 미달 가능성).
7.2 Fake 구현
Tickmark.AI.Fake.triage/2:Tickmark.AI.input_hash(input)의 첫 바이트로 risk_level 결정 (rem(byte, 3)), rationale 은 입력의 rule_ids 로 만든 고정 문장,response_model: "fake-triage-v1"(도메인 4.2 의 fake model_id. 버전을 붙여 Fake 로직이 바뀌면 이전 결과와 구분하고 실제 모델로 오인되지 않게 한다),stop_reason: "end_turn", 토큰 0. 같은 입력이면 항상 같은 출력.- 테스트에서 실패 경로는 Anthropic 구현을
Req.Test로 검증:config :tickmark, Tickmark.AI, req_options: [plug: {Req.Test, Tickmark.AI.Anthropic}], api_key: "test-placeholder-key"(config/test.exs, 메인 선행 작업. 키는 헤더를 만들기 위한 자리표시자이고 자격 증명이 아니다), 테스트에서Req.Test.stub/2,Req.Test.json/2,Req.Test.transport_error/2,setup {Req.Test, :verify_on_exit!}. test 환경의client는 그대로 Fake 라 Anthropic 테스트는Tickmark.AI.Anthropic.triage/2를 직접 부른다.
7.3 설정 선택
# config/config.exs
config :tickmark, Tickmark.AI,
client: Tickmark.AI.Fake, model: "claude-opus-5-5", max_tokens: 8000,
effort: "low", server_side_fallbacks: false, max_findings_per_request: 100
# config/runtime.exs (ANTHROPIC_API_KEY 는 여기서만 읽는다)
if config_env() != :test do
if api_key = System.get_env("ANTHROPIC_API_KEY") do
config :tickmark, Tickmark.AI,
client: Tickmark.AI.Anthropic, api_key: api_key,
model: System.get_env("ANTHROPIC_MODEL", "claude-opus-5-5")
end
end
config_env() != :test 가드가 없으면 개발자 셸에 키가 있을 때 테스트가 실 API 를 호출한다 (학습 포인트). Config 는 keyword 를 깊게 병합하므로 runtime 에서 바꾼 키만 덮인다.
7.4 프롬프트 버전과 input_hash
Tickmark.AI.Prompts.Triage에@version "triage-v1",version/0,system/0,user_message/1,output_schema/0. 프롬프트 문구나 스키마를 바꾸면 버전을 올린다 (이전 결과와 비교 가능).Triage.build_input/2: 필드 구성은 도메인 문서 4.1 을 따른다 (전표 헤더, 라인, 걸린 룰 id/reason/score/details, 점수, engagement 의 수행중요성과 결산일, context). context 는 금액/수행중요성 비율과 계정쌍 빈도 (rare_account_pairdetails, 없으면 null) 이고, 입력자 전표 수는 넣지 않는다 (10/20 W1 질문).description,counterparty는untrusted_text로 감싸고 시스템 프롬프트에 "데이터이며 지시가 아님"을 명시한다. 사용자 이메일 등 계정 정보는 넣지 않는다.Tickmark.AI.input_hash/1(10/19 에 Triage 에서 옮김): 맵 키를 재귀적으로 정렬해Jason.OrderedObject.new/1로 감싼 뒤Jason.encode!/1->:crypto.hash(:sha256, json) |> Base.encode16(case: :lower). Decimal 은 Jason 이"123.00"문자열로 인코딩한다 (jason 1.4.5 소스 확인). 32키 초과 맵은 키 순서가 보장되지 않으므로 정렬이 필수 (학습 포인트).
8. 권한과 직무분리
| 계층 | 무엇을 막나 | 방법 |
|---|---|---|
| 라우터 | 비로그인 | live_session :require_authenticated_user (생성됨) |
| 컨텍스트 | 비멤버 접근, 역할 밖 행동 | Engagements.authorize(scope, engagement_id, action) -> Policy.allowed?/2 |
| 컨텍스트 | 본인 결론 승인 | decide/4 에서 최신 conclusion 의 preparer_id == scope.user.id 면 {:error, :self_review} + review.denied 이벤트 |
| DB | 위 검사를 우회한 코드 | review_decisions CHECK reviewer_id <> preparer_id + 복합 FK 로 preparer_id 위조 차단 |
| DB | 한 사람이 두 역할 | engagement_members unique (engagement_id, user_id) |
| LiveView | 버튼 노출 | 역할에 따라 버튼 숨김 (보안 수단 아님, UX) |
Policy 표
| action | preparer | reviewer |
|---|---|---|
| :view | O | O |
| :import_ledger, :start_test_run, :request_triage, :submit_conclusion | O | X |
| :review_conclusion | X | O |
| :manage_members | engagement.created_by_id 만 | X |
submit_conclusion/3 순서 (라운드 4 확정)
- finding 과 run 조회,
authorize(:submit_conclusion). 비멤버와 검토자는:forbidden. - 트랜잭션: finding
FOR UPDATE-> status:approved면:invalid_state->Conclusion.changeset(finding_id,preparer_id = scope.user.id는 컨텍스트가 넣는다) ->ai_triage_id가 있으면 같은 finding 의ai_triages행인지 확인하고 아니면:ai_triage_idchangeset 오류 -> insert ->Finding.status_changeset(:concluded)(open, concluded, rejected 에서 가능) ->conclusion.submitted. - 커밋 뒤
finding_updated,audit_appended. concluded 에서 다시 내면 새 결론이 쌓이고 검토자는 최신 결론을 결정한다.
decide/4 순서 (라운드 4 확정, C-10, C-11 반영)
- finding 과 run 조회.
Engagements.fetch_role/2가 비멤버면:forbidden이고 이벤트를 남기지 않는다 (감사 대상이 있는지 숨긴다). - 역할에
:review_conclusion이 없으면 (작성자)review.denied(reasonrole) 를record/1로 남기고 broadcast 한 뒤:forbidden(C-10. 작성자의 시도도 기록). - 트랜잭션: finding
FOR UPDATE-> status !=:concluded면:invalid_state-> 최신 conclusion (inserted_at 내림차순 첫 행, usec) -> 그 id 가 인자conclusion_id와 다르면:stale(C-11) ->preparer_id == scope.user.id면 롤백하고 트랜잭션 밖에서review.denied(reasonself_review) 를record/1로 남긴 뒤:self_review->ReviewDecision.changeset(finding_id, conclusion_id, preparer_id 는 conclusion 에서 복사, reviewer_id = scope.user.id) insert ->Finding.status_changeset(:approved | :rejected)->review.approved또는review.rejected. - 커밋 뒤
finding_updated,audit_appended.
review.denied의attempted는 attrs 의"decision"이"approved"/"rejected"면 그 값, 아니면 null.- changeset 에
check_constraint(:reviewer_id, name: :reviewer_not_preparer),check_constraint(:comment, name: :comment_required_on_reject)와 FK constraint 를 매핑해 DB 오류도 changeset 오류로 돌린다 (스키마에 이미 있다). - SoD 테스트는 컨텍스트 경로 (role, self_review, stale) 와 DB backstop 을 따로 증명한다. backstop 은 컨텍스트를 우회해
Repo.insert로 넣어 CHECKreviewer_not_preparer, 복합 FK(conclusion_id, preparer_id) -> conclusions(id, preparer_id)(preparer_id 위조),comment_required_on_reject가 각각 거부하는지 본다.
9. CSV 적재
9.1 단계
- LiveView
allow_upload(:ledger, accept: ~w(.csv), max_entries: 1, max_file_size: 20_000_000)(LiveView 기본 8_000_000 바이트, 1.2.12 소스 확인). consume_uploaded_entries콜백 안에서Ledger.create_import/3: 파일을 import 디렉터리의<uuid>.csv로File.cp!, sha256 계산, Multi 로 import 행 + audit +ImportWorkerjob. 업로드 임시 파일은Plug.Upload.random_file/1로 만들어져 업로드 프로세스 종료 시 정리되므로 콜백 안에서 복사해야 한다 (정확한 삭제 시점은 확인 필요).- import 디렉터리:
config :tickmark, :import_dir를 runtime.exs 에서 envTICKMARK_IMPORT_DIR로 설정, 없으면Path.join(System.tmp_dir!(), "tickmark_imports"). DB 에는 상대 파일명만 저장. Azure Blob 은 2단계. ImportWorker->Ledger.process_import/1: status pending|processing 일 때만 진행, processing 으로 전이 + broadcast, 이후 하나의Repo.transact에서 전체 적재. 실패하면 전부 롤백하고 별도 트랜잭션으로 failed + errors 기록 (all-or-nothing).
9.2 파싱과 검증
path
|> File.stream!([:trim_bom]) # 줄 단위 (parse_stream 은 줄 단위 enumerable 을 기대), BOM 제거
|> NimbleCSV.RFC4180.parse_stream(skip_headers: false)
|> Stream.with_index(1)
- 첫 행은 헤더: 16개 컬럼 이름 집합이 계약과 정확히 일치해야 한다 (순서 무관, 이름으로 인덱스 매핑). 불일치 시 즉시 failed.
- NimbleCSV 필드는 원본 바이너리의 sub-binary 이므로 보관하는 문자열은
:binary.copy/1(메모리 누수 방지, 학습 포인트). 잘못된 따옴표는NimbleCSV.ParseError-> 해당 행 번호로 오류 기록 후 중단. Stream.chunk_by(&entry_number/1)로 전표 단위 그룹. 이미 본 entry_number 가 다시 나오면 오류 (MapSet추적) -> 같은 전표의 라인은 연속해야 한다 (계약 추가 요청).- 행 검증: 필수값,
Date.from_iso8601/1,NaiveDateTime.from_iso8601/1(YYYY-MM-DD HH:MM:SS. from_iso8601 은 오프셋을 조용히 무시하므로 길이 19자 확인을 먼저 하고, 오프셋이 붙으면invalid_date), 정수,Decimal.parse/1후 소수부 0 (정수 원) 및<= 9_999_999_999_999_999.99, debit/credit 중 정확히 하나 > 0, fiscal_period 1-13, source enum. - 전표 검증: 차변합 == 대변합 (
Decimal.equal?/2), 헤더 필드 (posting_date, document_date, entered_at, fiscal_year, fiscal_period, entered_by, approved_by, source, counterparty) 가 라인 간 동일 (GL 계약), line_number 중복 없음. posting_date 연월과 fiscal_period 불일치는 오류가 아닌 경고 (warnings에period_mismatch, 13 제외). - 오류 코드:
missing_column,unknown_column(알 수 없거나 중복된 헤더 이름. 파일 내용이므로 이름은 되돌려 보이지 않는다),required,invalid_date,invalid_number,invalid_value(enum 밖의 값, 현재 source),too_long(셀 길이 초과),both_sides,unbalanced_entry,inconsistent_header,non_contiguous_entry,duplicate_line_number,too_many_rows,parse_error(따옴표 오류, 셀 개수 불일치, UTF-8 아님, NUL 문자). 경고 코드는period_mismatch하나. 오류 항목 모양은%{"line" => 행 번호 | nil, "field" => 컬럼 이름 | nil, "code" => 코드}. 최대 100건만 저장하고error_count는 전체 수. 사용자에게는 "N행: 차대 불일치" 같은 문장만 보이고 스택트레이스는 로그로만.
9.3 적재
- 전표 헤더와 라인을 각각 1000행 chunk 로
Repo.insert_all. 공통 값은placeholders: %{import_id: id, engagement_id: eid, now: now}와{:placeholder, :now}로 전달 (같은 타입 컬럼에만 사용). 라인 컬럼 10개 x 1000 = 10,000 파라미터로 PostgreSQL 한도 65,535 이내. - entry id 는 Elixir 에서 미리 생성해 라인에 넣는다 (RETURNING 왕복 불필요).
- 제한: 파일 20MB, 데이터 행 100,000, 셀 길이 description 500자 / 나머지 200자. 20,000행 기준 적재 시간을 10/18 에 측정해 devlog 에 남긴다.
10. 마이그레이션 순서
| # | 이름 | 내용 |
|---|---|---|
| 0 | 20261017093257_create_users_auth_tables |
생성됨 (users, users_tokens, citext) |
| 1 | add_oban_jobs_table |
Oban.Migration.up(version: 14) |
| 2 | create_engagements |
engagements, engagement_members |
| 3 | create_ledger |
ledger_imports, journal_entries, journal_lines |
| 4 | create_testing |
test_runs (+ partial unique), test_run_rules, rule_results, findings (gen_random_uuid() default) |
| 5 | create_triage |
ai_triages |
| 6 | create_review |
conclusions (unique (id, preparer_id)), review_decisions (복합 FK, CHECK) |
| 7 | create_audit_events |
audit_events (identity seq) |
| 8 | add_append_only_triggers |
함수 + audit_events/ai_triages/conclusions/review_decisions 트리거, execute/2 |
모든 마이그레이션은 10/18 오전 메인 세션이 한 번에 작성하고 mix ecto.migrate + mix ecto.rollback --all 왕복을 확인한 뒤 병렬 라운드를 연다.
11. 추가 의존성
| 패키지 | 버전 (hex.pm, 2026-10-17) | mix.exs 표기 | 상태 |
|---|---|---|---|
| oban | 2.24.2 | {:oban, "~> 2.24"} |
추가 |
| nimble_csv | 1.3.0 | {:nimble_csv, "~> 1.3"} |
추가 |
| req | 0.7.5 (lock) | {:req, "~> 0.5"} |
phx.new 포함 (swoosh 와 함께), 그대로 사용 |
| jason | 1.4.5 | 포함 | |
| decimal | 3.2.0 | ecto 의존성 | 직접 선언 불필요 |
| phoenix_pubsub | 2.4.1 | phoenix 의존성 | |
| lazy_html | 0.1.13 | 포함 (test) | LiveViewTest |
| bcrypt_elixir | 3.3.2 | phx.gen.auth 가 추가 | |
| oban_web | 2.13.1 | 보류 | Apache-2.0, oban_met 필요. 데모용 대시보드는 2단계 |
| ash | 3.36.1 | 사용 안 함 | ADR-4 |
12. 병렬 개발 계획 (10/18-10/21)
선행 조건 (메인 세션, 순차): mix.exs 의존성, config (Oban, AI, import_dir), application.ex (Oban, Presence child), 마이그레이션 0-8, 각 스키마 모듈 (lib/tickmark/**/<schema>.ex), AuditTrail 최소 구현 (append/3, actor/1), Engagements.Policy, router 의 live 경로. 스키마를 먼저 만드는 이유: 모든 워커가 같은 struct 를 import 한다.
| 날짜 | Worker A | Worker B | Worker C | 순차/메인 |
|---|---|---|---|---|
| 10/18 | Engagements 컨텍스트 + EngagementLive (lib/tickmark/engagements*, lib/tickmark_web/live/engagement_live/*) |
Ledger CsvParser/Importer/ImportWorker + 테스트 (lib/tickmark/ledger/* 중 generator 제외) |
Ledger.Generator (lib/tickmark/ledger/generator.ex, 도메인 문서 7장 명세. 정답 라벨은 별도 CSV 로만 출력, DB 미저장) |
마이그레이션, 스키마, 공유 파일 |
| 10/19 | Testing 실행 경로: start_test_run, RuleWorker, 완료 감지, FailureHandler (lib/tickmark/testing/* 중 rules 제외, lib/tickmark/jobs/*) |
룰 모듈 6-8개 + Rules 레지스트리 (lib/tickmark/testing/rules/*) |
TestRunLive + ImportLive (lib/tickmark_web/live/{test_run,import}_live/*) |
룰 레지스트리 맵 충돌 시 메인이 병합 |
| 10/20 | AI 모듈 (lib/tickmark/ai/*) + Req.Test 테스트 |
Triage 컨텍스트 + TriageWorker (lib/tickmark/triage/*) |
FindingLive 목록/상세 (트리아지 표시까지) | AI config 는 메인 |
| 10/21 | Review 컨텍스트 + SoD 테스트 (lib/tickmark/review/*) |
AuditTrail 조회 + AuditLive (lib/tickmark/audit_trail*, lib/tickmark_web/live/audit_live/*) |
Presence + FindingLive 결론/리뷰 UI | Presence child, router 는 메인 |
- 같은 파일을 두 워커가 갖지 않는다. 컨텍스트 공개 함수 시그니처는 2.1 그대로 두고, 바꿔야 하면 메인 세션에 요청한다.
- 다른 워커의 컨텍스트가 아직 없을 때는 시그니처만 맞춘 테스트 fixture 를 자기
test/support/<context>_fixtures.ex에 둔다. - 라운드 종료 조건:
mix precommit(warnings-as-errors, format, test) 통과. - 10/19 조정 (라운드 3): 아래 "라운드 3" 표가 위 표의 10/19 행을 대체한다.
- 10/18 조정: 워커 B 가 컴파일 단계에서
Engagements.authorize/3에 의존하므로 접근 제어 핵심 (fetch_role/2,authorize/3,topic/1,subscribe/2,broadcast/2) 은 메인 세션이 선행 작업으로 구현한다. 워커 A 는 나머지 공개 함수와 EngagementLive 를 맡는다.Ledger.generate_sample_import/3는 B 와 C 병합 뒤 메인 세션이 구현하고, S2 의 GL 적재 목록 연결 (Ledger.list_imports/2,:import_updated처리) 도 병합 뒤 메인 세션이 한다.
12.1 라운드 3 (10/19-10/20, M2 + 3.1)
메인 선행 작업 (feature/m2-rule-engine, 워커 시작 전 커밋): Rule behaviour 와 Rule.amount_string/1, RuleContext, Rules 레지스트리 (리스트 + 테스트 전용 extra), 참조 룰 ApprovalControlGap, Tickmark.Testing 읽기 함수와 delegate, Runner/FailureHandler 골격, application.ex 의 attach/0 호출, config/test.exs (extra_rules, AI req_options), test/support/test_rules.ex.
워커는 Orca 터미널 워커로 띄운다. 에이전트와 모델은 사용자 지시 (빠른 구현은 Sol 6.1 max, 고급 추론은 Fable xhigh 또는 Astra xhigh).
| 워커 | 에이전트 / 모델 / effort | WBS | 소유 파일 (이 밖은 수정 금지) | 브랜치 | 산출 문서 |
|---|---|---|---|---|---|
| A | Claude / claude-fable-5-1 / xhigh |
2.1 | lib/tickmark/testing/runner.ex, lib/tickmark/testing/rule_worker.ex, lib/tickmark/jobs/failure_handler.ex, test/tickmark/testing/runner_test.exs, test/tickmark/testing/rule_worker_test.exs, test/tickmark/jobs/failure_handler_test.exs |
feature/m2-testing-runner |
devlog 2026-10-19-2 |
| B | Codex / gpt-6-astra / xhigh |
2.2, 2.4 | lib/tickmark/testing/rules.ex, lib/tickmark/testing/rules/* (approval_control_gap.ex 제외), test/tickmark/testing/rules/* (approval_control_gap_test.exs 제외), test/tickmark/testing/rules_label_test.exs |
feature/m2-rules |
devlog 2026-10-19-3 |
| C | Codex / gpt-6.1-sol / max |
2.3 | lib/tickmark_web/live/import_live/*, lib/tickmark_web/live/test_run_live/*, lib/tickmark_web/format.ex, test/tickmark_web/live/import_live_test.exs, test/tickmark_web/live/test_run_live_test.exs, test/tickmark_web/format_test.exs |
feature/m2-screens |
devlog 2026-10-19-4 |
| D | Codex / gpt-6.1-sol / max |
3.1 | lib/tickmark/ai.ex, lib/tickmark/ai/*, test/tickmark/ai_test.exs, test/tickmark/ai/* |
feature/m3-ai-client |
devlog 2026-10-19-5 |
| 메인 | 2.5, 2.6 | 병합, S1/S2 연결 (engagement_live/*), 라운드 devlog |
feature/m2-rule-engine |
devlog 2026-10-19-6 이후 |
- A 와 C 는 서로의 코드를 기다리지 않는다. C 는
Testing읽기 함수 (선행 작업) 와SchemaFixtures로 화면과 테스트를 만들고, 실시간 갱신은 6장 메시지를 테스트에서 직접 broadcast 해 검증한다. "테스트 실행" 단추는 S2 에 있어 메인 몫이다. - C 는 M3/M4 에 생기는 값 (AI 위험도, 작성자 판단, triage 바, AI 요청 단추) 을 자리만 두고 "-" 로 표시한다.
Triage,Review함수를 부르지 않는다. - B 의 정답 라벨 시험 (도메인 7.6 시험 기준 1, 2) 은 룰을 직접 부른다 (Runner 없이). 점수와 triage 집합은 룰
weight/0합 (상한 100) 으로 계산해 비교한다. Runner 를 거친 같은 시험은 병합 뒤 메인이 돌린다. - 동시 테스트 DB 충돌을 피하려고 워커마다
MIX_TEST_PARTITION을 다르게 준다 (A=2, B=3, C=4, D=5).
12.2 라운드 4 (10/20-10/21, M3 + M4)
메인 선행 작업 (feature/m2-rule-engine, 워커 시작 전 커밋): AI.model/0 과 Client model/0 콜백 (fa8a3c1), AuditTrail.broadcast_appended/1 과 S2, S3, S4 의 :triage_requested 무시 절 (f0769f5), 이 문서의 라운드 4 계약 (2.1, 4.2, 5.5.1, 6, 7, 8).
라운드 4a 는 컨텍스트와 읽기 화면을 병렬로 만든다. 화면의 동작 (트리아지 요청, 결론 제출, 승인/반려, S4 AI 열과 일괄 요청, Presence) 은 W1, W2, W4 병합 뒤 라운드 4b 에서 붙인다. 컨텍스트 함수가 있어야 화면 동작을 테스트할 수 있기 때문이다 (5.4절 "앞 작업의 결과가 있어야 시작할 수 있는 작업").
| 워커 | 에이전트 / 모델 / effort | WBS | 소유 파일 (이 밖은 수정 금지) | 브랜치 | 산출 문서 | partition |
|---|---|---|---|---|---|---|
| W1 Triage | Claude / claude-fable-5-1 / xhigh |
3.2 | lib/tickmark/triage.ex, lib/tickmark/triage/triage_worker.ex, lib/tickmark/jobs/failure_handler.ex, test/tickmark/triage_test.exs, test/tickmark/triage/triage_worker_test.exs, test/tickmark/jobs/failure_handler_test.exs |
feature/m3-triage |
devlog 2026-10-20-2 |
2 |
| W2 Review | Codex / gpt-6-astra / xhigh |
4.1 | lib/tickmark/review.ex, test/tickmark/review_test.exs |
feature/m4-review |
devlog 2026-10-20-3 |
3 |
| W3 감사 로그 | Codex / gpt-6.1-sol / max |
4.2 | lib/tickmark/audit_trail.ex (list_events/3 추가만), test/tickmark/audit_trail_test.exs, lib/tickmark_web/live/audit_live/*, test/tickmark_web/live/audit_live_test.exs |
feature/m4-audit-log |
devlog 2026-10-20-4 |
4 |
| W4 S5 표시 | Codex / gpt-6.1-sol / max |
3.3 (표시) | lib/tickmark/testing.ex (get_finding!/2, list_rule_results/2 추가), test/tickmark/testing_test.exs, lib/tickmark_web/live/finding_live/*, test/tickmark_web/live/finding_live_test.exs |
feature/m3-finding-show |
devlog 2026-10-20-5 |
5 |
| W5 M2 마무리 | Codex / gpt-6.1-sol / max |
2.4, 2.6 | test/tickmark/testing/runner_label_test.exs (새 파일), test/tickmark_web/live/engagement_live_test.exs, lib/tickmark/engagements.ex, lib/tickmark/ledger.ex, lib/tickmark/testing/runner.ex, lib/tickmark/testing/rules/duplicate_entry.ex, 해당 테스트 (test/tickmark/engagements_test.exs, test/tickmark/ledger_test.exs, test/tickmark/testing/runner_test.exs, test/tickmark/testing/rules_label_test.exs, test/tickmark/testing/rules/duplicate_entry_test.exs) |
feature/m2-wrapup |
devlog 2026-10-20-6 |
6 |
| 메인 | 3.4, 4.3, 4.4 (라운드 4b) | 계약, 병합, 통합 검증, 라운드 4b (engagement_live/*, test_run_live/* 의 AI 열과 일괄 요청, S5 동작, Presence) |
feature/m2-rule-engine |
devlog 2026-10-20-1 |
1 (기본) |
- 워커가 5명이라 5.4절의 3-4명 상한을 넘는다. 사용자 지시 ("선행적으로 병렬개발 가능한 것들 진행") 를 따르고, W5 는 다른 워커와 파일이 겹치지 않는 마무리 작업이라 검토 부담이 작다.
- W1 은
build_input/2에 쓸 rule_results 를Tickmark.Testing.RuleResult로 직접 읽는다 (Testing.list_rule_results/2는 W4 가 만든다. 병합 뒤 메인이 하나로 합칠지 정한다). - W3 의 S6 행위자 표시: user 는
display_name이 있으면 그것, 없으면 email (C-05), agent 는actor_id그대로 (triage:<model>), system 은 워커 이름.review.denied는 강조한다. - W4 는 S5 를 읽기 전용으로 만든다. 작성자 판단 폼만
Conclusion.changeset으로 검증하고 제출 단추는 자리만 둔다. 반려 사유 폼 (ReviewDecision.changeset) 은 '반려' 를 누른 뒤 열리는 2단계 흐름이라decide/4연결과 함께 라운드 4b 에서 붙인다 (W4 질문, 10/20). Triage, Review 함수를 부르지 않는다. engagement 토픽의 모든 메시지를 처리한다 (6장). - W5 의 broadcast 보강은 이벤트마다
AuditTrail.broadcast_appended/1을 커밋 뒤에 부르는 것이다. runner 의 트랜잭션 안record/1은 이벤트를 결과로 꺼내 커밋 뒤에 보낸다. DuplicateEntry 의 느린 쿼리 (라운드 3 통합에서 15초 timeout 2회) 는 EXPLAIN ANALYZE 로 원인을 찾고, 인덱스가 필요하면 마이그레이션은 변경 요청으로 남긴다. - 테스트 DB partition 은 표의 값을 쓴다 (
MIX_TEST_PARTITION=N mix precommit).
12.3 라운드 4b 선행 (W6, 10/20)
W2 (Review) 와 W4 (S5 표시) 가 병합되어 S5 의 결론 제출과 승인/반려는 지금 붙일 수 있다. W1 (Triage) 이 필요한 'AI 분석 요청' 단추, 화면을 가로지르는 Presence 와 상단 바, 검토 대기열과 '다음 미판단' 이동은 메인이 4b 에서 붙인다.
| 워커 | 에이전트 / 모델 / effort | WBS | 소유 파일 (이 밖은 수정 금지) | 브랜치 | 산출 문서 | partition |
|---|---|---|---|---|---|---|
| W6 S5 동작 | Codex / gpt-6.1-sol / max |
4.3 (S5 결론과 검토 동작) | lib/tickmark_web/live/finding_live/* (새 파일 허용), test/tickmark_web/live/finding_live_test.exs (test/tickmark_web/live/finding_live/* 로 나눠도 됨) |
feature/m4-finding-actions |
devlog 2026-10-20-7 |
7 |
- 먼저
finding_live/components.ex(610줄) 를 영역별 모듈 (각 300줄 이하) 로 나눈다. 동작 변경 없는 refactor 커밋으로 따로 둔다. - 결론 제출은
Review.submit_conclusion/3, 승인/반려는Review.decide/4를 부른다.Review,Testing, 스키마는 고치지 않는다. - C-11 화면 규칙. 확인 단추는 화면에 그린 결론 id 를
phx-value-conclusion-id로 보내고 서버는 그 값을decide/4에 넘긴다. 서버 assign 의 최신 id 를 쓰면, 재렌더 전에 누른 클릭이 검토자가 보지 못한 새 결론에 붙는다. 결론 제출의ai_triage_id도 화면에 그린 제안 id 를 쓴다. 조작된 값은decide/4의:stale과Conclusion.changeset의 다른 finding 제안 거부가 막는다. - 승인과 반려는 2단계다. 승인은 '승인' -> 확인/취소. 반려는 '반려' -> 사유 폼 (
ReviewDecision.changeset으로 검증, 필수, 2000자, 글자 수) -> 확인/취소. finding_updated를 받으면 검토자의 확인 단계는 처음으로 돌리고, 작성자가 쓰던 초안은 지우지 않고 경고만 보인다 (화면설계서 S5.4).- 에러 문구는 화면설계서 S5.4 와 부록 A 를 따른다.
:stale은 "작성자가 새 결론을 제출했습니다. 최신 결론을 확인한 뒤 다시 결정하세요." 를 쓴다.
12.4 라운드 5 (화면 셸, 인증 화면, 명세 문서, 10/20)
사용자 지시 (10/20): 화면설계서의 공통 셸, 홈, 로그인, 회원가입, 감사 대상 선택 (S1) 을 빠르게 맞추고, 요구사항 명세서, 기능 정의서, API 연동 규격서를 쓴다. WBS 5.2 를 당긴다. 메인 선행 작업: Gettext 기본 로케일 ko (config/config.exs). ko 번역이 없는 msgid 는 영어 원문으로 돌아가므로 이 설정만으로는 화면이 바뀌지 않는다.
| 워커 | 에이전트 / 모델 / effort | WBS | 소유 파일 (이 밖은 수정 금지) | 브랜치 | 산출 문서 | partition |
|---|---|---|---|---|---|---|
| F1 공통 셸과 홈 | Codex / gpt-6.1-sol / max (fast) |
5.2 (C-01 ~ C-04) | assets/css/app.css, lib/tickmark_web/components/layouts.ex, lib/tickmark_web/components/layouts/*, lib/tickmark_web/controllers/page_controller.ex, page_html.ex, page_html/*, error_html.ex, error_html/* (새로), lib/tickmark_web/format.ex 의 kst_time 만, S1-S4 와 S6 LiveView 의 <Layouts.app ...> 여는 태그 한 줄씩, 테스트 test/tickmark_web/controllers/page_controller_test.exs, error_html_test.exs, test/tickmark_web/components/layouts_test.exs (새로), format 테스트의 kst_time 부분 |
feature/ui-shell |
devlog 2026-10-20-12 |
8 |
| F2 인증 화면과 한글 문구 | Codex / gpt-6.1-sol / max (fast) |
5.2 (C-06, 인증 화면) | lib/tickmark_web/live/user_live/*, lib/tickmark_web/user_auth.ex 와 controllers/user_session_controller.ex (사용자에게 보이는 문구만), lib/tickmark/accounts/user_notifier.ex (메일 문구), priv/gettext/ko/LC_MESSAGES/* (새로), lib/tickmark/engagements/engagement.ex 의 message: 문자열만, 테스트 test/tickmark_web/live/user_live/*, user_auth_test.exs, user_session_controller_test.exs, 다른 테스트 파일의 영어 검증 문구 단언 줄 |
feature/ui-auth |
devlog 2026-10-20-13 |
9 |
| D1 요구사항과 기능 정의 | Codex / gpt-6-astra / max (fast) |
- | docs/planning/2026-10-20-9-요구사항-명세서.md, docs/planning/2026-10-20-10-기능-정의서.md |
docs/requirements-spec |
(문서 자체) | - |
| D2 API 연동 규격 | Codex / gpt-6-astra / max (fast) |
- | docs/architecture/2026-10-20-11-API-연동-규격서.md |
docs/api-spec |
(문서 자체) | - |
fast 는 사용자 Codex 설정의 service_tier = "priority" 로 모든 Codex 워커에 적용된다 (Orca 에 별도 플래그 없음).
Layouts.app/1 계약 (F1 이 만들고, S5 연결은 W6 병합 뒤 메인).
attr :flash, :map, required: true
attr :current_scope, :map, default: nil
# %{id, client_name, fiscal_year}. nil 이면 감사 대상 밖 상단 바 (S1, 인증 화면):
# 감사 대상 이름, 감사 로그, presence 를 그리지 않는다.
attr :engagement, :map, default: nil
# :engagement (S2) | :audit (S6) | nil. 해당 링크에 aria-current="page".
attr :active, :atom, default: nil
# [%{id, name}]. 빈 리스트면 아바타를 그리지 않는다. Presence 연결은 4.4 (메인).
attr :presence, :list, default: []
- 상단 바는 화면설계서 3.1 (48px, 영역 1-6) 과 디자인 시스템 2.3 의 6단계를 따른다. 사용자 메뉴는 이메일, 설정 (
/users/settings), 로그아웃 (DELETE /users/log-out). 로그인 전에는 로그인, 회원가입 링크. 루트 레이아웃의 영어 메뉴는 없앤다. - 본문 폭은
mx-auto max-w-screen-2xl px-4 sm:px-6 lg:px-8. 인증 화면은 자기 마크업에서 좁힌다. - 홈 (
/): 로그인한 사용자는/engagements로 보낸다. 로그인 전에는 한국어 소개와 로그인, 회원가입 단추. 라우트는 바꾸지 않는다. - C-05 (사람 이름) 는 MVP 에서
users.display_name을 더하지 않고 이메일을 쓴다 (메인 결정, 스키마와 마이그레이션 변경을 MVP 뒤로). - 회원가입은 지금처럼 이메일과 매직 링크다. 배포 환경의 메일 발송과 데모 계정은 5.3, 5.4 (메인) 에서 정한다.
- 문서 ID 체계 (D1, D2 공통): 요구사항
REQ-{영역}-{nn}(영역 ACC, ENG, GL, RUN, AI, REV, AUD, UI), 비기능NFR-{nn}, 기능FN-{화면 또는 영역}-{nn}(S1-S6, AUTH, SYS), 인터페이스IF-{종류}-{nn}(HTTP, LV, PS, JOB, EXT, FILE, CTX). D2 는 D1 과 동시에 쓰므로 FN, REQ 를 참조하지 않고 화면 (S1-S6) 과 이 문서의 절을 참조한다. 교차 링크는 메인이 병합 때 단다.
13. 주요 결정 (ADR 요약)
| ADR | 결정 | 대안 | 트레이드오프 / 근거 |
|---|---|---|---|
| 1 PK 타입 | binary_id (UUID) 유지 | bigint identity | --binary-id 로 생성됨. URL 추측 불가, 적재 시 id 선생성으로 RETURNING 왕복 제거. 인덱스가 크고 삽입 지역성이 낮지만 10만 행 규모에서 무시 가능. audit 정렬은 identity seq 로 해결 |
| 2 Job 단위 | (run, rule) 당 1개 | 룰 x 데이터 chunk, run 당 1개 | chunk 단위는 진행률이 세밀하나 전표 간 비교 룰 (중복 전표, 계정 조합 빈도) 의 경계 처리와 수백 개 job 완료 감지가 필요. run 당 1개는 병렬성 없음. 룰 단위는 "SQL 한 번" 룰 구조와 맞고 6-8 병렬로 충분. 느린 룰이 생기면 2단계에 분할 |
| 3 Append-only | 앱 규율 + DB 트리거 | REVOKE 권한 분리, 해시 체인 | 4.4 참조. 트리거 테스트로 "앱이 실수해도 DB 가 거부" 를 증명 (학습 포인트). 롤 분리/해시 체인은 2단계 |
| 4 Ash | 지금은 쓰지 않음 | Ash 3.36 (정책, AshOban, 감사 확장) | 선언형으로 코드가 줄지만 DSL 학습 비용이 5일 일정과 Phoenix/Ecto/OTP 기본기 목표 (AGENTS.md 7) 에 맞지 않고 디버깅 추적이 어렵다. Policy 를 순수 함수로 두어 2단계 이전을 쉽게 한다 |
| 5 중복 방지 | Oban unique 미사용, DB 상태 전이 + 워커 멱등 | unique: [keys: ..., states: :incomplete] |
OSS unique 는 조회 기반이라 경합에 약하고 bulk insert 불가 (Pro). 상태 전이는 job 생성과 같은 트랜잭션이라 정확 |
| 6 적재 | 헤더/라인 정규화, Elixir 검증, 단일 트랜잭션 chunk insert_all | 라인만 저장, 스테이징 테이블 후 SQL 이관 | 전표 단위 룰 쿼리가 단순하고 헤더 일관성 검증 가능. 대신 라인 연속성 계약 필요. 스테이징은 SQL 이 복잡하고 오류 행 번호를 잃기 쉽다 |
| 7 AI 역할 | 제안만, ai_triages 불변 저장 |
AI 가 finding 상태 변경 | 감사 판단 주체는 사람. 결론의 ai_triage_id 로 "AI 제안 -> 사람 결정" 추적. Fake 기본이라 키 없이 전체 흐름 동작 |
Changelog
- 2026-10-17: 최초 작성
- 2026-10-17: 메인 세션 검토. 도메인 문서와 정합: finding 점수를 가중치 합(상한 100)으로, 룰
weight/0와{:skip, reason}추가, test_run_rulesskipped, test_runstriage_threshold, counterparty 를 전표 헤더로 이동, AI 출력에 절차 구조와 missing_information 추가(confidence 제외), 트리아지 상한 100, server-side fallback 기본 off, ledger_importswarning_count/warnings, entered_at 오프셋 명시 거부 - 2026-10-17:
AuditTrail.list_events/3옵션에actor_type추가 (디자인 문서 S6 행위자 필터) - 2026-10-17: 메인 세션 선행 작업 반영. 마이그레이션 버전
20261017110526-20261017110533(10장 순서). CHECK 이름 확정 (fiscal_year_range,performance_materiality_positive,role_valid,source_kind_valid,status_valid,fiscal_period_range,source_valid,balanced,line_number_positive,debit_non_negative,credit_non_negative,one_side_positive,score_range,triage_status_valid,input_hash_length,risk_level_valid,outcome_valid,decision_valid,comment_required_on_reject,reviewer_not_preparer,actor_type_valid), partial unique 이름test_runs_one_running_per_import. review_decisions comment CHECK 의 NULL 허점 수정 (length(btrim(NULL))은 NULL 이라 CHECK 를 통과함). ai_triages 의 중복finding_id인덱스 제외. FK 이름 확인. AuditEventseq에writable: :never.Finding전이표를 스키마에 둠: status 는 3.9 그대로, triage_status 는none/succeeded/failed -> queued,queued -> succeeded/failed.ai_triages.suggested_procedures는 jsonb 컬럼에{:array, :map}필드로 왕복 확인 (테스트). LiveView 모듈명ImportLive.New,TestRunLive.Show,FindingLive.Show,AuditLive.Index(디자인 문서 S3-S6 갱신). 테스트용Tickmark.SchemaFixtures(test/support/fixtures/schema_fixtures.ex, 메인 소유) - 2026-10-17: 2.1 에
Ledger.Generator계약 (default_seed/0,generate/1) 추가. 12장에 10/18 조정 (접근 제어 핵심은 메인 선행,generate_sample_import/3와 S2 적재 목록 연결은 병합 뒤 메인) 추가 - 2026-10-17: 10/18 구현 결과 반영. 2.1 에
add_member의 문자열 role 허용,list_members비멤버[],process_import반환과 ImportWorker 결과 매핑,generate_sample_import/3의 seed 전용 opts 와synthetic-gl-seed-<seed>.csv파일명 규칙,Tickmark.Testing.Holidays(all/0,holiday?/1),X.t()표기 메모 추가. 4.2 에 payload 열 추가 (engagement, ledger_import 이벤트). 9.2 오류 코드에unknown_column,invalid_value,too_long,duplicate_line_number추가와parse_error범위, 오류 항목 모양 명시 - 2026-10-19: 라운드 3 계약 확정. 2.1 에
run_topic/1,count_findings/2(C-07),list_findings/3필터를min_score에서queue(:review/:minor/:all) 로 변경,Testing.Rules함수, 알 수 없는 rule id 는 ArgumentError,input_hash/1을Tickmark.AI로 이동. 2.2 레지스트리를 순서 있는 리스트로, 테스트 전용 extra_rules, 참조 룰, 룰당 전표 1회,details값 규칙 (Rule.amount_string/1). 4.2 test_run 이벤트 payload (설계서 부록 B). 5.3start_test_run/3순서. 5.4mark_rule0행을:noop으로 정정,run_rule/2전체 흐름,error컬럼 내용, FailureHandler 붙이는 위치와 멱등 조건. 6장 broadcast 시점. 7장 AI 파일 목록, 출력 검증 위치, test config. 12.1 라운드 3 배정 (Orca 워커 4명, 에이전트/모델 포함) - 2026-10-19: 7.2 Fake 의
response_model을"fake"에서"fake-triage-v1"로 정정 (도메인 4.2 와 일치, 워커 D 질문). 요청model컬럼 값은 3.2 Triage 에서 정한다 - 2026-10-19: 2.1 에
Testing.latest_run_summaries/1추가 (S1 최근 테스트, 검토 큐 열, WBS 2.5) - 2026-10-19:
count_findings/2에approved(검토 큐 안의 승인 수) 추가. S2 통계의 "승인 N / 검토 큐" 용 (WBS 2.6) - 2026-10-20: 5.4 성공 경로 순서를 "insert -> FOR UPDATE" 에서 "FOR UPDATE -> mark_rule -> insert" 로 변경. FK 검사의 FOR KEY SHARE 와 FOR UPDATE 가 충돌해 동시 완료 시 교착 (워커 A 질문, 승인)
- 2026-10-20: 라운드 4 계약 확정. 2.1 에
Testing.get_finding!/2preload 와list_rule_results/2, Triage 반환 (:below_threshold,:noop),build_input/2,latest_triages/2, Reviewdecide/4를(scope, finding, conclusion_id, attrs)로 바꾸고:stale추가 (C-11),AuditTrail.broadcast_appended/1,list_events/3옵션 (entry_number포함, C-13). 4.2 의 ai_triage, conclusion, review 이벤트 payload (부록 B + entry_number),ai_triage.failed행위자는 system. 5.5.1 Triage 순서, 일괄 요청의 조건부 update_all, 워커 반환표, 실패 reason 분류. 6장audit_appended필드 추가와:triage_requested, 구독자는 모든 메시지 처리. 7장model/0. 8장submit_conclusion/3와decide/4순서 (lock-first, review.denied role/self_review). 12.2 라운드 4 배정 - 2026-10-20: 12.2 W4 범위에서 반려 사유 폼 검증을 라운드 4b 로 옮김 (2단계 흐름이
decide/4와 한 덩어리) - 2026-10-20: 7.4
build_input/2로 표기 정정, context 구성 확정 (입력자 전표 수 제외, 계정쌍 빈도 없으면 null) - 2026-10-20: 5.5.1 snooze 의 "확인 필요" 해결 (W1 확인: attempt -1, meta.snoozed +1, scheduled, 상한 10회)
- 2026-10-20: 12.3 라운드 4b 선행 W6 (S5 결론 제출, 승인/반려 2단계, components.ex 분리) 배정. C-11 화면 규칙 (화면에 그린 conclusion_id, ai_triage_id 를 보낸다)
- 2026-10-20: 12.4 라운드 5 배정 (F1 셸과 홈, F2 인증 화면과 한글 문구, D1 요구사항과 기능 정의, D2 API 연동 규격).
Layouts.app/1attr 계약, C-05 는 이메일 유지, Gettext 기본 로케일 ko, 문서 ID 체계