설계 2026-10-17 docs/architecture/2026-10-17-4-시스템-구조와-데이터-모델.md GitHub에서 보기

시스템 구조와 데이터 모델

  • 문서 성격: 병렬 코딩 라운드(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 업로드부터 리뷰 승인까지

  1. preparer 가 ImportLive 에서 CSV 업로드 -> consume_uploaded_entries 콜백 안에서 Ledger.create_import/3 호출. 파일을 import 디렉터리로 복사하고, 같은 Multi 로 ledger_imports(pending) + audit_event + ImportWorker job 을 insert.
  2. ImportWorker (ingest 큐) 가 파일을 스트리밍 파싱, 검증, chunk 단위 insert_all. 성공/실패를 ledger_imports 에 기록하고 "engagement:#{id}" 로 broadcast.
  3. preparer 가 "테스트 실행" -> Testing.start_test_run/3. 한 트랜잭션에서 test_runs(running), 룰별 test_run_rules(queued), audit_event, 룰별 RuleWorker job N개를 insert.
  4. RuleWorker 들이 병렬 실행. 각자 hit 를 rule_results 에 넣고, 같은 트랜잭션에서 완료 감지(5.4). 진행률은 "test_run:#{id}" 로 broadcast. 마지막 룰이 끝나면 findings 를 집계 생성하고 run 을 completed 로 바꾼다.
  5. preparer 가 "AI 트리아지" -> Triage.request_triage/2 가 findings 를 queued 로 전이시키고 TriageWorker job 을 넣는다. 결과는 ai_triages(불변) 에 저장, 제안만 하고 상태를 결정하지 않는다.
  6. preparer 가 finding 별 결론 제출 (Review.submit_conclusion/3) -> finding.status = concluded.
  7. reviewer 가 승인/반려 (Review.decide/4). 본인 결론은 컨텍스트와 DB 양쪽에서 차단(8장).
  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_results unique (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 확정)

  1. Engagements.authorize(scope, import.engagement_id, :start_test_run). 실패면 {:error, :forbidden}.
  2. import 를 DB 에서 다시 읽는다. status != :succeeded 면 {:error, :import_not_ready} (화면이 들고 있던 struct 는 낡았을 수 있다).
  3. rule_ids 확인: :all 은 Rules.ids(). 리스트는 각 id 가 Rules.fetch/1 로 찾아져야 하고 비어 있으면 안 된다. 어기면 ArgumentError.
  4. 위 Multi. test_runs: rule_count, triage_threshold (스키마 기본값 25, 도메인 3.2), started_at. test_run_rules: 룰마다 params = module.default_params(), status: :queued. 감사 이벤트 payload 는 4.2.
  5. 커밋 뒤 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 확정)

  1. test_run_rules 행과 run 을 읽는다. 행이 terminal 이거나 run 이 running 이 아니면 {:ok, :noop}.
  2. queued -> running 으로 바꾸고 started_at 을 채운 뒤 (재시도로 이미 running 이면 그대로) 커밋하고 :rule_progress 를 보낸다. 룰 실행은 이 트랜잭션 밖이다 (긴 쿼리 동안 run 행을 잠그지 않는다).
  3. RuleContext (engagement preload, params 는 저장된 스냅샷) 로 module.run/1 호출.
  4. {:ok, hits}: 위 성공 경로. rule_results.score = module.weight(). 반환 {:ok, :done}.
  5. {:skip, reason}: 같은 락-마크-카운트-finalize 순서로 skipped, hit_count 0, error 에 reason. 반환 {:ok, :skipped}.
  6. {:error, reason}: DB 를 바꾸지 않고 {:error, reason}. 재시도할지는 RuleWorker 가 정한다. raise 는 잡지 않는다 (Oban 이 재시도하고 마지막 시도는 FailureHandler 가 처리).
  7. 상태가 바뀐 트랜잭션이 커밋된 뒤에만 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.2 triage_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

  1. finding 과 run 조회 (형식이 틀린 id 는 조회 전에 :forbidden). Engagements.authorize(scope, engagement_id, :request_triage).
  2. 트랜잭션: 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).
  3. 커밋 뒤 {:finding_updated, ...}, audit_appended. 반환은 갱신된 finding.

request_triage/2 (일괄)

  1. run 조회, authorize. run 이 아직 running 이면 finding 이 없으므로 결과는 0건이다 (따로 오류를 두지 않는다).
  2. 트랜잭션: 후보 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).
  3. 커밋 뒤 {:triage_requested, %{test_run_id, enqueued}} (0건이어도 보낸다) 와 이벤트가 있으면 audit_appended. finding 마다 finding_updated 를 보내지 않는다 (100건이면 화면이 100번 다시 읽는다. S4 는 triage_requested 에 한 번 다시 읽는다).

