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
8 changes: 8 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ 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)

## 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`.

## 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
84 changes: 82 additions & 2 deletions conformance/payload_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
append-only, no silent state change, no fabricated identifiers, no reasoning
traces in an audit record.

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.

Usage::

python3 conformance/payload_check.py # fetch schemas, then validate
Expand Down Expand Up @@ -271,6 +276,74 @@ def check_payloads(log, validator, gaps: list[dict]) -> None:
)


def check_decisions(log, jobs, validator) -> None:
"""RFC-0002 — the decisions this engine produced, judged by ``approval/v1``.

Same rule as the events above: nothing is hand-written to please the schema.
These are the records the real state machine wrote while the scenario ran.
"""
decisions = [
payload
for tenant in log.tenants()
for payload in log.payloads(tenant)
if payload["event_type"] == "GOVERNANCE_DECISION"
]
real = 0
for payload in decisions:
approval = (payload.get("metadata") or {}).get("approval")
if approval is None:
real += 1
fail(
"approval",
f"GOVERNANCE_DECISION {payload['event_id'][:8]} ไม่มี approval payload",
)
continue
for error in sorted(validator.iter_errors(approval), key=lambda e: list(e.path)):
real += 1
where = "/".join(str(part) for part in error.path) or "<root>"
fail(
"approval",
f"{approval.get('decision')} ({str(approval.get('approval_id'))[:8]}): "
f"{error.message} at {where}",
)
if real == 0:
ok("approval", f"{len(decisions)} decision ผ่าน approval/v1")

# "ทุก APPROVE ต้อง auditable — ต้องมี event GOVERNANCE_DECISION คู่กันเสมอ"
mismatched = [
job.job_id
for job in jobs
if len([h for h in job.history if h.to_state.value == "APPROVED"])
!= len([d for d in job.decisions if d.decision.value == "APPROVE"])
]
if mismatched:
fail("approval", f"เข้า APPROVED โดยไม่มี decision record: {mismatched}")
else:
ok("approval", "ทุก APPROVE มี decision record และ event คู่กับ STATE_TRANSITION")

# REQUIRE_CHANGES อยู่ใน vocabulary แต่ยังไม่มี RFC กำหนดว่ามันพา job ไปไหน
# engine ต้องปฏิเสธ ไม่ใช่เดาปลายทาง — ดู states.DECISION_TARGET
from devfactory_core import DecisionType, Job, Principal
from devfactory_core.errors import UnmappedDecision

probe = Job(
job_id="job-007",
tenant_id="acme",
workspace_id="ws-core",
principal=Principal("human", "alice"),
)
probe.submit_for_governance()
try:
probe.decide(
DecisionType.REQUIRE_CHANGES,
authority=Principal("human", "bob"),
reason="needs a test plan",
)
fail("approval", "REQUIRE_CHANGES ถูกรับเข้า — ต้องปฏิเสธจนกว่าจะมี RFC กำหนดปลายทาง")
except UnmappedDecision:
ok("approval", "REQUIRE_CHANGES ถูกปฏิเสธ — ยังไม่มี RFC กำหนดปลายทางของมัน")


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 Expand Up @@ -424,13 +497,20 @@ def main() -> int:
"https://schemas.agent-platform.internal/event/v1/event.schema.yaml",
pinned["non_schema_keys"],
)
approval_validator = build_validator(
schemas,
"https://schemas.agent-platform.internal/approval/v1/approval.schema.yaml",
pinned["non_schema_keys"],
)

log, jobs, external = run_scenario()
print(f"\n[1] payload ที่ระบบผลิตจริง — {len(log)} event จาก {len(jobs)} job")
check_payloads(log, validator, pinned.get("known_gaps") or [])
print("\n[2] guarantee ที่ JSON Schema ตรวจไม่ได้")
print("\n[2] คำตัดสินที่ระบบผลิตจริง — approval/v1 (RFC-0002)")
check_decisions(log, jobs, approval_validator)
print("\n[3] guarantee ที่ JSON Schema ตรวจไม่ได้")
check_guarantees(log, jobs, external)
print("\n[3] ช่องว่างที่รู้ตัว — ต้องมี issue และวันหมดอายุ")
print("\n[4] ช่องว่างที่รู้ตัว — ต้องมี issue และวันหมดอายุ")
check_gap_expiry(pinned.get("known_gaps") or [], args.today)

fails = [f for f in findings if f[0] == "FAIL"]
Expand Down
15 changes: 15 additions & 0 deletions contract-semantics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,21 @@ contracts:
- agent ออก APPROVE ให้งานของตัวเองไม่ได้ — "no agent has total authority"
- REQUIRE_CHANGES ไม่ใช่ REJECT — งานยังมีชีวิตและกลับมายื่นใหม่ได้

