G8 실제 rollback 증명 종료와 비-secure origin 회기 리뷰 크래시 수정

G8 마지막 게이트인 receipt-bound 실제 image rollback을 격리 NAS
vignette-preview-20260807 에서 실행해 종료했다.

Gate6 계약 정정:
감사 대상 current API 이미지가 com.docker.compose.project/service/version
image label 을 갖고 있어 "helper 의 compose label 0개" 계약은 감사되지 않은
다른 이미지를 쓰지 않는 한 성립하지 않는다. 계약을 key 부재가 아니라
소속(membership) 으로 바꿔 launch-nas-preview-g8-helpers.py 에 구현했다.
image 상속 label 을 baseline 으로 읽고 container 의 모든 compose label 이
baseline 과 같거나 선언된 격리 override 인지 검사하며, 최종 project 는
target 이 아니고 service 는 api/web/db/proxy 가 아니어야 한다. docker run
argv 에 target label 을 주입하면 fake-runner 테스트가 먼저 깨진다 (37/37).

실행 결과:
- rollback-old  receipt nas-g8-723eeef22eab05e63e3fafb0 -> 79ec../c530..
- restore-current receipt nas-g8-2738846cf2cf4fbe8ce0fc26 -> 52e0../6fdb..
- release gate/approval 각 2회 멱등, audit.ci_lifecycle_event rollback/executed 2,
  audit.ci_human_approval_event authorize_rollback 2, silent auto-promotion 0
- HMAC journal 6-record 체인 검증, health 3/3, OpenAPI 126, auth 401, Web 200
- helper 0, listener 0, 비밀 env 파기. down/volume rm/prune 미실행, 공개 런타임 미접촉
- 계획했던 Windows SSH 터널은 NAS sshd 가 direct-tcpip 를 거부해 사용할 수 없어
  sshd 설정 변경 대신 같은 격리 계약의 NAS-side probe 컨테이너로 실행했다

비-secure origin 크래시 수정:
배포된 NAS 프리뷰(평문 HTTP, 비-localhost)에 회기 스펙을 돌려 24건 실패를 확인했고
원인은 하나였다. crypto.randomUUID 는 secure context 전용인데 제품 코드 18곳이
fallback 없이 호출했고 RuptureRepairCard 는 렌더 시점 호출이라 회기 리뷰 라우트
전체가 error boundary 로 떨어졌다. 릴리스 게이트 108/108 은 localhost 후보 스택에서만
돌아 이 경로를 밟은 적이 없다. src/lib/uuid.ts 의 randomUuid() 로 통일하고 fallback 도
crypto.getRandomValues 를 우선 사용해 idempotency key 의 예측 불가능성을 유지했다.
회귀는 insecure-context-uuid.spec.ts 6/6 으로 고정했다(직접 호출 0건 검사 포함).
이 수정은 아직 NAS 에 배포하지 않았다.

검증:
API 898, gateway 58, executor 28, probe 11, helper launcher 37, release agent 21,
ruff clean, web api-types/typecheck/build, SSOT FAIL 0, SSOT unit 5/5,
dashboard E2E 10/10, 학생 폐루프 실 DB 브라우저 4/4(일회용 클론),
crypto 수정 후 기존 스펙 회귀 70/70, 복원된 NAS 실제 브라우저 SSE->DB 리뷰 PASS.

부수 발견(열린 항목):
공개 API 가 engine=false 로 degraded 인데 워치독이 이를 감지하지 못한다. engine 판정이
게이트웨이 /health 의 ok 만 보고 claude readiness probe 를 돌리지 않기 때문이다.
같은 .env 와 같은 CLI 로 새 게이트웨이를 다른 포트에 띄우면 즉시 ready 이므로 상주
프로세스의 세션만 죽은 형태다. TODO A절과 대시보드에 기록했다.

이 커밋은 파일 단위로 담겼다. 위 파일들에는 이전 세션의 미커밋 G0~G8 작업이 함께
들어 있으며, hunk 를 쪼개면 대시보드/체커/TODO 정합성이 깨져 SSOT 체커가 실패한다.
This commit is contained in:
Yun Chan 2026-08-07 22:17:20 +09:00
parent 76d0b9ae9b
commit 93dd8f82d7
22 changed files with 10057 additions and 473 deletions

View file

