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
5 changes: 5 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ it caused, so no job reaches `APPROVED` without a record of who decided it and w
says which state it sends a job to. 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
approval reaches `TIMED_OUT` rather than waiting indefinitely
([RFC-0007 Amendment 1](rfcs/0007-job-lifecycle-completeness.md#amendment-1--approved-may-time-out-2026-08-19)).

## Multi-Tenancy
Tenant → Workspace → Resource. `tenant_id` is required on every job, decision, and
event; isolation is enforced at the storage layer, not by query filter.
Expand Down
127 changes: 122 additions & 5 deletions conformance/payload_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,18 +137,22 @@ 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.

Six jobs across two tenants, covering every terminal state, the mid-run
approval pause, rejection and resubmission, and recovery by supersession —
plus two inbound external events that no job caused.
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.

The journeys themselves are ``simulation/flows.py``, which issue #7 made the
one place they are written down. Two files describing the same lifecycle
differently is how they end up disagreeing about it, and this check exists to
catch disagreement rather than to add one.
"""
from datetime import datetime, timedelta, timezone

from devfactory_core import JobState, Principal
from devfactory_observability import EventLog, accept_external
from simulation.flows import (
approval_expired,
cancelled_by_a_person,
failed_at,
happy_path,
Expand Down Expand Up @@ -186,11 +190,21 @@ def run_scenario():
# 5. an approval nobody answered, in a second tenant
stalled = stalled_awaiting_approval(globex, job_id="job-006", authority=reviewer)

jobs = [happy, revised, failed, replacement, cancelled, stalled]
# 6. an approval that expired where it sat — the only flow that produces an
# approval/v1 payload carrying expires_at, so the field is validated as a
# real payload rather than asserted about in the abstract
expired = approval_expired(
acme,
job_id="job-008",
authority=reviewer,
expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
)

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

# 6. events from another system, which no job caused
# 7. events from another system, which no job caused
external = [
accept_external(
{
Expand Down Expand Up @@ -313,6 +327,23 @@ def check_decisions(log, jobs, validator) -> None:
else:
ok("approval", "ทุก APPROVE มี decision record และ event คู่กับ STATE_TRANSITION")

# "approval ที่หมดอายุแล้วใช้เดินงานไม่ได้ ต้องขอใหม่" (approval/v1 expires_at)
# เขาเขียนความหมายไว้ใน schema แล้ว แต่ JSON Schema ตรวจให้ไม่ได้ว่า engine ทำตามไหม
# จึงต้องตรวจที่นี่ · ดู rfcs/0007 Amendment 1 · issue #17
expiring = [
payload
for payload in decisions
if (payload.get("metadata") or {}).get("approval", {}).get("expires_at")
]
if expiring:
ok("approval", f"{len(expiring)} approval พก expires_at และผ่าน approval/v1")
else:
fail(
"approval",
"ไม่มี approval ที่พก expires_at เลย — เช็คข้างล่างจะกลายเป็นการตรวจสิ่งที่ไม่มีจริง",
)
check_expiry_enforced()

# REQUIRE_CHANGES อยู่ใน vocabulary แต่ยังไม่มี RFC กำหนดว่ามันพา job ไปไหน
# engine ต้องปฏิเสธ ไม่ใช่เดาปลายทาง — ดู states.DECISION_TARGET
from devfactory_core import DecisionType, Job, Principal
Expand All @@ -336,6 +367,92 @@ def check_decisions(log, jobs, validator) -> None:
ok("approval", "REQUIRE_CHANGES ถูกปฏิเสธ — ยังไม่มี RFC กำหนดปลายทางของมัน")


def check_expiry_enforced() -> None:
"""Would we notice a job still running on an approval that had expired?

Two sides, because the guarantee has two. The engine must refuse to move the
job; and if something else moved it anyway, the trail must not read back as
though that were fine — the deadline and the moment are both recorded, so the
log can be judged against itself by a reader who was not there.

The second half is checked by forging a trail rather than by producing one,
for the same reason the ``REQUIRE_CHANGES`` probe below exists: the engine
cannot produce the record we need to catch, and a check that can only see
records the engine agrees with is not checking the engine.
"""
import dataclasses
from datetime import datetime, timedelta, timezone

from devfactory_core import JobState, Principal
from devfactory_core.errors import ExpiredApproval
from devfactory_observability import ExecutionAfterExpiry, replay_job
from simulation.flows import job_factory

# A clock that can be pushed forward, because an approval expires by time
# passing and nothing else. Granting one that was already stale would be a
# different, less interesting record.
start = datetime(2026, 8, 19, 9, 0, tzinfo=timezone.utc)
now = {"t": start}

def clock() -> datetime:
now["t"] += timedelta(seconds=1)
return now["t"]

owner = Principal("human", "alice")
reviewer = Principal("human", "bob")
new = job_factory(
tenant_id="acme", workspace_id="ws-core", principal=owner, clock=clock
)
deadline = start + timedelta(minutes=5)

lapsed = new("job-009")
lapsed.submit_for_governance()
lapsed.approve(authority=reviewer, reason="approved", expires_at=deadline)
now["t"] = deadline + timedelta(hours=1) # nobody came for the job in time
try:
lapsed.transition(JobState.TASK_PLANNING)
fail("approval", "งานเดินเข้า TASK_PLANNING ได้ด้วย approval ที่หมดอายุแล้ว")
except ExpiredApproval:
ok("approval", "approval ที่หมดอายุใช้เดินงานต่อไม่ได้ — engine ปฏิเสธ")

# The job is not stuck: RFC-0007 Amendment 1 gives it somewhere honest to land.
lapsed.time_out(reason="approval_expired — the approval lapsed before planning began")
if lapsed.state is JobState.TIMED_OUT:
ok("approval", "งานที่ถือ approval หมดอายุเข้า TIMED_OUT ได้ (rfcs/0007 Amendment 1)")
else:
fail("approval", f"งานที่ถือ approval หมดอายุไปจบที่ {lapsed.state.value}")

# Now the log. Same journey with an approval that was still valid, then the
# recorded deadline moved into the past — which is what "the job kept going on
# an expired approval" looks like when it is read back rather than watched.
ran_on = new("job-010")
ran_on.submit_for_governance()
ran_on.approve(
authority=reviewer, reason="approved", expires_at=now["t"] + timedelta(hours=1)
)
ran_on.transition(JobState.TASK_PLANNING)
forged = [
dataclasses.replace(
event,
metadata={
**event.metadata,
"approval": {
**event.metadata["approval"],
"expires_at": start.isoformat(),
},
},
)
if event.type_value == "GOVERNANCE_DECISION"
else event
for event in ran_on.events
]
try:
replay_job(forged)
fail("approval", "trail ที่บันทึกว่างานเดินต่อหลัง approval หมดอายุ ยัง replay ผ่าน")
except ExecutionAfterExpiry:
ok("approval", "replay จับได้ว่า trail บันทึกการเดินงานหลัง approval หมดอายุ")


def check_gap_expiry(gaps: list[dict], today: str) -> None:
"""A waiver with no end date is a permanent exception, which ADR-0006 forbids."""
for gap in gaps or ():
Expand Down
23 changes: 21 additions & 2 deletions contract-semantics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,20 @@ contracts:
note: >-
packages/core ทำ decision interface ครบตาม RFC-0002 แล้ว (issue #5)
vocabulary ยังมีครบ 3 ค่าตามชุดปิด — ห้ามลบค่าใดออกจาก contract
enforced_optional_fields:
# field ที่ agent-platform เพิ่มเองได้ (platform_may_add_freely ด้านล่าง)
# แต่ตัว schema เขียนความหมายไว้แล้ว — repo นี้จึงบังคับตามความหมายนั้น
# ไม่ใช่การเพิ่ม guarantee ใหม่ แต่คือการปิดช่องว่างของการ conform
- field: expires_at
detail: >-
approval/v1 บอกว่า "approval ที่หมดอายุแล้วใช้เดินงานไม่ได้ ต้องขอใหม่ ·
งานที่ค้างรออนุมัติจนเลยกำหนดควรเข้าสถานะ timeout ไม่ใช่รอตลอดไป"
· Decision เก็บค่านี้ · engine ปฏิเสธการเข้า state ฝั่ง execution
ด้วย approval ที่หมดอายุ (ExpiredApproval) · replay ปฏิเสธ trail ที่บันทึกว่าเกิดขึ้นแล้ว
(ExecutionAfterExpiry) · ปลายทางของงานที่ค้างคือ TIMED_OUT ตาม rfcs/0007 Amendment 1
optional: >-
ยัง optional เหมือนใน schema — approval ที่ไม่มี expires_at คือไม่มีวันหมดอายุ
บังคับให้ต้องมีจะทำให้ engine เข้มกว่า contract ที่ conform อยู่ · issue #17
unmapped:
- decision: REQUIRE_CHANGES
detail: >-
Expand Down Expand Up @@ -228,8 +242,12 @@ not_derived:
# ก่อน APPROVED ยังไม่มี execution ผลลัพธ์ที่ซื่อสัตย์คือ REJECTED / CANCELLED / TIMED_OUT
failable: [TASK_PLANNING, IN_PROGRESS, AWAITING_APPROVAL, VALIDATING, DEPLOYABLE]

# rfcs/0007
timeoutable: [GOVERNANCE_ANALYSIS, TASK_PLANNING, IN_PROGRESS, AWAITING_APPROVAL, VALIDATING]
# rfcs/0007 · APPROVED เพิ่มโดย Amendment 1 ของ rfcs/0007 (2026-08-19, issue #17)
# เหตุผลเป็น governance ไม่ใช่ liveness: การอนุมัติต้องมีวันหมดอายุ
# job ที่ค้างใน APPROVED ได้ตลอดไป = เริ่มทำงานอีกสัปดาห์ภายใต้คำตัดสินที่บริบทเปลี่ยนไปแล้ว
# ซึ่งเป็น stale APPROVED แบบเดียวกับที่ rfcs/0007 Decision 1 ห้ามตอนไม่ยอมปลุก job ที่ FAILED
timeoutable: [GOVERNANCE_ANALYSIS, APPROVED, TASK_PLANNING, IN_PROGRESS, AWAITING_APPROVAL,
VALIDATING]

# rfcs/0007 — หยุดงานได้ทุกจุดก่อนที่มันจะ settle (= states ทั้งหมด ลบ terminal)
# เขียนเป็น list เหมือน failable/timeoutable เพื่อให้เครื่องที่อ่านสามคีย์นี้ parse ได้แบบเดียวกัน
Expand All @@ -245,6 +263,7 @@ not_derived:
- AWAITING_APPROVAL คนละอย่างกับ GOVERNANCE_ANALYSIS — อันหลังคือประตูก่อนเริ่มงาน
- 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)
layering: job (ที่นี่) > execution (execution/v1) > step (event/)

orchestration_execution_boundary:
Expand Down
21 changes: 19 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ enforced, so the engine rejects rather than repairs.
| an edge not in the table | `InvalidTransition`, naming what *was* allowed |
| anything out of `COMPLETED` / `FAILED` / `CANCELLED` / `TIMED_OUT` | `TerminalState` — recovery is `supersede()`, not a revival |
| `TASK_PLANNING` before `APPROVED` | `InvalidTransition` — execution is forbidden before approval |
| 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` |
Expand Down Expand Up @@ -116,5 +117,21 @@ Settled by [RFC-0010](../../rfcs/0010-failable-states.md): `FAILED` is reachable
the states where work exists to fail — and refused before `APPROVED`, where the honest
outcomes are `REJECTED`, `CANCELLED`, or `TIMED_OUT`. See `states.FAILABLE`.

`APPROVED` is in neither `FAILABLE` nor `TIMEOUTABLE`, so a job that stalls there has no
automatic exit — RFC-0010 records this as an open question rather than a decision.
## Approval expiry

`approval/v1` carries `expires_at` and says what it means: an approval past it cannot be
used to run work and has to be granted again. `Decision` stores it, `approve()` takes it,
and the engine refuses to move a job into a post-approval state under a lapsed one.

```python
job.approve(authority=bob, reason="scope agreed", expires_at=deadline)
job.approval_expires_at # the deadline, or None
job.approval_expired # whether it has passed, as of now
```

`expires_at` is optional in the contract and optional here — an approval without one never
expires. What changed with issue #17 is that `APPROVED` is now in `TIMEOUTABLE`
([RFC-0007 Amendment 1](../../rfcs/0007-job-lifecycle-completeness.md#amendment-1--approved-may-time-out-2026-08-19)),
so a job holding an approval that ran out has an honest terminal to reach instead of
waiting for a human to cancel it. The timeout *policy* — how long an approval is good for
— stays out of scope, as it is in RFC-0007 and RFC-0010.
2 changes: 2 additions & 0 deletions packages/core/devfactory_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
CrossTenantDecision,
DecisionStateMismatch,
ExecutionBeforeApproval,
ExpiredApproval,
IncompleteDecision,
InvalidIdentifier,
InvalidTransition,
Expand Down Expand Up @@ -41,6 +42,7 @@
"Event",
"EventType",
"ExecutionBeforeApproval",
"ExpiredApproval",
"IncompleteDecision",
"InvalidIdentifier",
"InvalidTransition",
Expand Down
40 changes: 40 additions & 0 deletions packages/core/devfactory_core/decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,17 @@ class Decision:
decided_at: datetime
workspace_id: str | None = None

#: When this decision stops authorising anything — ``approval/v1``
#: ``expires_at``.
#:
#: Optional there and optional here, which is the whole of what the contract
#: says: an approval with no expiry never expires, and requiring one would
#: make this engine stricter than the contract it conforms to. What is *not*
#: optional is the meaning when it is present — "approval ที่หมดอายุแล้วใช้เดินงาน
#: ไม่ได้ ต้องขอใหม่" — which :class:`~devfactory_core.job.Job` enforces by
#: refusing to move a job into execution under an expired one.
expires_at: datetime | None = None

#: The decision this one replaces.
#:
#: ``approval/v1`` states the guarantee — "การเปลี่ยนใจคือ approval ใบใหม่ที่
Expand Down Expand Up @@ -160,6 +171,33 @@ def __post_init__(self) -> None:
raise IncompleteDecision("reason")
if not isinstance(self.decided_at, datetime):
raise IncompleteDecision("decided_at")
if self.expires_at is not None:
if not isinstance(self.expires_at, datetime):
raise ValueError(
f"expires_at must be a datetime — got {type(self.expires_at).__name__}"
)
# ``approval/v1`` types it ``format: date-time``, which is RFC 3339 and
# carries an offset. A naive value cannot be compared against the
# engine's clock without assuming a zone, and assuming one is how an
# approval silently expires at the wrong moment.
if self.expires_at.tzinfo is None:
raise ValueError(
"expires_at must be timezone-aware — an approval that expires at "
"an unstated offset expires at a different time for every reader"
)

def is_expired(self, now: datetime) -> bool:
"""Whether this decision no longer authorises anything, as of ``now``.

An approval with no ``expires_at`` is never expired: the field is optional
in ``approval/v1``, and absent means "no deadline was set", not "expired".

The boundary is inclusive — at the instant named the approval is already
spent. ``expires_at`` is the moment it stops being usable, not the last
moment it can be used, and rounding that in the permissive direction would
let a job start on an approval that had run out.
"""
return self.expires_at is not None and now >= self.expires_at

def as_payload(self) -> dict[str, Any]:
"""Render to the ``approval/v1`` wire shape.
Expand All @@ -180,6 +218,8 @@ def as_payload(self) -> dict[str, Any]:
}
if self.workspace_id is not None:
payload["workspace_id"] = self.workspace_id
if self.expires_at is not None:
payload["expires_at"] = self.expires_at.isoformat()
if self.supersedes_decision_id is not None:
# Our field, not theirs — see the field comment above.
payload["supersedes_decision_id"] = self.supersedes_decision_id
Expand Down
Loading
Loading