/* * **현장에 내리는 조치 하나** — 제안 · 승인 · 넘김 · 응답이 한 행에 남는다. * * ── 왜 표인가 (2026-08-31) ─────────────────────────────────────────────── * 승인이 프로세스 안에만 있으면 트윈이 재기동한 순간 승인 대기 줄이 사라진다. 그때 사람은 **자기가 * 승인한 것이 실행됐는지 알 수 없다.** 조치는 되돌리기 어려운 것이므로 그 모름이 그대로 사고가 된다. * * 그리고 이 행은 **누가 언제 승인했나**의 기록이기도 하다. 사고 뒤에 그것을 물을 자리가 여기다. * * ── 판정은 여기 없다 ───────────────────────────────────────────────────── * 어느 상태에서 어느 사건이 허용되는지는 계약이 든다(`@operato/ops-contract` §`nextCommandState`). * 이 파일은 그 값을 담기만 한다 — 판정이 두 벌이 되면 화면과 서버가 다른 답을 낸다. */ import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId, UpdateDateColumn } from 'typeorm' import { Field, ID, Int, ObjectType } from 'type-graphql' import { Domain, ScalarObject } from '@things-factory/shell' @Entity() /* 대기 줄을 여는 질의가 이 세 칸으로 묻는다 — 어느 테넌트의 · 어느 트윈의 · 어느 상태인가. */ @Index('ix_twin_command_0', (e: TwinCommandRecord) => [e.domain, e.instanceId, e.state]) @ObjectType({ description: 'One actuation command proposed against a twin, with its approval trail.' }) export class TwinCommandRecord { @PrimaryGeneratedColumn('uuid') @Field(type => ID, { description: 'Unique identifier of the command.' }) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Tenant this command belongs to.' }) domain?: Domain @RelationId((e: TwinCommandRecord) => e.domain) domainId?: string @Column() @Field({ description: 'Twin instance this command acts on.' }) instanceId: string @Column() @Field({ description: 'Command type in twin vocabulary. Translating it into a legacy call is the adapter job.' }) type: string @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Command payload in twin vocabulary.' }) payload?: any @Column() @Field({ description: 'Who proposed it: operator, rule, ai or schedule. A person weighs this when approving.' }) origin: string /** * **낸 사람** — 종류가 아니라 사람이다. * * `origin` 은 「사람이 냈나 · 규칙이 냈나」만 말한다. 그것만으로는 **낸 사람이 자기 것을 승인하는 것**을 * 막을 수 없고, 그러면 게이트가 시늉이 된다. 되돌릴 수 없는 조치에 두 사람이 필요하다는 것이 이 표의 * 존재 이유다. * * 규칙·AI 가 낸 것은 비어 있다 — 사람이 아니다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'The person who proposed it. Absent when a rule or model proposed it.' }) proposedBy?: string /** * **되돌릴 수 있는가** — 게이트가 걸리는 기준이다. * * 비어 있으면 「되돌릴 수 없다」로 읽는다(§`requiresApproval`). 모름을 되돌릴 수 있다로 읽으면 그 * 커맨드가 승인 없이 나간다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Whether the effect can be undone. Absent means it cannot, and a person must approve.' }) reversible?: boolean @Column() @Field({ description: 'proposed · approved · rejected · dispatched · acked · failed.' }) state: string /** * **무엇 때문에 냈나** — 규칙이 낸 조치가 가리키는 관측의 식별자(`Attention.id`). * * 두 가지를 한다. * * 같은 조건을 두 번 올리지 않는다 결정을 기다리는 커맨드의 이 값을 모아 판정에 넘긴다 * 승인하는 사람이 근거를 본다 「무슨 일이 있어서 이것이 올라왔나」 * * 앞엣것이 이 칸의 존재 이유다. 프로세스 기억으로 중복을 막으면 **재기동이 곧 중복**이 된다 — 다시 선 * 트윈이 같은 조건을 처음 보는 것으로 읽고 대기 줄에 같은 지시를 또 올린다. * * 사람이 낸 것은 비어 있다. 사람은 자기가 왜 내는지 알고 있고, 그 이유를 값으로 요구하면 아무 글자나 적힌다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'The observation that caused a rule to propose this. Empty when a person proposed it.' }) causeRef?: string /** 어느 규칙이 냈나 — 규칙을 끄거나 고칠 때 그 규칙이 무엇을 냈는지 되짚을 자리. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'The rule that proposed this, when a rule did.' }) ruleRef?: string /** * **결재선에 올라간 자리** — 업무 목록(`@things-factory/worklist`)의 액티비티 인스턴스 식별자. * * 이 값이 있으면 승인·거절은 **그 목록에서만** 한다. 여기 있는 직접 결정 뮤테이션은 막힌다 — 문이 두 * 벌이면 결재선에는 대기로 남아 있는데 조치는 이미 나간 상태가 만들어진다. * * 비어 있으면 결재선을 쓰지 않는 배포다. 그때는 직접 결정이 유일한 문이다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Approval record this command waits on. While set, decisions come only from there.' }) approvalRef?: string /* ── 승인의 기록 ───────────────────────────────────────────────────────── * 한 칸에 JSON 으로 담지 않고 세 칸으로 나눈다 — 「누가 승인했나」로 묻는 질의가 실제로 온다. * * 결재선을 지난 경우에도 여기에 적는다. 판정의 온전한 기록(위임·상신·회차)은 결재선이 들고, 이 세 칸은 * **이 커맨드에 대한 마지막 판정**이다 — 커맨드 목록이 결재선을 조회하지 않고도 「누가 언제」를 보여야 한다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Who approved or rejected it.' }) decidedBy?: string /* * ISO 문자열로 든다 — `datetime` 은 드라이버 방언이라 다섯 드라이버를 그대로 지나지 못하고, * sqlite 는 그것을 검증하지 않아 개발에서 잡히지 않는다. 이 저장소의 다른 시각 칸도 그 모양이다 * (§`TwinReference.lastSyncedAt`). 계약의 `CommandApproval.at` 도 ISO 문자열이라 옮길 것이 없다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'When it was approved or rejected (ISO 8601).' }) decidedAt?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Why — free text the approver wrote.' }) decisionNote?: string /** * 어댑터가 돌려준 것 — 저쪽에서 무엇이 되었는지 되짚을 수 있어야 한다. * * 이것이 없으면 되돌릴 때 **무엇을 되돌릴지 말할 수 없다.** */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Reference the adapter returned. Without it a reversal cannot name what to undo.' }) dispatchRef?: string /* * ── 저쪽에서 실제로 무엇이 되었나 ───────────────────────────────────────── * 상태(`state`)와 다른 축이다. `acked` 는 「저쪽이 답했다」로 끝나고, 이 셋은 그 뒤에 관측으로 * 돌아온 사실이다. 되돌아오는 관측이 없는 커넥터의 조치는 이 셋이 영원히 빈다 — 그것이 정상이고, * 빈 것은 「효과가 없었다」가 아니라 **「모른다」**다. */ /** * 받아들였으나 저쪽이 덧붙인 말 — **실패가 아니고 「끝났다」도 아니다.** * * MES 가 지시를 접수해 놓고 지시서를 못 붙이면 그 사실이 여기 온다. `error` 와 다른 칸이어야 한다 — * `error` 에 적으면 실패로 읽혀 커맨드가 `failed` 로 앉는다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'What the source added while accepting — not a failure, not a completion. Kept verbatim.' }) dispatchNote?: string /** 저쪽이 보고한 상태 — **해석하지 않고 그대로.** 어느 값이 「나갔다」인지는 현장이 안다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Status the source reported for this instruction, kept verbatim. The kernel does not interpret it.' }) effectStatus?: string /** 그 보고를 관측한 시각. 컬럼 타입을 적지 않는다 — 드라이버 방언이라 개발에서 안 잡힌다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'When that report was observed. Empty means no report has been correlated yet.' }) effectAt?: string /** 몇 번 보고됐나 — 한 번도 안 바뀐 것과 다섯 번 바뀐 것은 다른 일이다. */ @Column({ type: 'int', nullable: true }) @Field(type => Int, { nullable: true, description: 'How many reports have been correlated to this instruction.' }) effectSeen?: number /** * 저쪽이 받으면서 덧붙인 말 중 **다음에 할 일** 한 줄 — 원인은 `dispatchNote` 다. * * 왜 둘로 나누나: 읽는 사람은 「그래서 내가 뭘 해야 하나」를 먼저 찾는다(UX 레인). 한 문장으로 * 주면 화면이 자르게 되고, 자르는 규칙이 화면마다 생긴다. 어댑터가 이미 둘로 알고 있으니 * 붙이지 않고 그대로 받는다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'The next action the source expects, as one line. The cause is dispatchNote.' }) dispatchNoteNext?: string /** * **다시 해서 될 일인가** — 어댑터가 실패마다 답한다(§`CommandAck.retry`). * * again 그대로 다시 해 볼 만하다 — 못 닿았거나 저쪽이 잠깐 흔들렸다 * after-fix 사람이 고친 뒤 그대로 나간다 — 설정 문제 * never 이 조치로는 영원히 안 된다 — 지시 내용이 틀렸다 * * 가르는 자리는 「조치를 다시 낼 필요가 있나」다. 설정이 틀린 것은 조치가 멀쩡하므로 `after-fix`, * 지시 내용이 틀린 것은 그 조치가 영원히 틀렸으므로 `never` 다(인티그레이션 레인 판단). * * **비어 있으면 `never` 로 읽지 않는다.** 모르는 것과 영원히 안 되는 것은 다르다 — 선언하지 않은 * 커넥터의 실패를 `never` 로 접으면 고칠 수 있는 것을 사람이 포기한다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Whether retrying can work: again · after-fix · never. Empty means the adapter did not say.' }) retry?: string /** 몇 번 넘겨 봤나 — 목록의 「몇 번 해 봤나」가 이 값이다. 첫 시도가 1이다. */ @Column({ type: 'int', nullable: true }) @Field(type => Int, { nullable: true, description: 'How many dispatch attempts have been made. The first attempt is 1.' }) attempts?: number /** * **다음에 넘겨 볼 시각**(ISO) — 물러섬을 표로 든다. * * 메모리에 두지 않는 이유: 재기동하면 사라지고, 그러면 물러서던 것이 전부 즉시 재시도가 된다. * 표에 두면 일꾼이 여러 대여도 같은 답을 본다. * * 비어 있으면 **다음 주기에 바로** 본다(한 번도 실패하지 않은 것). */ @Column({ nullable: true }) @Field({ nullable: true, description: 'When to try dispatching again (ISO 8601). Empty means the next cycle.' }) nextAttemptAt?: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Why the dispatch failed. The server sentence, kept as it came.' }) error?: string @CreateDateColumn() @Field({ nullable: true, description: 'When the command was proposed.' }) createdAt?: Date @UpdateDateColumn() @Field({ nullable: true, description: 'When the row last changed.' }) updatedAt?: Date }