# ℹ️ ไม่ใช่ส่วนหนึ่งของ contract — บอกสถานะฝั่ง implementation เฉย ๆ
# อยู่นอก frozen: จึงไม่ขยับ semantics_version และไม่กระทบ drift_check
implementation_status:
note: >-
packages/core ทำ decision interface ครบตาม RFC-0002 แล้ว (issue #5)
vocabulary ยังมีครบ 3 ค่าตามชุดปิด — ห้ามลบค่าใดออกจาก contract
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 — สิ่งที่ขาดคือปลายทาง ไม่ใช่ตัวค่า

platform_may_add_freely:
# เพิ่มได้ผ่าน ADR ฝั่ง agent-platform อย่างเดียว ไม่ต้องมี RFC ที่นี่ (Rule 1)
# ปิดประเด็น §4.6 ของ consumer-devfactory-core.md — rfcs/0002 ไม่ได้ "ขาด" field เหล่านี้
Expand Down
58 changes: 44 additions & 14 deletions packages/core/README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
# core module

The job state machine the control plane's lifecycle engine.
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).
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 — those are issues #5, #6, and #7.
In memory only. No persistence, no policy engine, no API. *Approval* is a decision by an
authority; *policy* — whether approval was needed at all — is `policy/v1` and is not
here.

## Use

```python
from devfactory_core import Job, JobState, Principal

alice = Principal("human", "alice")
bob = Principal("human", "bob")
job = Job(
job_id="job-001",
tenant_id="default", # RFC-0006: never omitted, even single-tenant
Expand All @@ -22,14 +26,18 @@ job = Job(
)

job.submit_for_governance(reason="ready for review")
job.approve(authority=alice, reason="scope matches milestone v0.1")

decision = job.approve(authority=bob, reason="scope matches milestone v0.1")
assert job.approval is decision # what execution runs under, not a boolean
assert decision.as_payload()["decision"] == "APPROVE" # approval/v1 wire shape

job.transition(JobState.TASK_PLANNING)
job.transition(JobState.IN_PROGRESS)

job.pause_for_approval(reason="merge needs sign-off")
assert job.state is JobState.AWAITING_APPROVAL
assert job.awaiting_from is JobState.IN_PROGRESS
job.resume(reason="approved", principal=alice)
job.resume(reason="approved", principal=bob)

job.transition(JobState.VALIDATING)
job.transition(JobState.DEPLOYABLE)
Expand All @@ -54,27 +62,49 @@ enforced, so the engine rejects rather than repairs.
| 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 |
| a decision about another job | `WrongDecisionSubject` |
| a decision that does not produce the transition being made | `DecisionStateMismatch` |

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`;
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.

`event_payloads()` renders the trail in `event/v1` wire shape. It is **not** validated
entering `APPROVED` or `REJECTED` 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
is what makes *every APPROVE is auditable* hold.

`event_payloads()` renders the trail in `event/v1` wire shape, and
`Decision.as_payload()` renders a decision in `approval/v1` shape. Neither is validated
here — owning a copy of the schema would be a parallel schema, which
[RFC-0005](../../rfcs/0005-platform-contract-authority.md) Rule 4 forbids. Validation
against the pinned contract is issue #6, and these payloads are what it will validate.
[RFC-0005](../../rfcs/0005-platform-contract-authority.md) Rule 4 forbids.
`conformance/payload_check.py` validates both against the pinned contracts.

## Decisions

```python
from devfactory_core import DecisionType

job.decide(DecisionType.APPROVE, authority=bob, reason="scope agreed") # == job.approve(...)
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
```

## Tests

```bash
cd packages/core
python -m pytest # 235 tests
python -m pytest --cov=devfactory_core # coverage gate at 90%, currently 100%
python -m pytest # the full core suite
python -m pytest --cov=devfactory_core # coverage gate at 90%
```

## Which states may fail
Expand Down
21 changes: 20 additions & 1 deletion packages/core/devfactory_core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,47 @@
"""devfactory-core — governance-first control plane.

Phase 1: the job state machine, in memory. See ``packages/core/state-machine.md``.
The governance decision interface it is gated by is ``decision.py`` — RFC-0002.
"""

from .decision import Decision, DecisionType, Subject, new_decision_id
from .errors import (
CrossTenantDecision,
DecisionStateMismatch,
ExecutionBeforeApproval,
IncompleteDecision,
InvalidIdentifier,
InvalidTransition,
JobStateMachineError,
MissingApprovalContext,
MissingAuthority,
MissingPrincipal,
MissingReason,
SelfApproval,
TerminalState,
UnmappedDecision,
WrongDecisionSubject,
WrongResumeState,
)
from .events import INTERNAL_SOURCE, Event, EventType
from .identity import DEFAULT_TENANT, Principal
from .job import Job, TransitionRecord
from .states import TERMINAL, TRANSITIONS, JobState
from .states import DECISION_TARGET, TERMINAL, TRANSITIONS, JobState

__all__ = [
"DECISION_TARGET",
"DEFAULT_TENANT",
"TERMINAL",
"TRANSITIONS",
"INTERNAL_SOURCE",
"CrossTenantDecision",
"Decision",
"DecisionStateMismatch",
"DecisionType",
"Event",
"EventType",
"ExecutionBeforeApproval",
"IncompleteDecision",
"InvalidIdentifier",
"InvalidTransition",
"Job",
Expand All @@ -38,7 +52,12 @@
"MissingPrincipal",
"MissingReason",
"Principal",
"SelfApproval",
"Subject",
"TerminalState",
"TransitionRecord",
"UnmappedDecision",
"WrongDecisionSubject",
"WrongResumeState",
"new_decision_id",
]
Loading
Loading