Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions conformance/payload_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,73 @@ def check_gap_expiry(gaps: list[dict], today: str) -> None:
ok("gap", f"{gap['id']} ยังอยู่ในอายุ ถึง {expires} · {gap['issue']}")


def check_trail_closure(log, jobs) -> None:
"""RFC-0012 — every terminal emits a closing record, and the count is honest.

Checked against the trails the engine actually produced, not against a
fixture: the scenario settles jobs in all four terminals, and each has to
close itself.
"""
from devfactory_core.states import TERMINAL

unsettled: list[str] = []
miscounted: list[str] = []
settled_kinds: set[str] = set()

for job in jobs:
trail = [e for e in job.events]
closing = [e for e in trail if e.type_value == "JOB_SETTLED"]
if job.state not in TERMINAL:
if closing:
miscounted.append(f"{job.job_id} ยังไม่ terminal แต่มีใบปิด")
continue
if not closing:
unsettled.append(job.job_id)
continue
if closing[-1] is not trail[-1]:
miscounted.append(f"{job.job_id} ใบปิดไม่ใช่ใบสุดท้าย")
record = closing[-1]
announced = (record.metadata or {}).get("event_count")
actual = sum(1 for e in trail if e.job_id == job.job_id)
if announced != actual:
miscounted.append(f"{job.job_id} ประกาศ {announced} จริง {actual}")
settled_kinds.add(str((record.metadata or {}).get("settled_as")))

if unsettled:
fail("closure", f"job ที่ terminal แต่ไม่มีใบปิดท้าย: {unsettled}")
elif miscounted:
fail("closure", f"ใบปิดท้ายไม่ตรงกับ trail: {miscounted}")
else:
settled = [j for j in jobs if j.state in TERMINAL]
ok(
"closure",
f"{len(settled)} job ที่จบแล้วมีใบปิดท้ายครบ และ event_count ตรงกับ trail",
)

# The point of RFC-0012 is that the rule has no exception in it, so the
# scenario has to exercise more than the terminal that already worked.
expected = {"COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT"}
missing = expected - settled_kinds
if missing:
fail("closure", f"scenario ยังไม่ครอบ terminal: {sorted(missing)}")
else:
ok("closure", "ครอบครบทั้ง 4 terminal — ไม่ใช่แค่ทางที่จบสำเร็จ")

# And the check has to be able to fail: truncate a real trail and see it caught.
from devfactory_observability import UnsettledTrail
from devfactory_observability.replay import replay_job

victim = next((j for j in jobs if j.state in TERMINAL), None)
if victim is None:
fail("closure", "scenario ไม่มี job ที่จบแล้วให้ทดสอบการตัดท้าย")
return
try:
replay_job(list(victim.events)[:-1])
fail("closure", f"ตัดใบท้ายของ {victim.job_id} แล้ว replay ยังผ่าน")
except UnsettledTrail:
ok("closure", f"trail ที่ถูกตัดท้ายถูกจับได้ ({victim.state.value})")


def check_guarantees(log, jobs, external) -> None:
# append-only: the digest of a prefix must not change as the log grows
before = log.digest("acme")
Expand Down Expand Up @@ -710,9 +777,11 @@ def main() -> int:
check_payloads(log, validator, pinned.get("known_gaps") or [])
print("\n[2] คำตัดสินที่ระบบผลิตจริง — approval/v1 (RFC-0002)")
check_decisions(log, jobs, approval_validator, approval_schema)
print("\n[3] guarantee ที่ JSON Schema ตรวจไม่ได้")
print("\n[3] ใบปิดท้ายของทุก terminal — RFC-0012")
check_trail_closure(log, jobs)
print("\n[4] guarantee ที่ JSON Schema ตรวจไม่ได้")
check_guarantees(log, jobs, external)
print("\n[4] ช่องว่างที่รู้ตัว — ต้องมี issue และวันหมดอายุ")
print("\n[5] ช่องว่างที่รู้ตัว — ต้องมี issue และวันหมดอายุ")
check_gap_expiry(pinned.get("known_gaps") or [], args.today)

fails = [f for f in findings if f[0] == "FAIL"]
Expand Down
2 changes: 2 additions & 0 deletions conformance/pinned.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ guarantees:
- external event ต้องคง source ไว้
- ห้ามเก็บ private reasoning / chain-of-thought เป็น audit record
- event ที่ devfactory-core ส่งออกต้องมี job_id เสมอ
# rfcs/0012 — guarantee ของเราเอง ยังไม่ได้อยู่ใน event/v1 guarantees ของ platform
- ทุกการจบแบบ terminal ต้องออกใบปิดท้าย พร้อม event_count ที่ตรงกับ trail

