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
16 changes: 10 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ Terminal: COMPLETED · FAILED · CANCELLED · TIMED_OUT

Full spec: [packages/core/state-machine.md](packages/core/state-machine.md) ·
[RFC-0001](rfcs/0001-job-state-machine.md) as amended by [RFC-0007](rfcs/0007-job-lifecycle-completeness.md)
and [RFC-0011](rfcs/0011-require-changes-destination.md)

## Governance Decisions
`APPROVE` and `REJECT` move a job out of `GOVERNANCE_ANALYSIS`; each is recorded as an
immutable decision and emitted as `GOVERNANCE_DECISION` next to the `STATE_TRANSITION`
it caused, so no job reaches `APPROVED` without a record of who decided it and why.
`REQUIRE_CHANGES` is part of the vocabulary and is refused by the engine until an RFC
says which state it sends a job to. See [RFC-0002](rfcs/0002-governance-decision-contract.md)
and `packages/core/devfactory_core/decision.py`.
`APPROVE`, `REJECT` and `REQUIRE_CHANGES` move a job out of `GOVERNANCE_ANALYSIS`; each is
recorded as an immutable decision and emitted as `GOVERNANCE_DECISION` next to the
`STATE_TRANSITION` it caused, so no job reaches `APPROVED` without a record of who decided
it and why. `REQUIRE_CHANGES` sends a job back to `DRAFT`
([RFC-0011](rfcs/0011-require-changes-destination.md)) and stays distinguishable from a
rejection by its route: a `REJECT` passes through `REJECTED` and leaves it in the job's
history, a `REQUIRE_CHANGES` never enters that state. See
[RFC-0002](rfcs/0002-governance-decision-contract.md) and
`packages/core/devfactory_core/decision.py`.

An approval can also carry `expires_at`, and one that has passed it authorises nothing:
the engine refuses to move the job into execution, and a job left holding a lapsed
Expand Down
60 changes: 41 additions & 19 deletions conformance/payload_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@

The governance decisions those jobs produced are validated the same way against
``approval/v1``, together with the guarantees RFC-0002 owns: every APPROVE leaves
a record, and ``REQUIRE_CHANGES`` is refused rather than given a destination
nobody has decided on yet.
a record, and a ``REQUIRE_CHANGES`` sends the job back to ``DRAFT`` without its
trail ever passing through ``REJECTED`` — RFC-0011, which is what keeps
*"REQUIRE_CHANGES ไม่ใช่ REJECT"* true of the record and not only of the wording.

Usage::