@ -0,0 +1,920 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Icon, Kicker } from "../../components/ui";
import { randomUuid } from "../../lib/uuid";
import {
continuousImprovementApi,
type ContentQualificationView,
type ContinuousImprovementGateKind,
type ContinuousImprovementViewResponse,
type GateArtifactView,
type HumanApprovalRequest,
type LifecycleEventView,
type ModelChangeGateView,
type RegressionDagNodeView,
type ReleaseGateView,
} from "./continuousImprovementApi";
const ARTIFACT_KINDS = ["baseline", "threshold", "provenance", "rollback"] as const;
const ARTIFACT_LABELS: Record<(typeof ARTIFACT_KINDS)[number], string> = {
baseline: "기준선",
threshold: "임계값",
provenance: "출처",
rollback: "롤백",
};
const NODE_LABELS: Record<RegressionDagNodeView["node_type"], string> = {
reproduction_test: "재현 테스트",
implementation: "구현",
e2e: "E2E",
runtime_proof: "런타임 증명",
};
const EVENT_LABELS: Record<LifecycleEventView["event_status"], string> = {
approved: "승인 원장 기록",
requested: "롤백 실행 대기 · 실행기 미구성",
executed: "롤백 실행",
failed: "롤백 실행 실패",
healthy: "모니터 정상",
drift_detected: "드리프트 감지",
rollback_recommended: "롤백 권고",
rollback_verified: "롤백 검증 완료",
};
const CONTENT_KIND_LABELS: Record<ContentQualificationView["content_kind"], string> = {
case: "사례",
rupture: "균열 수선",
practice: "의도적 수련",
benchmark: "벤치마크",
};
type GateItem =
| { kind: "model_change_gate"; value: ModelChangeGateView }
| { kind: "release_gate"; value: ReleaseGateView };
function shortId(value: string, length = 10): string {
return value.length > length ? `${value.slice(0, length)}` : value;
}
function dateTimeLabel(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "기록 시각 미상";
return new Intl.DateTimeFormat("ko-KR", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function gateId(item: GateItem): string {
return item.value.gate_id;
}
function gateTitle(item: GateItem): string {
if (item.kind === "model_change_gate") return "모델 변경 게이트";
return `릴리스 ${item.value.release_id}`;
}
function gateDecision(item: GateItem): HumanApprovalRequest["decision"] {
if (item.kind === "release_gate") {
return item.value.qualified ? "approve_promotion" : "reject";
}
if (item.value.gate_decision === "promote") return "approve_promotion";
if (item.value.gate_decision === "rollback") return "authorize_rollback";
return "keep_quarantine";
}
function decisionLabel(item: GateItem): string {
if (item.kind === "release_gate") {
return item.value.qualified ? "승격 후보" : "게이트 미통과";
}
if (item.value.gate_decision === "promote") return "승격 후보";
if (item.value.gate_decision === "rollback") return "롤백 후보";
return "격리 유지";
}
function actionLabel(item: GateItem): string {
const decision = gateDecision(item);
if (decision === "approve_promotion") return "사람 승인 기록";
if (decision === "authorize_rollback") return "롤백 승인 기록";
if (decision === "keep_quarantine") return "격리 결정 기록";
return "거부 결정 기록";
}
function artifactsFor(
view: ContinuousImprovementViewResponse,
kind: ContinuousImprovementGateKind,
ownerId: string,
): GateArtifactView[] {
return view.gate_artifacts.filter(
(artifact) => artifact.owner_kind === kind && artifact.owner_id === ownerId,
);
}
function hasCompleteArtifacts(artifacts: GateArtifactView[]): boolean {
return ARTIFACT_KINDS.every((kind) =>
artifacts.some((artifact) => artifact.artifact_kind === kind),
);
}
function isBoundarySafe(view: ContinuousImprovementViewResponse): boolean {
return (
view.data_classification === "synthetic_replay_red_team_coverage_drift" &&
!view.silent_auto_promotion_allowed &&
!view.raw_transcript_included &&
!view.pii_included &&
!view.clinical_claim_allowed
);
}
function BoundaryStrip({ view }: { view: ContinuousImprovementViewResponse }) {
const boundaries = [
["입력", "합성 replay · red-team · coverage drift"],
["승격", "silent auto-promotion 금지"],
["효과", "사람 승인 후 append-only"],
["데이터", "원문 · PII · 임상 주장 미포함"],
];
const safe = isBoundarySafe(view);
return (
<section className={`cic-boundary ${safe ? "is-safe" : "is-blocked"}`} aria-label="운영 경계">
<div className="cic-boundary__mark" aria-hidden="true">
<Icon name={safe ? "shield" : "alert"} size={19} />
</div>
{boundaries.map(([label, value]) => (
<div className="cic-boundary__item" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</section>
);
}
function ArtifactRail({ artifacts }: { artifacts: GateArtifactView[] }) {
return (
<ol className="cic-artifacts" aria-label="게이트 필수 증거">
{ARTIFACT_KINDS.map((kind, index) => {
const matches = artifacts.filter((artifact) => artifact.artifact_kind === kind);
const ready = matches.length > 0;
return (
<li className={ready ? "is-ready" : "is-missing"} key={kind}>
<span className="cic-artifacts__index" aria-hidden="true">
{ready ? <Icon name="check" size={14} /> : index + 1}
</span>
<div>
<strong>{ARTIFACT_LABELS[kind]}</strong>
<span>{ready ? `${matches.length}개 고정` : "증거 없음"}</span>
</div>
</li>
);
})}
</ol>
);
}
function ContentQualificationCard({
item,
view,
reason,
onReasonChange,
onApprove,
approving,
}: {
item: ContentQualificationView;
view: ContinuousImprovementViewResponse;
reason: string;
onReasonChange: (value: string) => void;
onApprove: () => void;
approving: boolean;
}) {
const payload = item.draft_payload;
const catalog = view.catalog_entries.find(
(entry) => entry.qualification_id === item.qualification_id,
);
const approval = view.approvals.find(
(candidate) =>
candidate.target_kind === "content_qualification" &&
candidate.target_id === item.qualification_id,
);
const boundarySafe = isBoundarySafe(view);
const evidenceReady = item.source_provenance_uris.length > 0;
const canApprove =
boundarySafe && Boolean(payload) && evidenceReady && !approval && !catalog && reason.trim().length > 0;
const requirementId = `content-approval-requirement-${item.qualification_id}`;
const titleId = `content-qualification-title-${item.qualification_id}`;
const blockedReason = !boundarySafe
? "데이터 경계 위반으로 승인 차단"
: !payload
? "검수 가능한 visible payload가 없어 승인 차단"
: !evidenceReady
? "repo provenance가 없어 승인 차단"
: !reason.trim()
? "승인 사유를 먼저 입력해야 함"
: "visible payload와 repo provenance 검토 준비됨";
return (
<article
className="cic-content-card"
data-qualification-id={item.qualification_id}
aria-labelledby={titleId}
>
<header>
<div>
<span className="cic-content-card__kind">{CONTENT_KIND_LABELS[item.content_kind]}</span>
<h3 id={titleId}>{payload?.title ?? item.catalog_entry_id}</h3>
<code>{item.catalog_entry_id}</code>
</div>
<span className={`cic-decision ${catalog ? "is-approved" : ""}`}>
{catalog ? "카탈로그 승인" : "사람 검토 대기"}
</span>
</header>
<dl className="cic-content-card__facts" aria-label="후보 검증 요약">
<div>
<dt></dt>
<dd>{item.difficulty_level}/5</dd>
</div>
<div>
<dt> </dt>
<dd>{item.red_team_review_count} agents</dd>
</div>
<div>
<dt> </dt>
<dd>
{Math.round(item.benchmark_pass_rate * 100)}% · {item.benchmark_variant_count} variants
</dd>
</div>
<div>
<dt></dt>
<dd>{item.source_count} repo source</dd>
</div>
</dl>
{payload ? (
<details className="cic-content-review">
<summary> payload </summary>
<div className="cic-content-review__body">
<section>
<h4> </h4>
<p>{payload.synthetic_profile}</p>
</section>
<section>
<h4> </h4>
<p>{payload.scenario}</p>
<p>{payload.rupture_or_challenge}</p>
</section>
<section>
<h4> </h4>
<p>{payload.learner_task}</p>
<ul>
{payload.success_criteria.map((criterion) => (
<li key={criterion}>{criterion}</li>
))}
</ul>
</section>
<section>
<h4> </h4>
<ul>
{payload.grounded_claims.map((claim) => (
<li key={`${claim.source_ref}:${claim.claim}`}>
{claim.claim} <code>{claim.source_ref}</code>
</li>
))}
</ul>
</section>
</div>
</details>
) : (
<div className="cic-content-card__blocked" role="alert">
visible payload를 . .
</div>
)}
{approval ? (
<div className="cic-effect" role="status">
<Icon name={catalog ? "check" : "alert"} size={17} />
<div>
<strong>{catalog ? "카탈로그 승인 원장 기록됨" : "후보 결정 원장 기록됨"}</strong>
<span>
{approval.decision} · {dateTimeLabel(approval.created_at)}
</span>
</div>
</div>
) : (
<form
className="cic-approval-form"
onSubmit={(event) => {
event.preventDefault();
if (canApprove && !approving) onApprove();
}}
>
<label htmlFor={`content-reason-${item.qualification_id}`}>
<span> </span>
<input
id={`content-reason-${item.qualification_id}`}
name={`content-approval-reason-${item.qualification_id}`}
value={reason}
onChange={(event) => onReasonChange(event.target.value)}
aria-describedby={requirementId}
placeholder="예: 합성 경계와 수련 목표를 직접 검수함"
autoComplete="off"
maxLength={180}
/>
</label>
<Button
size="sm"
variant="primary"
type="submit"
disabled={!canApprove || approving}
aria-describedby={requirementId}
>
{approving ? "승인 원장 기록 중…" : "카탈로그 승인"}
</Button>
<p
id={requirementId}
className={`cic-approval-requirement ${canApprove ? "is-ready" : !boundarySafe ? "is-blocked" : ""}`}
>
{blockedReason}
</p>
</form>
)}
</article>
);
}
function PipelineSection({
view,
reasons,
setReason,
approve,
approvingId,
}: {
view: ContinuousImprovementViewResponse;
reasons: Record<string, string>;
setReason: (id: string, value: string) => void;
approve: (item: ContentQualificationView) => void;
approvingId: string | null;
}) {
const latest = view.content_qualifications.slice(0, 4);
return (
<section className="cic-panel cic-pipeline" aria-labelledby="cic-pipeline-title">
<header className="cic-section-head">
<div>
<Kicker dot={false}>CONTENT QUALIFICATION</Kicker>
<h2 id="cic-pipeline-title"> </h2>
<p> red-team과 benchmark를 .</p>
</div>
<span className="cic-state-chip"> </span>
</header>
<div className="cic-pipeline__flow" aria-label="콘텐츠 검증 단계">
{[
["01", "Synthetic replay", "비식별 합성 입력"],
["02", "Independent red-team", "안전 · 누출 · 편향"],
["03", "Coverage drift", "변형 benchmark"],
["04", "Human catalog gate", "append-only 승인"],
].map(([step, title, detail]) => (
<div className="cic-pipeline__step" key={step}>
<span>{step}</span>
<strong>{title}</strong>
<small>{detail}</small>
</div>
))}
</div>
{latest.length === 0 ? (
<div className="cic-empty" role="status">
<Icon name="hourglass" size={20} />
<div>
<strong> </strong>
<p> qualified candidate를 .</p>
</div>
</div>
) : (
<div className="cic-content-grid">
{latest.map((item) => (
<ContentQualificationCard
key={item.qualification_id}
item={item}
view={view}
reason={reasons[item.qualification_id] ?? ""}
onReasonChange={(value) => setReason(item.qualification_id, value)}
onApprove={() => approve(item)}
approving={approvingId === item.qualification_id}
/>
))}
</div>
)}
</section>
);
}
function GateCard({
item,
view,
reason,
onReasonChange,
onApprove,
approving,
boundarySafe,
}: {
item: GateItem;
view: ContinuousImprovementViewResponse;
reason: string;
onReasonChange: (value: string) => void;
onApprove: () => void;
approving: boolean;
boundarySafe: boolean;
}) {
const id = gateId(item);
const artifacts = artifactsFor(view, item.kind, id);
const complete = hasCompleteArtifacts(artifacts);
const approval = view.approvals.find(
(candidate) => candidate.target_kind === item.kind && candidate.target_id === id,
);
const effect = view.lifecycle_events.find(
(event) => event.target_kind === item.kind && event.target_id === id,
);
const canSubmit = boundarySafe && complete && !approval && reason.trim().length > 0;
const blockedReason = !boundarySafe
? "데이터 경계 위반으로 승인 차단"
: !complete
? "필수 증거 4종이 모두 있어야 기록 가능"
: !reason.trim()
? "승인 사유를 먼저 입력해야 함"
: undefined;
const requirementId = `approval-requirement-${id}`;
const requirementCopy = blockedReason ?? "증거 4종과 승인 사유가 준비됨";
return (
<article className="cic-gate-card" data-gate-id={id}>
<header>
<div>
<span className="cic-gate-card__kind">
{item.kind === "model_change_gate" ? "MODEL" : "RELEASE"}
</span>
<h3>{gateTitle(item)}</h3>
<code>{shortId(id, 14)}</code>
</div>
<span className={`cic-decision cic-decision--${gateDecision(item)}`}>
{decisionLabel(item)}
</span>
</header>
{item.kind === "model_change_gate" && item.value.reasons.length > 0 ? (
<ul className="cic-gate-card__reasons">
{item.value.reasons.slice(0, 3).map((value) => (
<li key={value}>{value}</li>
))}
</ul>
) : null}
<ArtifactRail artifacts={artifacts} />
{approval ? (
<div className="cic-effect" role="status">
<Icon name="check" size={17} />
<div>
<strong>append-only </strong>
<span>
{approval.decision} · {dateTimeLabel(approval.created_at)}
{effect ? ` · effect ${shortId(effect.lifecycle_event_id, 8)}` : ""}
</span>
</div>
</div>
) : (
<form
className="cic-approval-form"
onSubmit={(event) => {
event.preventDefault();
if (canSubmit && !approving) onApprove();
}}
>
<label htmlFor={`reason-${id}`}>
<span> </span>
<input
id={`reason-${id}`}
name={`approval-reason-${id}`}
value={reason}
onChange={(event) => onReasonChange(event.target.value)}
aria-describedby={requirementId}
placeholder="예: 기준선과 롤백 절차를 독립 검토함…"
autoComplete="off"
maxLength={180}
/>
</label>
<Button
size="sm"
variant={gateDecision(item) === "authorize_rollback" ? "danger" : "primary"}
type="submit"
disabled={!canSubmit || approving}
aria-describedby={requirementId}
>
{approving ? "원장 기록 중…" : complete ? actionLabel(item) : "증거 4종 미완료"}
</Button>
<p
id={requirementId}
className={`cic-approval-requirement ${!boundarySafe ? "is-blocked" : canSubmit ? "is-ready" : ""}`}
>
{requirementCopy}
</p>
</form>
)}
</article>
);
}
function GateSection({
view,
reasons,
setReason,
approve,
approvingId,
}: {
view: ContinuousImprovementViewResponse;
reasons: Record<string, string>;
setReason: (id: string, value: string) => void;
approve: (item: GateItem) => void;
approvingId: string | null;
}) {
const items: GateItem[] = [
...view.model_change_gates.map((value) => ({ kind: "model_change_gate" as const, value })),
...view.release_gates.map((value) => ({ kind: "release_gate" as const, value })),
].sort(
(left, right) =>
new Date(right.value.created_at).getTime() - new Date(left.value.created_at).getTime(),
);
const boundarySafe = isBoundarySafe(view);
return (
<section className="cic-gates" aria-labelledby="cic-gates-title">
<header className="cic-section-head">
<div>
<Kicker dot={false}>PROMOTION CONTROL</Kicker>
<h2 id="cic-gates-title"> · </h2>
<p> . 4 .</p>
</div>
</header>
{items.length === 0 ? (
<div className="cic-empty" role="status">
<Icon name="shield" size={20} />
<div>
<strong> </strong>
<p> .</p>
</div>
</div>
) : (
<div className="cic-gates__grid">
{items.map((item) => (
<GateCard
key={`${item.kind}:${gateId(item)}`}
item={item}
view={view}
reason={reasons[gateId(item)] ?? ""}
onReasonChange={(value) => setReason(gateId(item), value)}
onApprove={() => approve(item)}
approving={approvingId === gateId(item)}
boundarySafe={boundarySafe}
/>
))}
</div>
)}
</section>
);
}
function IncidentDag({ view }: { view: ContinuousImprovementViewResponse }) {
const incidents = view.incidents.slice(0, 3);
return (
<section className="cic-panel" aria-labelledby="cic-incidents-title">
<header className="cic-section-head cic-section-head--compact">
<div>
<Kicker dot={false}>INCIDENT REGRESSION</Kicker>
<h2 id="cic-incidents-title"> DAG</h2>
<p> .</p>
</div>
</header>
{incidents.length === 0 ? (
<div className="cic-empty cic-empty--plain" role="status">
<Icon name="check" size={19} />
<div>
<strong> </strong>
<p> DAG가 .</p>
</div>
</div>
) : (
<div className="cic-incident-list">
{incidents.map((incident) => {
const nodes = view.regression_dag_nodes.filter(
(node) => node.incident_record_id === incident.incident_record_id,
);
return (
<article key={incident.incident_record_id}>
<header>
<div>
<strong>{incident.affected_contract}</strong>
<code>{incident.incident_id}</code>
</div>
<span>{dateTimeLabel(incident.created_at)}</span>
</header>
<ol className="cic-dag" aria-label={`${incident.incident_id} 회귀 DAG`}>
{(["reproduction_test", "implementation", "e2e", "runtime_proof"] as const).map(
(kind) => {
const node = nodes.find((candidate) => candidate.node_type === kind);
return (
<li className={`is-${node?.node_status ?? "pending"}`} key={kind}>
<span aria-hidden="true">
{node?.node_status === "passed" ? <Icon name="check" size={14} /> : null}
</span>
<strong>{NODE_LABELS[kind]}</strong>
<small>{node?.node_status ?? "pending"}</small>
</li>
);
},
)}
</ol>
</article>
);
})}
</div>
)}
</section>
);
}
function LifecycleLedger({ events }: { events: LifecycleEventView[] }) {
return (
<section className="cic-panel" aria-labelledby="cic-lifecycle-title">
<header className="cic-section-head cic-section-head--compact">
<div>
<Kicker dot={false}>APPEND-ONLY EFFECTS</Kicker>
<h2 id="cic-lifecycle-title"> · </h2>
<p> .</p>
</div>
</header>
{events.length === 0 ? (
<div className="cic-empty cic-empty--plain" role="status">
<Icon name="hourglass" size={19} />
<div>
<strong> </strong>
<p> .</p>
</div>
</div>
) : (
<ol className="cic-ledger">
{events.slice(0, 8).map((event) => {
const rollbackVerificationPending =
event.event_type === "rollback" &&
event.event_status === "executed" &&
!events.some(
(candidate) =>
candidate.target_kind === event.target_kind &&
candidate.target_id === event.target_id &&
candidate.event_status === "rollback_verified" &&
new Date(candidate.created_at) > new Date(event.created_at),
);
return (
<li className={`is-${event.event_status}`} key={event.lifecycle_event_id}>
<span className="cic-ledger__line" aria-hidden="true" />
<span className="cic-ledger__dot" aria-hidden="true" />
<div>
<strong>{EVENT_LABELS[event.event_status]}</strong>
<span>
{event.target_kind === "model_change_gate" ? "모델" : "릴리스"} · {shortId(event.target_id)}
</span>
{rollbackVerificationPending ? (
<em> verification </em>
) : null}
</div>
<time dateTime={event.created_at}>{dateTimeLabel(event.created_at)}</time>
</li>
);
})}
</ol>
)}
</section>
);
}
export function ContinuousImprovementCockpit() {
const [view, setView] = useState<ContinuousImprovementViewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [approvingId, setApprovingId] = useState<string | null>(null);
const [reasons, setReasons] = useState<Record<string, string>>({});
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError(null);
try {
const next = await continuousImprovementApi.read(signal);
setView(next);
} catch (cause) {
if (signal?.aborted) return;
setError(cause instanceof Error ? cause.message : "개선 원장을 불러오지 못했어.");
} finally {
if (!signal?.aborted) setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
const approve = useCallback(
async (item: GateItem) => {
if (!view) return;
const id = gateId(item);
const artifacts = artifactsFor(view, item.kind, id);
const reason = reasons[id]?.trim() ?? "";
const alreadyApproved = view.approvals.some(
(candidate) => candidate.target_kind === item.kind && candidate.target_id === id,
);
if (!isBoundarySafe(view) || !hasCompleteArtifacts(artifacts) || alreadyApproved || !reason) {
return;
}
setApprovingId(id);
setError(null);
setNotice(null);
try {
const evidenceRefs = [...new Set(artifacts.map((artifact) => artifact.provenance_uri))];
const result = await continuousImprovementApi.appendApproval({
submission_id: randomUuid(),
approval_event_id: randomUuid(),
effect_record_id: randomUuid(),
target_kind: item.kind,
target_id: id,
decision: gateDecision(item),
reason_code: reason,
evidence_refs: evidenceRefs,
});
setNotice(`append-only 승인과 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`);
setReasons((current) => ({ ...current, [id]: "" }));
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "승인 원장을 기록하지 못했어.");
} finally {
setApprovingId(null);
}
},
[load, reasons, view],
);
const approveQualification = useCallback(
async (item: ContentQualificationView) => {
if (!view) return;
const id = item.qualification_id;
const reason = reasons[id]?.trim() ?? "";
const alreadyApproved = view.approvals.some(
(candidate) =>
candidate.target_kind === "content_qualification" && candidate.target_id === id,
);
const alreadyCataloged = view.catalog_entries.some(
(candidate) => candidate.qualification_id === id,
);
if (
!isBoundarySafe(view) ||
!item.draft_payload ||
item.source_provenance_uris.length === 0 ||
alreadyApproved ||
alreadyCataloged ||
!reason
) {
return;
}
setApprovingId(id);
setError(null);
setNotice(null);
try {
const evidenceRefs = [
...new Set([
...item.source_provenance_uris,
`audit://continuous-improvement/human-review/${id}`,
]),
];
const result = await continuousImprovementApi.appendApproval({
submission_id: randomUuid(),
approval_event_id: randomUuid(),
effect_record_id: randomUuid(),
target_kind: "content_qualification",
target_id: id,
decision: "approve_content",
reason_code: reason,
evidence_refs: evidenceRefs,
});
setNotice(`카탈로그 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`);
setReasons((current) => ({ ...current, [id]: "" }));
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "콘텐츠 승인 원장을 기록하지 못했어.");
} finally {
setApprovingId(null);
}
},
[load, reasons, view],
);
const orderedEvents = useMemo(
() =>
[...(view?.lifecycle_events ?? [])].sort(
(left, right) => new Date(right.created_at).getTime() - new Date(left.created_at).getTime(),
),
[view],
);
if (!view && loading) {
return (
<div className="cic-loading" role="status" aria-live="polite">
<span aria-hidden="true" />
</div>
);
}
if (!view) {
return (
<section className="cic-degraded" role="alert">
<Icon name="alert" size={22} />
<div>
<Kicker dot={false}>DEGRADED READ MODEL</Kicker>
<h1> </h1>
<p>{error ?? "운영 원장이 응답하지 않았어."} .</p>
<Button size="sm" variant="secondary" onClick={() => void load()}>
</Button>
</div>
</section>
);
}
return (
<div className="cic" data-testid="continuous-improvement-cockpit" aria-busy={loading}>
<header className="cic-hero">
<div>
<Kicker> · CONTINUOUS IMPROVEMENT OS</Kicker>
<h1> </h1>
<p>
, ,
.
</p>
</div>
<Button
size="sm"
variant="secondary"
leading={<Icon name="refresh" size={16} />}
disabled={loading}
onClick={() => void load()}
>
</Button>
</header>
{error ? (
<div className="cic-alert" role="alert">
<Icon name="alert" size={17} />
<span> · · {error}</span>
</div>
) : null}
{notice ? (
<div className="cic-notice" role="status" aria-live="polite">
<Icon name="check" size={17} />
<span>{notice}</span>
</div>
) : null}
<BoundaryStrip view={view} />
<PipelineSection
view={view}
reasons={reasons}
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
approve={(item) => void approveQualification(item)}
approvingId={approvingId}
/>
<GateSection
view={view}
reasons={reasons}
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
approve={(item) => void approve(item)}
approvingId={approvingId}
/>
<div className="cic-lower-grid">
<IncidentDag view={view} />
<LifecycleLedger events={orderedEvents} />
</div>
<footer className="cic-footnote">
<Icon name="info" size={16} />
<p>
. · , ,
PII, , .
</p>
</footer>
</div>
);
}