Skip to content

APPROVED เข้า TIMEOUTABLE — การอนุมัติต้องหมดอายุได้ (#17) - #20

Merged
monthop-gmail merged 1 commit into
mainfrom
feat/approved-expiry
Aug 19, 2026
Merged

APPROVED เข้า TIMEOUTABLE — การอนุมัติต้องหมดอายุได้ (#17)#20
monthop-gmail merged 1 commit into
mainfrom
feat/approved-expiry

Conversation

@monthop-gmail

Copy link
Copy Markdown
Owner

ปิด #17การอนุมัติมีวันหมดอายุได้แล้ว และ engine บังคับมัน

ไม่ใช่ฟีเจอร์ใหม่ แต่เป็นการปิด ช่องว่างของการ conformapproval/v1 ที่เรา pin ไว้มี expires_at
พร้อมข้อความว่า "approval ที่หมดอายุแล้วใช้เดินงานไม่ได้ ต้องขอใหม่ · งานที่ค้างรออนุมัติจนเลยกำหนด
ควรเข้าสถานะ timeout ไม่ใช่รอตลอดไป"
· ฝั่งเราไม่มีทั้งที่เก็บค่านั้นและไม่มีทางให้ APPROVED หมดเวลาได้เลย

เปลี่ยนอะไร

ที่ อะไร
rfcs/0007 Amendment 1 — ตาราง Decision 2 แถว TIMED_OUT เพิ่ม APPROVED พร้อมเหตุผลเต็ม
rfcs/0010 3 บรรทัดชี้ว่า "Decided" ข้อนั้นไปจบที่ RFC-0007 Amendment 1
states.py TIMEOUTABLE += APPROVEDที่เดียว ไม่มี hard-code edge ที่อื่น
decision.py expires_at (บังคับ tz) · is_expired(now) · ลง payload ชื่อเดียวกับสัญญา
job.py guard ใหม่ — เข้า POST_APPROVAL ด้วย approval ที่หมดอายุ → ExpiredApproval
replay.py ExecutionAfterExpiry — จับจาก trail ได้ด้วย ไม่ใช่แค่ตอน runtime
contract-semantics.yaml timeoutable ตรงกับโค้ด · invariant เรื่อง expired approval · ไม่ bump semantics_version

ผลตรวจ

ก่อน หลัง
pytest 441 481 passed
payload_check.py 13 passed 0 fail 17 passed 0 fail
simulation/e2e_flow.py 29 passed 0 fail 29 passed 0 fail (13 job / 81 event)

เทสเดิมที่ต้องแก้มีข้อเดียว — test_timeout_not_reachable_from_draft_or_approved ซึ่งเข้ารหัสกฎเก่า
ไว้ตรง ๆ · ไม่มี guarantee อื่นถูกลดความเข้ม

จุดตัดสินที่ต้องรีวิวเป็นพิเศษ

DEPLOYABLE → COMPLETED ไม่ถูกบล็อกด้วย expiry — approval ที่หมดอายุห้าม เดินงานใหม่
แต่การปิดงานที่ทำเสร็จแล้วไม่ใช่การเดินงานใหม่ · ถ้าบล็อกด้วย จะบีบให้ job ที่งานเสร็จแล้วไปจบที่
FAILED ซึ่ง เล่าเรื่องผิด · ขอบเขตการ refuse จึงเป็น POST_APPROVAL ทั้งชุดรวม resume
จาก AWAITING_APPROVAL แต่เว้นทางปิดงาน

⚠️ ช่องว่างที่เจอและ ไม่ได้ปิด

สัญญาบอกว่า "ต้องขอใหม่" แต่ lifecycle ไม่มีทางให้ขอใหม่

ไม่มี edge จาก APPROVED กลับ GOVERNANCE_ANALYSIS และ supersede() ใช้ได้เฉพาะจาก FAILED
ซึ่ง APPROVED เข้าไม่ได้ (RFC-0010) → job ที่ approval หมดอายุจบได้แค่ TIMED_OUT/CANCELLED
และการยื่นใหม่กลายเป็น job ใหม่ที่ไม่มีอะไรลิงก์กลับ — audit chain ขาดตรงนั้น

เป็น lifecycle change ต้องมี RFC จึงไม่สร้าง edge เอง แต่เขียนเป็นเทสบันทึกสภาพจริงไว้

และยังไม่มีใครตั้งค่า expires_at — policy values อยู่นอกขอบเขตทั้ง RFC-0007 และ RFC-0010
ตามเดิม · สิ่งที่ PR นี้เปลี่ยนคือ deadline มีความหมายที่ engine บังคับได้ และมี state ให้ลง
ตัวเลขจริงต้องมาจาก orchestration

Closes #17

RFC-0010 decided this and deliberately left it unimplemented: TIMEOUTABLE is
RFC-0007's rule, so the amendment belongs there. RFC-0007 Amendment 1 adds
APPROVED to the "entered from" list for TIMED_OUT, and states.py follows.

The argument is governance, not liveness. A job that can sit in APPROVED forever
may start executing a week later under a verdict formed in a context that no
longer holds — the same stale APPROVED that RFC-0007 Decision 1 refuses when it
keeps FAILED terminal. That door was shut on one side and left open on the other.

It is also a conformance gap rather than a proposal. approval/v1 has carried
expires_at since it was written and says what it means: "approval ที่หมดอายุแล้ว
ใช้เดินงานไม่ได้ ต้องขอใหม่ · งานที่ค้างรออนุมัติจนเลยกำหนดควรเข้าสถานะ timeout
ไม่ใช่รอตลอดไป". So:

* Decision carries expires_at and renders it under the contract's own name;
  approve()/decide() take it. Optional there and optional here — an approval with
  no deadline never expires, and existing jobs are untouched.
* The engine refuses to enter any POST_APPROVAL state under a lapsed approval
  (ExpiredApproval), including the way back out of a pause. Both halves of the
  direction lock now guard the same set, so there is no state reachable without a
  valid approval but not without any approval.
* Replay refuses a trail that records it happening anyway (ExecutionAfterExpiry).
  The deadline and the moment are both in the log, so the claim is checkable by a
  reader who was not there — the same reason UnauditedExecution exists.
* conformance/payload_check.py validates a real approval payload carrying
  expires_at, and probes both refusals; simulation gains approval_expired, whose
  job settles at TIMED_OUT rather than at a failure.

contract-semantics.yaml records timeoutable and the enforced field. No
semantics_version bump: frozen is untouched, exactly as in #16.

Not done, and reported instead of guessed: nothing here sets expires_at or fires
a timeout — the policy values stay out of scope in RFC-0007 and RFC-0010 alike —
and the lifecycle still has no edge for "ขอใหม่", so a job whose approval lapsed
settles at TIMED_OUT and a re-request is a new job with nothing linking it back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant