/* * **어느 관측에 어느 지시를 붙이는가** — 선언 한 행. * * ── 왜 표인가 (2026-09-01) ─────────────────────────────────────────────── * 규칙은 현장이 정한다. 「병목이 세 시간 넘으면 추가 생산을 올린다」는 그 공장의 정책이고, 코드에 박으면 * 공장마다 포크가 생긴다. 그리고 **끌 수 있어야 한다** — 규칙이 잘못 낸 것을 본 사람이 가장 먼저 하려는 * 일이 그것이다. * * ── 판정은 여기 없다 ───────────────────────────────────────────────────── * 무엇을 올릴지는 `rule-evaluate.ts` 가 든다. 이 파일은 값을 담기만 한다 — 판정이 두 벌이 되면 화면과 * 서버가 다른 답을 낸다. * * ── 계약에 넣지 않았다 ─────────────────────────────────────────────────── * 이 규칙이 가리키는 두 값(`Attention.kind` · `CommandTypeSpec.type`)은 계약의 어휘다. 그러나 「그 둘을 * 어떻게 잇는가」는 이 호스트의 배선이다 — 커널은 규칙을 읽지 않고, 커널을 쓰는 다른 소비처가 이 표를 * 알 이유가 없다. */ import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId, UpdateDateColumn } from 'typeorm' import { Field, ID, ObjectType } from 'type-graphql' import { Domain, ScalarObject } from '@things-factory/shell' import { User } from '@things-factory/auth-base' @Entity() /* 판정이 이 두 칸으로 묻는다 — 이 테넌트의 · 이 트윈의 규칙. */ @Index('ix_twin_actuation_rule_0', (e: TwinActuationRule) => [e.domain, e.instanceId]) @ObjectType({ description: 'One rule mapping an observed condition to a field instruction that needs approval.' }) export class TwinActuationRule { @PrimaryGeneratedColumn('uuid') @Field(type => ID, { description: 'Unique identifier of the rule.' }) readonly id: string @ManyToOne(type => Domain) @Field(type => Domain, { nullable: true, description: 'Tenant this rule belongs to.' }) domain?: Domain @RelationId((e: TwinActuationRule) => e.domain) domainId?: string /** * **어느 트윈인가** — 전역 규칙을 두지 않는다. * * 라인이 공장마다 다르므로 한 규칙이 모든 트윈에 걸리면 어느 공장에서는 반드시 틀린다. 트윈을 지목하면 * 그 사실이 행에 드러나고, 화면이 「이 공장에 걸린 규칙」을 그대로 보여 줄 수 있다. */ @Column() @Field({ description: 'Twin instance this rule applies to. There are no global rules.' }) instanceId: string @Column({ nullable: true }) @Field({ nullable: true, description: 'What a person calls this rule.' }) name?: string /** 어느 관측인가 — 커널의 안정 코드(`Attention.kind`). */ @Column() @Field({ description: 'Observed condition kind in kernel vocabulary (Attention.kind).' }) attentionKind: string @Column({ nullable: true }) @Field({ nullable: true, description: 'Only at this severity or above: low, medium, high, critical.' }) minSeverity?: string /** * **조건이 이만큼 이어졌을 때** 올린다. * * 없으면 처음 본 주기에 올린다. 깜빡이는 조건에 현장 지시가 나가는 것을 막는 유일한 칸이다. */ @Column({ type: 'int', nullable: true }) @Field({ nullable: true, description: 'Propose only after the condition has held this long (ms).' }) minHeldMs?: number /** * 무엇을 내는가 — **선언된 조치 종류**(`CommandTypeSpec.type`). * * 커널 자극(`CMD`)이 아니다. 자극을 적으면 판정이 「선언되지 않은 조치 종류」로 거절한다 — 그 값은 * 모델 상태를 바꾸는 것이고 현장에 나가는 지시가 아니다. */ @Column() @Field({ description: 'Declared command type to propose. Not a kernel stimulus.' }) commandType: string /** * 지시의 내용 — `{이름}` 을 관측의 값으로 바꿔 넣는다. * * 바꿀 값은 `Attention.params` 와 `anchor` 에서 온다. 바꿀 것이 없으면 그 칸을 비우고, 요구하는 칸이 * 비면 판정이 거절한다 — 지어낸 값으로 현장 지시를 만들지 않는다. */ @Column({ type: 'simple-json', nullable: true }) @Field(type => ScalarObject, { nullable: true, description: 'Command payload template. {name} is filled from the observation.' }) payloadTemplate?: any /** * **한 번 낸 근거에 다시 내기까지 기다리는 시간(ms).** * * 비워 두면 기본값이 걸린다(한 시간). 0 으로 두면 기다리지 않는데, 그러면 승인하는 순간 같은 것이 * 다시 올라온다 — 걸어 보고 실제로 그 상태를 만들었다. */ @Column({ type: 'int', nullable: true }) @Field({ nullable: true, description: 'Wait this long before proposing again for the same cause (ms).' }) cooldownMs?: number /** 지우지 않고 끌 수 있어야 한다 — 잘못 낸 것을 본 사람이 가장 먼저 하려는 일이다. */ @Column({ default: true }) @Field({ nullable: true, description: 'A disabled rule is not evaluated. Turning off is not deleting.' }) enabled?: boolean /* ── 마지막 판정의 기록 ───────────────────────────────────────────────── * 규칙이 **아무것도 올리지 않을 때** 사람이 알 수 있어야 한다. 조용히 안 도는 규칙과 조건이 없어서 안 * 올리는 규칙은 화면에서 똑같이 보이고, 그 둘을 구별하지 못하면 사람은 규칙이 도는 줄 안다. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'When this rule was last evaluated (ISO 8601).' }) lastRunAt?: string @Column({ type: 'int', nullable: true }) @Field({ nullable: true, description: 'How many commands this rule has proposed in total.' }) proposedCount?: number /** 마지막에 왜 안 올렸나 — 사람 말로. */ @Column({ nullable: true }) @Field({ nullable: true, description: 'Why the last evaluation proposed nothing, when it did not.' }) lastSkipReason?: string @ManyToOne(type => User, { nullable: true }) @Field(type => User, { nullable: true }) creator?: User @ManyToOne(type => User, { nullable: true }) @Field(type => User, { nullable: true }) updater?: User @CreateDateColumn() @Field({ nullable: true }) createdAt?: Date @UpdateDateColumn() @Field({ nullable: true }) updatedAt?: Date }