# ช่องว่างที่รู้ตัว — พร้อม issue อ้างอิงและเงื่อนไขที่แคบที่สุดเท่าที่จะแคบได้
#
Expand Down
26 changes: 24 additions & 2 deletions contract-semantics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@
# ที่มาฉบับเต็มเป็น prose อยู่ใน rfcs/ — ถ้าไฟล์นี้ขัดกับ RFC ให้ถือ RFC เป็นหลัก
# แล้วเปิด issue เพราะแปลว่าไฟล์นี้ผิด

semantics_version: "1.1"
semantics_version: "1.2"
owner: devfactory-core
authority: rfcs/0005-platform-contract-authority.md
amended_by: rfcs/0009-vocabulary-extension.md # Rule 2 — เพิ่ม event type เป็น additive แล้ว
license: MIT

# 1.1 → 1.2 (2026-08-21)
# เพิ่ม guarantee: ทุกการจบแบบ terminal ต้องออกใบปิดท้าย (rfcs/0012)
# contract ที่ derive ต้องอัปเดต derived_from.semantics_version เป็น "1.2"
#
# 1.0 → 1.1 (2026-08-18)
# event_types เปลี่ยนจาก "ชุดปิด" เป็น "ขั้นต่ำที่ต้องมี" — agent-platform เพิ่มเองได้
# contract ที่ derive ต้องอัปเดต derived_from.semantics_version เป็น "1.1"
Expand Down Expand Up @@ -53,7 +57,7 @@ derived_from_block_template: |
derived_from:
repo: monthop-gmail/devfactory-core
manifest: contract-semantics.yaml
semantics_version: "1.1"
semantics_version: "1.2"
rfcs:
- rfcs/0002-governance-decision-contract.md
license: MIT
Expand Down Expand Up @@ -164,6 +168,21 @@ contracts:
source:
- rfcs/0003-audit-event-log-schema.md
- rfcs/0008-external-event-intake.md # amend 0003
- rfcs/0012-terminal-closing-record.md # เพิ่ม guarantee ใบปิดท้าย

# ℹ️ ไม่ใช่ส่วนหนึ่งของ contract — อยู่นอก frozen: จึงไม่ขยับ semantics_version
# guarantee ข้างล่างพูดถึง "ใบปิดท้าย" โดยไม่ระบุชื่อ type โดยเจตนา:
# guarantee เป็นของ repo นี้ ส่วน vocabulary เพิ่มได้เองฝั่ง agent-platform (rfcs/0009)
implementation_status:
closing_record_type: JOB_SETTLED
note: >-
packages/core emit JOB_SETTLED เป็นใบสุดท้ายทุกครั้งที่ job เข้า terminal
· COMPLETED ออกทั้ง JOB_COMPLETED และ JOB_SETTLED เพราะสองใบตอบคนละคำถาม
และกฎที่ไม่มีข้อยกเว้นคือสิ่งที่ทำให้ตรวจได้
· พก metadata.settled_as และ metadata.event_count (นับตาม job_id รวมใบปิดเอง)
· payload validate ผ่านอยู่แล้วเพราะ field event_type อ้าง EventTypeName ชุดเปิด
— การเพิ่ม JOB_SETTLED เข้า $defs.EventType เป็น additive ฝั่ง agent-platform
ทำเมื่อไหร่ก็ได้ ไม่มีอะไรรอมัน

frozen:
event_types:
Expand All @@ -183,6 +202,9 @@ contracts:
guarantees:
- event เป็น append-only
- no silent state change — ทุกการเปลี่ยน state ต้องมี event
# rfcs/0012 — ปิดช่องที่ agent-platform#23 ยกมา: trail ที่ถูกตัดท้ายอ่านได้
# เหมือน trail ที่สมบูรณ์ เพราะไม่มีอะไรยืนยันใบสุดท้าย
- ทุกการจบแบบ terminal ต้องออกใบปิดท้าย — ไม่ใช่แค่ทางที่จบสำเร็จ

invariants:
- ทุก event ต้องตอบได้ว่า "เกี่ยวกับอะไร" — subject จำเป็นเสมอ ส่วน job_id optional
Expand Down
7 changes: 6 additions & 1 deletion packages/core/devfactory_core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ class EventType(str, Enum):
which is noted below and is not enforced by this enum.

Emitted by the job state machine (``packages/core``):
``JOB_CREATED`` · ``STATE_TRANSITION`` · ``JOB_COMPLETED`` ·
``JOB_CREATED`` · ``STATE_TRANSITION`` · ``JOB_COMPLETED`` · ``JOB_SETTLED``

``JOB_COMPLETED`` and ``JOB_SETTLED`` answer different questions and a
successful job emits both — did the work get delivered, and is this trail
finished. RFC-0012 keeps them separate for that reason. ·
``GOVERNANCE_DECISION``

``GOVERNANCE_DECISION`` is emitted alongside the ``STATE_TRANSITION`` it
Expand All @@ -55,6 +59,7 @@ class EventType(str, Enum):
EXECUTION_STARTED = "EXECUTION_STARTED"
EXECUTION_FAILED = "EXECUTION_FAILED"
JOB_COMPLETED = "JOB_COMPLETED"
JOB_SETTLED = "JOB_SETTLED"