triage_finding/1 (TriageWorker 전용)

  1. finding (journal_entry 와 lines, test_run 과 engagement) 과 그 전표의 rule_results 를 읽는다. triage_status != :queued 면 API 를 부르지 않고 {:ok, :noop}.
  2. input = build_input(finding, rule_results), input_hash = AI.input_hash(input), model = AI.model(), prompt_version = Tickmark.AI.Prompts.Triage.version().
  3. 같은 (finding_id, prompt_version, model, input_hash) 행이 있으면 API 를 부르지 않고 그 행을 다시 붙인다 (reused: true). 없으면 트랜잭션 밖에서 AI.triage(input) 을 부르고 System.monotonic_time/1 로 latency_ms 를 잰다. {:error, e} 면 DB 를 바꾸지 않고 그대로 돌려준다.
  4. 트랜잭션: finding FOR UPDATE -> queued 가 아니면 :noop -> 새 결과면 ai_triages insert (on_conflict: :nothing, 위 4열 conflict_target. 충돌하면 기존 행을 다시 읽는다) -> triage_status :succeeded -> ai_triage.completed (actor {:agent, "triage:" <> response_model}).
  5. 커밋 뒤 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/oban Basic 엔진 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_pair details, 없으면 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 확정)

  1. finding 과 run 조회, authorize(:submit_conclusion). 비멤버와 검토자는 :forbidden.
  2. 트랜잭션: finding FOR UPDATE -> status :approved 면 :invalid_state -> Conclusion.changeset (finding_id, preparer_id = scope.user.id 는 컨텍스트가 넣는다) -> ai_triage_id 가 있으면 같은 finding 의 ai_triages 행인지 확인하고 아니면 :ai_triage_id changeset 오류 -> insert -> Finding.status_changeset(:concluded) (open, concluded, rejected 에서 가능) -> conclusion.submitted.
  3. 커밋 뒤 finding_updated, audit_appended. concluded 에서 다시 내면 새 결론이 쌓이고 검토자는 최신 결론을 결정한다.

decide/4 순서 (라운드 4 확정, C-10, C-11 반영)

  1. finding 과 run 조회. Engagements.fetch_role/2 가 비멤버면 :forbidden 이고 이벤트를 남기지 않는다 (감사 대상이 있는지 숨긴다).
  2. 역할에 :review_conclusion 이 없으면 (작성자) review.denied (reason role) 를 record/1 로 남기고 broadcast 한 뒤 :forbidden (C-10. 작성자의 시도도 기록).
  3. 트랜잭션: finding FOR UPDATE -> status != :concluded 면 :invalid_state -> 최신 conclusion (inserted_at 내림차순 첫 행, usec) -> 그 id 가 인자 conclusion_id 와 다르면 :stale (C-11) -> preparer_id == scope.user.id 면 롤백하고 트랜잭션 밖에서 review.denied (reason self_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.
  4. 커밋 뒤 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 로 넣어 CHECK reviewer_not_preparer, 복합 FK (conclusion_id, preparer_id) -> conclusions(id, preparer_id) (preparer_id 위조), comment_required_on_reject 가 각각 거부하는지 본다.

9. CSV 적재

9.1 단계

  1. LiveView allow_upload(:ledger, accept: ~w(.csv), max_entries: 1, max_file_size: 20_000_000) (LiveView 기본 8_000_000 바이트, 1.2.12 소스 확인).
  2. consume_uploaded_entries 콜백 안에서 Ledger.create_import/3: 파일을 import 디렉터리의 <uuid>.csv 로 File.cp!, sha256 계산, Multi 로 import 행 + audit + ImportWorker job. 업로드 임시 파일은 Plug.Upload.random_file/1 로 만들어져 업로드 프로세스 종료 시 정리되므로 콜백 안에서 복사해야 한다 (정확한 삭제 시점은 확인 필요).
  3. import 디렉터리: config :tickmark, :import_dir 를 runtime.exs 에서 env TICKMARK_IMPORT_DIR 로 설정, 없으면 Path.join(System.tmp_dir!(), "tickmark_imports"). DB 에는 상대 파일명만 저장. Azure Blob 은 2단계.
  4. 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_rules skipped, test_runs triage_threshold, counterparty 를 전표 헤더로 이동, AI 출력에 절차 구조와 missing_information 추가(confidence 제외), 트리아지 상한 100, server-side fallback 기본 off, ledger_imports warning_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 이름 확인. AuditEvent seq 에 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.3 start_test_run/3 순서. 5.4 mark_rule 0행을 :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!/2 preload 와 list_rule_results/2, Triage 반환 (:below_threshold, :noop), build_input/2, latest_triages/2, Review decide/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/1 attr 계약, C-05 는 이메일 유지, Gettext 기본 로케일 ko, 문서 ID 체계