fix(studio-save): PROJECT_DECISION 의 칸을 계약에 맞추고 지울 수 있게 만든다

B-010 §4.1 에서 찾고 안 고친 것이다. 그때 안 고친 이유는 예산이 아니라 측정이었고,
이번에는 그 런에서 잰다.

- 결정문→statement · 영향→consequences(OrderedText 배열) · 판단 이유→rationale
- 근거→basis 를 뺐다. ProjectDecisionInput 에 그 칸이 없고 unevaluatedProperties:
  false 라 보내면 거절된다. 기록의 ## 근거 절은 그대로 둔다
- decisionStatus·decidedOn 을 frontmatter 에서 읽는다. enum 밖의 값은 지어내지 않고
  거절한다 — 운영에 초안을 만들어 놓고 422 를 받는 것보다 낫다

그리고 절의 모양이 둘이었다. 사실·가정은 `- ` 목록이고 DECISION 의 영향은 빈 줄로 나뉜
문단이다. 목록만 읽어서 문단으로 쓴 절이 조용히 [] 가 되고 있었다 — minItems 가 없어
그대로 저장되고 내용만 사라진다. 목록을 먼저 보고 없으면 문단으로 나누며, 글이 있는데
항목이 0 개면 거절한다. QUESTION·REFERENCE 의 이미 잰 값은 안 바뀐다(목록이라 첫 갈래에서
끝난다).

--harness-test 의 PROJECT_DECISION 거절은 우회하지 않고 값을 받게 했다. --project-id 는
사람이 Studio 목록에서 읽은 uuid 다 — 어댑터가 이름을 uuid 로 바꾸지 않는다. 없으면
여전히 거절한다. projectId 는 [string, "null"] 이고 계약이 저장 시점에는 강제하지 않으니
null 로도 만들어지고, 만들어지면 지울 수 없다. 되읽기가 그 값을 확인한다.

