import { CreateDateColumn, Entity, Index, Column, RelationId, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' import { ObjectType, Field, ID } from 'type-graphql' import { Domain, ScalarObject } from '@things-factory/shell' import { config } from '@things-factory/env' /* * 긴 글의 컬럼 타입은 **드라이버마다 이름이 다르다** — 이 레포의 정본 관행대로 여기서 고른다 * (board-service 의 `Board.model` 과 같은 방식). `text` 를 그대로 적었더니 Oracle 이 모르는 이름이었다. */ const DATABASE_TYPE = config.get('ormconfig', {}).type /* * TwinAuditEvent — **사람이 무엇을 허락했는가.** * * ── 왜 저널(TwinEvent)이 아닌가 ─────────────────────────────────────────────── * 처음에는 저널에 함께 넣었다. "감사는 원장에서 하니까" 라는 이유였는데, 그건 **화면의 편의**였고 * 저장 구조를 정할 근거가 못 됐다. 넣자마자 예외가 넷 생겼다 — 리플레이에서 빼고, 백필에서 빼고, * 원장 거래 분류에서 빼고, 원장 렌더에서 갈랐다. **예외가 넷이면 설계가 "여기 아니다" 라고 말하는 것이다.** * * 갈라 두면 예외가 0 이고, 오히려 얻는다: * · 행위자가 **컬럼**이 된다 — "누가 승인한 것 전부" 는 감사의 기본 질문인데 payload 안에서는 못 찾았다 * · revision 이 없다. 저널에서는 그 칸을 비워 "상태가 아님" 을 뜻하게 썼는데, 없음을 종류 표시로 * 쓰는 것이 여러 번 문제를 냈다(백필의 `bizStep IS NULL = 옛 행` 전제가 이것 때문에 깨졌다) * · bizStep 오염이 없다 — 저널의 키 추출기는 값이 없으면 이벤트 타입으로 대체하므로, * 감사 행을 태우면 CBV 표준 어휘 컬럼에 `governance.command` 가 박힌다 * * ── 무엇을 믿는가 ──────────────────────────────────────────────────────────── * **신원·시각은 서버가 각인한다.** 클라이언트가 말한 신원을 그대로 적으면 위조가 자유로워 그 기록은 * 아무것도 증명하지 못한다. 세션이 아는 사용자와 서버 시각만 신뢰한다. * 클라이언트에서 받는 것은 **의도**뿐이다 — 어디서 시작됐나(origin), 왜 하나(reason). */ @Entity() /* 목록 기본 정렬 — 최근 것부터. 감사는 "요즘 무슨 일이 있었나" 로 시작한다. */ @Index('ix_twin_audit_0', (e: TwinAuditEvent) => [e.domain, e.instanceId, e.eventTime], { unique: false }) /* 행위자로 찾기 — 저널에 있을 때는 불가능했던 질문("김현장이 승인한 것 전부"). */ @Index('ix_twin_audit_1', (e: TwinAuditEvent) => [e.domain, e.actorEmail, e.eventTime], { unique: false }) @ObjectType({ description: 'Audit record of a twin command: who approved what, and what the kernel answered.' }) export class TwinAuditEvent { @PrimaryGeneratedColumn('uuid') @Field(type => ID, { description: 'Unique identifier of the audit record.' }) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Owning tenant domain.' }) domain?: Domain @RelationId((e: TwinAuditEvent) => e.domain) domainId?: string @Column() @Field({ description: 'Twin runtime instance the command was aimed at.' }) instanceId: string /* * 서버 시각. 클라이언트 시계를 믿지 않는다 — 기록의 시간축이 단말 설정에 좌우되면 안 된다. * * **타입 이름은 적지 않는다** (2026-08-19 Postgres 부팅 실패로 고침). * `datetime` 은 sqlite·MySQL 의 이름이고 Postgres 는 `timestamp` 만 안다 — 거꾸로 `timestamp` 는 * sqlite 가 모른다. **다섯 드라이버에 다 통하는 이름이 없다.** 그래서 이름 고르는 일을 TypeORM 에 * 맡긴다(`emitDecoratorMetadata` 가 켜져 있어 `Date` 에서 드라이버별 타입을 고른다). */ @Column() @Field({ description: 'Server time when the command was dispatched.' }) eventTime: Date @Column({ nullable: true }) @Field({ nullable: true, description: 'Client-supplied command id, for correlating with kernel logs.' }) commandId?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Command type (e.g. order.hold, equipment.repair).' }) commandType?: string /* 'user' | 'ai' | 'automation' | 'unknown'. 모르면 unknown 이지 사람이 한 것으로 치지 않는다. */ @Column({ nullable: true }) @Field({ nullable: true, description: "Where the command originated: 'user' | 'ai' | 'automation' | 'unknown'." }) origin?: string /* * 승인 사유 — 사람이 쓰는 글이라 길이를 미리 자르지 않는다. * * 이름을 드라이버별로 고른다(2026-08-19): `text` 는 Oracle 이 모르고(그쪽은 `clob`), MySQL 계열은 * 긴 글에 `longtext` 를, MSSQL 은 `nvarchar(MAX)` 를 쓴다. 다중 DB 를 지키는 이 레포의 관행이 그렇다. */ @Column({ nullable: true, type: DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb' ? 'longtext' : DATABASE_TYPE == 'oracle' ? 'clob' : DATABASE_TYPE == 'mssql' ? 'nvarchar' : 'text', length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined }) @Field({ nullable: true, description: 'Why this action — the rationale shown on the approval card.' }) reason?: string /* ── 신원: 세션에서만 온다 ── */ /* * **원본이 그래서 어떻게 했나** — 허락과 일어남 사이에 끼어 있는 것을 같은 행에 닫는다 * (ADR-0029 §1 결정 C, 2026-08-19). * * 명령이 원본으로 가면 감사는 「허락했다」까지만 증명한다 — 어댑터가 오류 없이 실패하면 원장에는 승인만 * 남는다. 그래서 어댑터의 답을 되적는다. * * · 비어 있음 — **원본에 묻지 않았다**(커널이 받은 명령이다). 「모른다」와 다르다. * · `unknown` — 물었는데 **답을 못 받았다**. 성공으로 적지 않는다. * · `applied` · `refused` — 원본이 답했다(둘 다 사실이다). */ @Column({ nullable: true }) @Field({ nullable: true, description: "What the source did with it: 'applied' | 'refused' | 'unknown' (asked but no answer). Empty means the source was not asked — the kernel took the command." }) sourceOutcome?: string /** 원본이 준 사유 코드 — 있으면 그대로 적는다(우리가 지어내지 않는다). */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Reason code the source returned, as it gave it.' }) sourceCode?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Approving user id, stamped by the server from the session.' }) actorId?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Approving user email, stamped by the server from the session.' }) actorEmail?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Approving user name, stamped by the server from the session.' }) actorName?: string /* ── 결과: 거절도 남긴다 ── */ @Column({ default: false }) @Field({ description: 'Whether the kernel accepted the command.' }) accepted: boolean @Column({ nullable: true }) @Field({ nullable: true, description: 'Language-neutral rejection code when the kernel refused.' }) errorCode?: string /** * 명령 인자 — **원형 그대로.** 무엇에 대한 명령이었는지가 감사의 핵심이라 요약하지 않는다. * 지나치게 크면 자르되 `argsTruncated` 로 잘랐다는 사실을 남긴다(알리지 않고 버리지 않는다). */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Command arguments as given.' }) args?: any @Column({ default: false }) @Field({ description: 'True when args were too large and stored truncated.' }) argsTruncated: boolean @CreateDateColumn() @Field({ nullable: true, description: 'Timestamp when the audit row was written.' }) createdAt?: Date }