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
+55
View File
@@ -733,6 +733,61 @@ class HarnessTestPlanTest(unittest.TestCase):
{"id": "y4", "text": "하나", "order": 1}]}
self.assertFalse(ss.compare_saved(sent, swapped)["same"])
def test_decision_uses_the_contract_field_names(self):
"""`결정문`→`statement` · `영향`→`consequences`. 그리고 `근거`→`basis` 는
계약에 없는 칸이라 `unevaluatedProperties: false` 에 거절된다 — 안 보낸다."""
self.assertEqual({"결정문": "statement", "판단 이유": "rationale",
"영향": "consequences"}, ss.FIELD_MAP["PROJECT_DECISION"])
doc = ss._decision_shape(
{"slug": "s", "statement": "이렇게 한다", "rationale": "왜냐하면",
"consequences": "- 하나\n- 둘"},
{"decisionStatus": "ADOPTED", "decidedOn": "2026-08-30"})
self.assertEqual({"id", "text", "order"}, set(doc["consequences"][0]))
self.assertEqual("ADOPTED", doc["decisionStatus"])
self.assertEqual("2026-08-30", doc["decidedOn"])
for gone in ("basis", "decision", "impact"):
self.assertNotIn(gone, doc)
def test_decision_status_outside_the_enum_is_refused(self):
"""enum 이 셋뿐이다. 지어내지 않고 여기서 막는다 —
운영에 초안을 만들어 놓고 422 를 받는 것보다 낫다."""
with self.assertRaises(ss.Refused):
ss._decision_shape({"slug": "s"}, {"decisionStatus": "REJECTED"})
self.assertIsNone(ss._decision_shape({"slug": "s"}, {})["decisionStatus"])
self.assertIsNone(ss._decision_shape({"slug": "s"}, {})["decidedOn"])
def test_paragraph_sections_are_not_silently_empty(self):
"""항목의 모양이 절마다 다르다. `- ` 목록만 읽으면 문단으로 쓴 절이 조용히
`[]` 가 된다 — 스키마에 `minItems` 가 없어 그대로 저장되고 내용만 사라진다."""
bullets = ss._ordered("s", "consequences", "- 하나\n- 둘")
paras = ss._ordered("s", "consequences", "감수하는 것 : 가.\n\n얻는 것 : 나.")
self.assertEqual(2, len(bullets))
self.assertEqual(2, len(paras))
self.assertEqual("얻는 것 : 나.", paras[1]["text"])
# 절이 비어 있는 것은 정상이다 — 못 읽은 것과 다르다
self.assertEqual([], ss._ordered("s", "consequences", ""))
self.assertEqual([], ss._ordered("s", "consequences", None))
def test_a_decision_without_a_project_id_is_refused(self):
"""프로젝트 id 없이 만들면 그 초안을 못 지운다. 우회하지 않는다."""
doc = dict(SENT, kind="PROJECT_DECISION", slug="s")
doc.pop("projectId", None)
with self.assertRaises(ss.Refused) as cm:
ss.plan_requests("docs/p/t/decision/x.md", doc, None, None, harness_test=True)
self.assertIn("--project-id", str(cm.exception))
def test_a_decision_with_a_project_id_plans_a_deletable_draft(self):
"""값이 있으면 정리 경로에 들어가고, 되읽기가 그 값을 확인한다 —
되읽은 값이 다르면 지우려는 곳이 문서가 있는 곳이 아니다."""
pid = "11111111-aaaa-bbbb-cccc-222222222222"
doc = dict(SENT, kind="PROJECT_DECISION", slug="s", projectId=pid)
steps = ss.plan_requests("docs/p/t/decision/x.md", doc, None, None,
harness_test=True)
cleanup = next(x for x in steps if x["op"] == "cleanup")
self.assertIn(f"/projects/{pid}/decisions/", cleanup["path"])
verify = next(x for x in steps if x["op"] == "verify")
self.assertIn(pid, verify["expect"]["projectId"])
def test_a_harness_plan_still_has_no_publish_path(self):
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)