python3 -m unittest discover -s scripts/tests — Ran 278 · OK (skipped=13)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
This commit is contained in:
DongHyeonka
2026-09-11 11:03:03 +09:00
co-authored by Claude Opus 5
parent 0d588814cc
commit f765a79d6f
2 changed files with 127 additions and 9 deletions
+72 -9
View File
@@ -77,8 +77,13 @@ FIELD_MAP = {
# C 가 V-009 의 `P-QUESTION-02` 에서 맞았다. 여기서는 이름만 잇고 모양은 `_question_shape` 가 만든다
"QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns",
"제약": "constraints", "선택지": "options", "다음 검증": "nextValidation"},
"PROJECT_DECISION": {"근거": "basis", "결정문": "decision", "판단 이유": "rationale",
"영향": "impact"},
# **`근거` 절은 Studio 로 안 보낸다.** `ProjectDecisionInput` 에 그 칸이 없고
# `unevaluatedProperties: false` 라 보내면 거절된다(`studio-v1.yaml:1046`).
# 기록의 `## 근거` 절은 그대로 둔다 — 저장소가 아는 것과 서버가 받는 것은 다르다.
# `결정문`→`statement` · `영향`→`consequences`(OrderedText 배열) 이고
# `decisionStatus`·`decidedOn` 은 frontmatter 에 있다 (`_decision_shape`)
"PROJECT_DECISION": {"결정문": "statement", "판단 이유": "rationale",
"영향": "consequences"},
}
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
@@ -351,8 +356,23 @@ def _question_options(chunk: str) -> list[dict]:
def _ordered(slug: str, field: str, raw) -> list[dict]:
"""`- ` 항목을 `OrderedText` 배열로. 빈 칸도 `[]` 로 남긴다 — required 다."""
items = _bullets(raw) if isinstance(raw, str) else []
"""을 `OrderedText` 배열로. 빈 칸도 `[]` 로 남긴다 — required 다.
**항목의 모양이 절마다 다르다.** `사실`·`가정` 은 `- ` 목록이고 DECISION 의 `영향` 은
빈 줄로 나뉜 문단이다. 목록만 읽으면 문단으로 쓴 절이 **조용히 `[]` 가 된다** —
스키마에 `minItems` 가 없어 그대로 저장되고 내용만 사라진다.
그래서 목록을 먼저 보고, 없으면 문단으로 나눈다. **글이 있는데 항목이 0 개면 거절한다** —
비어 있는 것과 못 읽은 것은 다른 일이다.
"""
if not isinstance(raw, str) or not raw.strip():
return [] # 절이 비어 있다 — 정상이다
items = _bullets(raw)
if not items:
items = [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()]
if not items:
raise Refused(f"{field} 절에 글이 있는데 항목을 하나도 못 읽었다. "
"빈 칸으로 보내지 않는다 — 무엇이 사라졌는지 보고 고친다")
return [{"id": _ordered_id(slug, field, i), "text": t, "order": i}
for i, t in enumerate(items)]
@@ -393,6 +413,27 @@ def _reference_shape(doc: dict, fm: dict) -> dict:
return doc
DECISION_STATUS = ("PROPOSED", "ADOPTED")
def _decision_shape(doc: dict, fm: dict) -> dict:
"""PROJECT_DECISION 의 칸을 계약의 모양으로 (`studio-v1.yaml:1050-1063`)."""
slug = doc.get("slug") or ""
doc["consequences"] = _ordered(slug, "consequences", doc.get("consequences"))
for field in ("statement", "rationale"):
if not isinstance(doc.get(field), str):
doc[field] = ""
status = (fm.get("decisionStatus") or "").strip().upper()
if status and status not in DECISION_STATUS:
# **지어내지 않는다.** enum 이 셋뿐이라 다른 값은 서버가 거절한다 —
# 여기서 막는 편이 운영에 초안을 만들어 놓고 422 를 받는 것보다 낫다
raise Refused(f"decisionStatus 가 {status!r} 다. 계약은 "
f"{' · '.join(DECISION_STATUS)} 와 null 뿐이다 (studio-v1.yaml:1055)")
doc["decisionStatus"] = status or None
doc["decidedOn"] = (fm.get("decidedOn") or "").strip() or None
return doc
def _question_shape(doc: dict, fm: dict) -> dict:
"""QUESTION 의 칸을 서버 스키마의 **모양**으로 바꾼다.
@@ -457,6 +498,8 @@ def build_input(record_path: str) -> dict:
doc = _concept_shape(doc, fm)
if kind == "REFERENCE":
doc = _reference_shape(doc, fm)
if kind == "PROJECT_DECISION":
doc = _decision_shape(doc, fm)
return doc
@@ -769,7 +812,12 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
"body": None,
"expect": {"status": 200,
"compare": "정규화한 본문·칸·자료를 보낸 것과 견준다",
"private": "currentPublication 이 null 이거나 status != PUBLISHED"},
"private": "currentPublication 이 null 이거나 status != PUBLISHED",
# 정리 경로가 이 값을 쓴다. 되읽은 값이 보낸 값과 다르면 **지우려는 곳이
# 문서가 있는 곳이 아니다** — 그 상태로 DELETE 하면 404 가 나고 초안이 남는다
**({"projectId": f"보낸 값 {doc.get('projectId')} 와 같아야 한다 — "
"정리 경로가 이 값을 쓴다"}
if doc.get("projectId") else {})},
})
if harness_test:
# 시험 초안은 만든 자리에서 지운다. **`--harness-test` 일 때만** 이 단계를 낸다 —
@@ -788,13 +836,21 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None,
raise Refused(
f"이 종류의 삭제 경로를 모른다: {kind!r}. 지울 수 없는 것을 만들지 않는다\n"
f" 아는 종류: {' · '.join(sorted(DELETE_PATHS))}")
if "{projectId}" in template:
project_id = doc.get("projectId")
if "{projectId}" in template and not project_id:
# **우회하지 않는다.** 프로젝트 id 없이 만들면 그 초안을 못 지운다.
# `projectId` 는 `[string, "null"]` 이고 계약이 「게시 시점에 non-null,
# 저장 시점에는 강제하지 않는다」라고 적어 두었다(`studio-v1.yaml:932-934`) —
# 그러니 null 로도 만들어지고, 만들어지면 지울 수 없다.
raise Refused(
f"{kind} 는 프로젝트 아래에 있어 지우려면 프로젝트 id 가 필요하다. "
"이 어댑터는 그 값을 모른다 — 시험 초안으로 만들지 않는다")
f"{kind} 는 프로젝트 아래에 있어 지우려면 프로젝트 id 가 필요하다.\n"
" `--project-id <uuid>` 로 준다. 값은 사람이 Studio 목록에서 읽은 것이어야 "
"한다 — 이 어댑터는 이름을 uuid 로 바꾸지 않는다.\n"
" 없으면 시험 초안을 만들지 않는다. 지울 수 없는 것을 운영에 만들지 않는다")
path = template.format(id=document_id or "<생성된 id>", projectId=project_id)
steps.append({
"op": "cleanup", "method": "DELETE",
"path": template.format(id=document_id or "<생성된 id>"),
"path": path,
"headers": {"X-CSRF-TOKEN": "<env STUDIO_CSRF_TOKEN>"},
"cookies": {"TECHLOG_SESSION": "<env STUDIO_SESSION_COOKIE>"},
# **본문이 필수다.** `requestBody: required: true` 이고 컨트롤러가
@@ -930,6 +986,10 @@ def main() -> int:
ap.add_argument("--expected-version", type=int, help="GET 으로 읽은 현재 version")
ap.add_argument("-o", "--out", help="계획을 적을 파일")
ap.add_argument("--verdicts", help="경고마다 PASS/FAIL/UNKNOWN 을 적은 검토 판정 파일")
ap.add_argument("--project-id",
help="PROJECT_DECISION 이 걸리는 프로젝트의 uuid. 사람이 Studio 목록에서 "
"읽은 값이어야 한다 — 이 어댑터는 이름을 uuid 로 바꾸지 않는다. "
"삭제 경로가 이 값을 쓴다")
ap.add_argument("--harness-test", action="store_true",
help=f"시험 초안이다. 제목 앞에 {HARNESS_PREFIX}, slug 앞에 {HARNESS_SLUG_PREFIX} 를 붙인다")
ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)")
@@ -953,6 +1013,9 @@ def main() -> int:
cleared = _review_gate(all_warnings, args.verdicts, args.package,
pkg.get("preservation"))
doc = build_input(args.record)
if args.project_id:
# **지어내지 않는다.** 사람이 Studio 목록에서 읽어 준 값만 들어온다
doc["projectId"] = args.project_id
if args.harness_test:
doc = as_harness_test(doc)
steps = plan_requests(args.record, doc, args.document_id, args.expected_version,