Expand Down Expand Up @@ -137,10 +138,10 @@ def build_validator(schemas: dict[str, dict], target_id: str, non_schema_keys: l
def run_scenario():
"""Drive real jobs through the real engine and return the log plus what happened.

Seven jobs across two tenants, covering every terminal state, the mid-run
approval pause, rejection and resubmission, recovery by supersession, and an
approval that expired where it sat — plus two inbound external events that no
job caused.
Eight jobs across two tenants, covering every terminal state, the mid-run
approval pause, rejection and resubmission, being sent back for changes,
recovery by supersession, and an approval that expired where it sat — plus two
inbound external events that no job caused.

The journeys themselves are ``simulation/flows.py``, which issue #7 made the
one place they are written down. Two files describing the same lifecycle
Expand All @@ -158,6 +159,7 @@ def run_scenario():
happy_path,
job_factory,
rejected_then_resubmitted,
require_changes_then_resubmitted,
stalled_awaiting_approval,
)

Expand Down Expand Up @@ -200,7 +202,15 @@ def run_scenario():
expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
)

jobs = [happy, revised, failed, replacement, cancelled, stalled, expired]
# 7. sent back for changes, revised, resubmitted, approved — RFC-0011. The only
# flow that produces an approval/v1 payload carrying decision=REQUIRE_CHANGES,
# so the third value of a closed vocabulary is validated as something the
# engine really emitted rather than asserted about in the abstract.
sent_back = require_changes_then_resubmitted(
acme, job_id="job-011", authority=reviewer
)

jobs = [happy, revised, failed, replacement, cancelled, stalled, expired, sent_back]
for job in jobs:
log.extend(job.events)

Expand Down Expand Up @@ -344,10 +354,10 @@ def check_decisions(log, jobs, validator) -> None:
)
check_expiry_enforced()

# REQUIRE_CHANGES อยู่ใน vocabulary แต่ยังไม่มี RFC กำหนดว่ามันพา job ไปไหน
# engine ต้องปฏิเสธ ไม่ใช่เดาปลายทาง — ดู states.DECISION_TARGET
from devfactory_core import DecisionType, Job, Principal
from devfactory_core.errors import UnmappedDecision
# REQUIRE_CHANGES มีปลายทางแล้วตาม rfcs/0011 — DRAFT ตรงจากประตู
# เดิมเช็คนี้ยืนยันว่า engine ปฏิเสธค่านี้ ตอนนี้กลับด้านเป็นยืนยันว่ามันทำงานถูก
# ดู states.DECISION_TARGET
from devfactory_core import DecisionType, Job, JobState, Principal

probe = Job(
job_id="job-007",
Expand All @@ -356,15 +366,27 @@ def check_decisions(log, jobs, validator) -> None:
principal=Principal("human", "alice"),
)
probe.submit_for_governance()
try:
probe.decide(
DecisionType.REQUIRE_CHANGES,
authority=Principal("human", "bob"),
reason="needs a test plan",
probe.decide(
DecisionType.REQUIRE_CHANGES,
authority=Principal("human", "bob"),
reason="needs a test plan",
)
if probe.state is JobState.DRAFT and probe.approval is None:
ok("approval", "REQUIRE_CHANGES พา job กลับไป DRAFT และไม่เหลือ approval ค้างไว้ (rfcs/0011)")
else:
fail(
"approval",
f"REQUIRE_CHANGES พา job ไปจบที่ {probe.state.value} "
f"(approval={probe.approval is not None}) — ต้องเป็น DRAFT และไม่มี approval",
)
fail("approval", "REQUIRE_CHANGES ถูกรับเข้า — ต้องปฏิเสธจนกว่าจะมี RFC กำหนดปลายทาง")
except UnmappedDecision:
ok("approval", "REQUIRE_CHANGES ถูกปฏิเสธ — ยังไม่มี RFC กำหนดปลายทางของมัน")

# "REQUIRE_CHANGES ไม่ใช่ REJECT" — ปลายทางเดียวกันกับ REJECT แต่คนละเส้นทาง
# เส้นทางอยู่ใน trail จริง ไม่ใช่แค่ใน decision record จึงตรวจที่ history
visited = [h.to_state.value for h in probe.history]
if visited == ["GOVERNANCE_ANALYSIS", "DRAFT"]:
ok("approval", "REQUIRE_CHANGES ข้าม REJECTED — trail แยกออกจากการถูกปฏิเสธ")
else:
fail("approval", f"เส้นทางของ REQUIRE_CHANGES คือ {visited} — คาดว่าต้องข้าม REJECTED")


def check_expiry_enforced() -> None:
Expand Down
38 changes: 29 additions & 9 deletions contract-semantics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ contracts:
note: >-
packages/core ทำ decision interface ครบตาม RFC-0002 แล้ว (issue #5)
vocabulary ยังมีครบ 3 ค่าตามชุดปิด — ห้ามลบค่าใดออกจาก contract
· ตั้งแต่ rfcs/0011 ทั้งสามค่ามีปลายทางแล้ว engine ไม่ปฏิเสธค่าใดอีก
enforced_optional_fields:
# field ที่ agent-platform เพิ่มเองได้ (platform_may_add_freely ด้านล่าง)
# แต่ตัว schema เขียนความหมายไว้แล้ว — repo นี้จึงบังคับตามความหมายนั้น
Expand All @@ -121,14 +122,28 @@ contracts:
optional: >-
ยัง optional เหมือนใน schema — approval ที่ไม่มี expires_at คือไม่มีวันหมดอายุ
บังคับให้ต้องมีจะทำให้ engine เข้มกว่า contract ที่ conform อยู่ · issue #17
unmapped:
- decision: REQUIRE_CHANGES
detail: >-
engine ปฏิเสธด้วย UnmappedDecision เพราะยังไม่มี RFC ที่ repo นี้บอกว่า
job ที่ถูกตีกลับให้แก้ไปอยู่ state ไหน · REJECTED ผิด invariant
("REQUIRE_CHANGES ไม่ใช่ REJECT") · DRAFT ต้องมี edge ใหม่ที่ยังไม่มี RFC ประกาศ
· state ที่ 14 คือการเปลี่ยน lifecycle · ดู states.DECISION_TARGET
means: ค่านี้ยังอยู่ใน contract — สิ่งที่ขาดคือปลายทาง ไม่ใช่ตัวค่า
decision_targets:
# ครบทั้งสามค่าแล้วตั้งแต่ rfcs/0011 — เดิม REQUIRE_CHANGES ไม่มีปลายทาง
# และ engine ปฏิเสธด้วย UnmappedDecision
APPROVE: APPROVED
REJECT: REJECTED
REQUIRE_CHANGES: DRAFT
require_changes:
detail: >-
rfcs/0011 กำหนดให้ REQUIRE_CHANGES พา job กลับไป DRAFT ผ่าน edge ใหม่
GOVERNANCE_ANALYSIS → DRAFT · ไม่เพิ่ม state และไม่เพิ่มค่าใน vocabulary
· approval ถูกล้างเหมือน REJECT เพราะการถูกสั่งให้แก้ไม่ใช่การได้รับอนุมัติ
how_it_stays_distinct_from_reject: >-
ปลายทางเดียวกันแต่คนละเส้นทาง และเส้นทางอยู่ใน trail จริง:
REJECT เดิน GOVERNANCE_ANALYSIS → REJECTED → DRAFT (state REJECTED ค้างใน
history ตลอดไป) ส่วน REQUIRE_CHANGES เดิน GOVERNANCE_ANALYSIS → DRAFT ตรง ๆ
ข้าม REJECTED · คนที่อ่านย้อนจาก log อย่างเดียวจึงแยกออกได้ ไม่ต้องเชื่อ
decision record อย่างเดียว · invariant "REQUIRE_CHANGES ไม่ใช่ REJECT"
จึงถูกบังคับด้วยรูปร่างของ trail ไม่ใช่ด้วยคำอธิบาย
note_for_consumers: >-
ผลที่ตามมา: "transition นี้เป็นคำตัดสินหรือไม่" ต้องดูที่ edge ไม่ใช่ที่ปลายทาง
— GOVERNANCE_ANALYSIS → DRAFT เป็นคำตัดสิน ส่วน REJECTED → DRAFT ไม่ใช่
(เป็นแค่ขั้นแก้ไขหลังคำตัดสินที่บันทึกไปแล้ว) · ดู states.DECISION_BY_EDGE

platform_may_add_freely:
# เพิ่มได้ผ่าน ADR ฝั่ง agent-platform อย่างเดียว ไม่ต้องมี RFC ที่นี่ (Rule 1)
Expand Down Expand Up @@ -200,6 +215,7 @@ not_derived:
- rfcs/0001-job-state-machine.md
- rfcs/0007-job-lifecycle-completeness.md # amend 0001
- rfcs/0010-failable-states.md # ระบุว่า state ไหนไป FAILED ได้
- rfcs/0011-require-changes-destination.md # เพิ่ม edge GOVERNANCE_ANALYSIS → DRAFT
note: >-
ให้ vocabulary ไว้เพื่อให้ execution/v1 อ้างความหมายของ job state ได้
โดยไม่ต้องคาดเดา — คำเตือน external-authority-pending ใน execution.schema.yaml
Expand All @@ -224,7 +240,9 @@ not_derived:
# ไม่ลง state ที่อยู่ใน terminal เพราะไม่มี edge ออก — ดู terminal ด้านบน
progression:
DRAFT: [GOVERNANCE_ANALYSIS]
GOVERNANCE_ANALYSIS: [APPROVED, REJECTED]
# rfcs/0011 เพิ่ม DRAFT — ประตูมีสามคำตัดสิน จึงมีทางออกสามทาง ทางละคำตัดสิน
# GOVERNANCE_ANALYSIS → DRAFT คือ REQUIRE_CHANGES · ไม่ผ่าน REJECTED โดยเจตนา
GOVERNANCE_ANALYSIS: [APPROVED, REJECTED, DRAFT]
APPROVED: [TASK_PLANNING]
REJECTED: [DRAFT]
TASK_PLANNING: [IN_PROGRESS]
Expand Down Expand Up @@ -264,6 +282,8 @@ not_derived:
- FAILED · CANCELLED · TIMED_OUT ต้องมี reason metadata
- job ที่ยังไม่ผ่าน APPROVED เข้า FAILED ไม่ได้ — recovery ด้วย supersedes_job_id จะไม่มีอะไรให้ supersede (rfcs/0010)
- approval ที่เลย expires_at แล้วใช้เดินงานต่อไม่ได้ — engine ปฏิเสธ ไม่ใช่ปล่อยผ่าน · job ที่ค้างอยู่เข้า TIMED_OUT ได้ (rfcs/0007 Amendment 1)
- REQUIRE_CHANGES กับ REJECT จบที่ DRAFT เหมือนกันแต่คนละเส้นทาง — REJECT ผ่าน REJECTED ส่วน REQUIRE_CHANGES ข้าม · แยกกันที่ trail ไม่ใช่แค่ที่ decision record (rfcs/0011)
- คำถามว่า transition นี้เป็นคำตัดสินไหม ต้องอ่านที่ edge ไม่ใช่ที่ปลายทาง — REJECTED → DRAFT ไม่ใช่คำตัดสิน (rfcs/0011)
layering: job (ที่นี่) > execution (execution/v1) > step (event/)

orchestration_execution_boundary:
Expand Down
31 changes: 24 additions & 7 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
The job state machine and the governance decision interface that gates it.

Spec: [`state-machine.md`](state-machine.md) — [RFC-0001](../../rfcs/0001-job-state-machine.md)
as amended by [RFC-0007](../../rfcs/0007-job-lifecycle-completeness.md), with the tenant
model from [RFC-0006](../../rfcs/0006-tenant-workspace-model.md) and decisions from
as amended by [RFC-0007](../../rfcs/0007-job-lifecycle-completeness.md) and
[RFC-0011](../../rfcs/0011-require-changes-destination.md), with the tenant model from
[RFC-0006](../../rfcs/0006-tenant-workspace-model.md) and decisions from
[RFC-0002](../../rfcs/0002-governance-decision-contract.md).

In memory only. No persistence, no policy engine, no API. *Approval* is a decision by an
Expand Down Expand Up @@ -59,11 +60,10 @@ enforced, so the engine rejects rather than repairs.
| any post-approval state under an approval past its `expires_at` | `ExpiredApproval` — it has to be granted again |
| `FAILED` / `CANCELLED` / `TIMED_OUT` without a reason | `MissingReason` |
| `CANCELLED` without a principal | `MissingPrincipal` |
| `APPROVED` / `REJECTED` without an authority and reason | `MissingAuthority` |
| a transition that *is* a decision, without an authority and reason | `MissingAuthority` — including `GOVERNANCE_ANALYSIS → DRAFT`, which is a `REQUIRE_CHANGES` |
| pausing outside `IN_PROGRESS` / `VALIDATING` / `DEPLOYABLE` | `MissingApprovalContext` |
| resuming into a state other than `awaiting_from` | `WrongResumeState` |
| a malformed identifier | `InvalidIdentifier` — `identity/v1` `Id` form |
| `decide(REQUIRE_CHANGES)` | `UnmappedDecision` — no RFC says where it sends a job |
| a decision missing decision, reason, authority, or timestamp | `IncompleteDecision` |
| an agent approving the job it is the principal for | `SelfApproval` |
| a decision from another tenant or workspace | `CrossTenantDecision` — rejected, never coerced |
Expand All @@ -75,7 +75,7 @@ A refused call leaves the job untouched and writes nothing to the audit trail.
## Events

Construction emits `JOB_CREATED`; every accepted transition emits `STATE_TRANSITION`;
entering `APPROVED` or `REJECTED` also emits `GOVERNANCE_DECISION`; reaching
an edge that *is* a governance decision also emits `GOVERNANCE_DECISION`; reaching
`COMPLETED` also emits `JOB_COMPLETED`. There is no way to change state without going
through `transition()`, which is what makes *no silent state change* hold rather than
merely be documented — and no way to reach `APPROVED` without a decision record, which
Expand All @@ -98,10 +98,27 @@ job.decide(DecisionType.APPROVE, authority=bob, reason="scope agreed") # == job
job.decisions # every decision made about this job, immutable, in order
job.approval # the APPROVE it executes under, or None

job.decide(DecisionType.REQUIRE_CHANGES, authority=bob, reason="add tests")
# UnmappedDecision: declared by RFC-0002, but no RFC says which state it sends a job to
job.require_changes(authority=bob, reason="add tests") # → DRAFT, RFC-0011
```

All three of RFC-0002's decisions are executable.
[RFC-0011](../../rfcs/0011-require-changes-destination.md) settled the last one:
`REQUIRE_CHANGES` sends a job to `DRAFT` by the `GOVERNANCE_ANALYSIS → DRAFT` edge,
clearing the approval on the way — being told to make changes is not being told to
proceed.

It stays distinguishable from a `REJECT`, which ends in the same place, by the **route**:
a rejection goes `GOVERNANCE_ANALYSIS → REJECTED → DRAFT` and leaves `REJECTED` standing
in the job's history forever, while a `REQUIRE_CHANGES` goes straight there and never
enters that state. The distinction is in the trail, so a reader who was not present can
make it from the log alone — which is what makes *"REQUIRE_CHANGES ไม่ใช่ REJECT"* a
checked guarantee rather than a stated one.

One thing to read carefully if you consume the lifecycle: **whether a transition is a
decision is a property of the edge, not the destination.** `GOVERNANCE_ANALYSIS → DRAFT`
is a `REQUIRE_CHANGES`; `REJECTED → DRAFT` is the ordinary revision step and is not a
decision at all. Ask `states.decision_for_edge()`.

## Tests

```bash
Expand Down
7 changes: 4 additions & 3 deletions packages/core/devfactory_core/decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ class DecisionType(str, Enum):
observe.

All three values are declared because the set is closed: declaring two of
three would quietly narrow the contract this repository publishes. Declaring
``REQUIRE_CHANGES`` is not the same as being able to execute it — see
``states.DECISION_TARGET`` for why the engine refuses it.
three would quietly narrow the contract this repository publishes. All three
are also executable since RFC-0011 gave ``REQUIRE_CHANGES`` a destination —
see ``states.DECISION_TARGET``, which is also where the reason a
``REQUIRE_CHANGES`` stays distinguishable from a ``REJECT`` is written down.

``str`` mixin so a decision serialises as its own name.
"""
Expand Down
Loading
Loading