def new_event_id() -> str:
Expand Down
34 changes: 33 additions & 1 deletion packages/core/devfactory_core/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ class Job:

Construction emits ``JOB_CREATED``; every accepted transition emits
``STATE_TRANSITION``; entering a decision state also emits
``GOVERNANCE_DECISION``; reaching COMPLETED also emits ``JOB_COMPLETED``.
``GOVERNANCE_DECISION``; reaching COMPLETED also emits ``JOB_COMPLETED``;
and settling in **any** terminal emits ``JOB_SETTLED`` last of all
(RFC-0012).
There is no way to change ``state`` without going through
:meth:`transition`, which is what makes "no silent state change" hold rather
than merely be documented — and no way to reach ``APPROVED`` without leaving
Expand Down Expand Up @@ -342,8 +344,38 @@ def transition(
)
if to is JobState.COMPLETED:
self._emit(EventType.JOB_COMPLETED, actor=principal or self._principal)
if to in TERMINAL:
self._settle(to, actor=principal or self._principal)
return event

def _settle(self, terminal: JobState, *, actor: Principal) -> Event:
"""Emit the closing record. RFC-0012.

Nothing that comes after a job's last record can vouch for it, which is
why replay could only ever catch a trail truncated after ``COMPLETED`` —
that terminal implies ``JOB_COMPLETED`` and the other three implied
nothing. This record is what the other three were missing, and
``COMPLETED`` emits it too so the rule has no exception in it for a
reader to have to know about.

``event_count`` is scoped by ``job_id``, not by subject. That is the unit
replay actually verifies — ``replay_tenant`` groups by ``job_id`` — and it
is the scope that matters: ``GOVERNANCE_DECISION`` is about the approval
and carries its own ``subject_id``, so counting by subject would miss the
very records the count exists to notice.
"""
counted = sum(1 for event in self._events if event.job_id == self._job_id)
return self._emit(
EventType.JOB_SETTLED,
actor=actor,
metadata={
"settled_as": terminal.value,
# +1 for this record: a reader holding the trail compares the
# count against everything it holds, this record included.
"event_count": counted + 1,
},
)

# ---- named transitions -------------------------------------------------
# These exist so the arguments a guard requires are visible in the call
# signature rather than discovered at runtime.
Expand Down
38 changes: 38 additions & 0 deletions packages/core/state-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,44 @@ Timeout **policy** — how long an approval is good for — is not set here. RFC
RFC-0010 both leave the values out of scope, so nothing in this repository supplies a
default `expires_at` or fires a timeout on its own.

## Trail closure

Every job that settles emits **`JOB_SETTLED`** as its last record — `COMPLETED`,
`FAILED`, `CANCELLED`, and `TIMED_OUT` alike ([RFC-0012](../../rfcs/0012-terminal-closing-record.md)).
A successful job emits `JOB_COMPLETED` as well, because the two answer different
questions: whether the work was delivered, and whether the record is finished.

The reason there is no exception for `COMPLETED` is that an exception would put the
burden on the reader to know which endings are special, and not knowing that is what
this closes. Replay verifies a trail by having each record vouch for the one before
it, which leaves the last record unvouched — so before this, only `COMPLETED` could
be checked for truncation at the end, and `FAILED`, `CANCELLED`, and `TIMED_OUT`
trails cut short replayed clean while reporting the state before the lost record.

```json
{ "event_type": "JOB_SETTLED",
"metadata": { "settled_as": "TIMED_OUT", "event_count": 9 } }
```

`event_count` counts every event carrying that **`job_id`**, the closing record
included. Not by subject: `GOVERNANCE_DECISION` is about the approval and carries its
own `subject_id`, while `replay_tenant` groups by `job_id` — so `job_id` is the scope
where producer and reader count the same set.

What the count adds is records that **no structural check asks for**. Transitions are
vouched for by the next one, and a decision transition demands its `GOVERNANCE_DECISION`
(`UnauditedDecision`); what neither notices is a record nothing demands — a forged or
duplicated one, or a missing one of a type with no check of its own, which is what
`TASK_ASSIGNED`, `EXECUTION_STARTED`, and `EXECUTION_FAILED` will be.

Replay refuses three things it could not see before: `UnsettledTrail` (terminal with no
closing record), `PrematureSettlement` (a closing record on a job still running), and
`MiscountedTrail` (the count disagrees with the trail).

**A trail that has not settled cannot be checked this way**, and nothing here pretends
otherwise — "complete" means "nothing missing up to the end", and a running trail has
no end. That belongs to the store, where `EventLog.digest()` already is the primitive.

## Recovery

A terminal job is never woken. Trying again is a **new job** that records
Expand Down
Loading
Loading