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
37 changes: 37 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: test

# The seed of the release gate ADR-0006 requires of a consumer. Right now it runs
# unit tests; payload conformance against the pinned contracts joins it in issue #6.

on:
push:
branches: [main]
paths: ['packages/**', 'apps/**', '.github/workflows/test.yml']
pull_request:
paths: ['packages/**', 'apps/**', '.github/workflows/test.yml']
workflow_dispatch:

permissions:
contents: read

jobs:
core:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install
working-directory: packages/core
run: pip install --no-cache-dir -e '.[test]'

- name: Test
working-directory: packages/core
run: python -m pytest --cov=devfactory_core --cov-report=term-missing
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
.pytest_cache/
.coverage
.coverage.*
htmlcov/
*.egg-info/
build/
dist/
.venv/
venv/
86 changes: 86 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,87 @@
# core module

The job state machine — the control plane's lifecycle engine.

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).

In memory only. No persistence, no policy engine, no API — those are issues #5, #6, and #7.

## Use

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

alice = Principal("human", "alice")
job = Job(
job_id="job-001",
tenant_id="default", # RFC-0006: never omitted, even single-tenant
workspace_id="ws-core",
principal=alice,
)

job.submit_for_governance(reason="ready for review")
job.approve(authority=alice, reason="scope matches milestone v0.1")
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.transition(JobState.VALIDATING)
job.transition(JobState.DEPLOYABLE)
job.transition(JobState.COMPLETED)

job.event_payloads() # audit trail in event/v1 wire shape
```

## What the engine refuses

Every refusal below is deliberate. The lifecycle exists so governance can be
enforced, so the engine rejects rather than repairs.

| call | refusal |
| --- | --- |
| 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 |
| `FAILED` / `CANCELLED` / `TIMED_OUT` without a reason | `MissingReason` |
| `CANCELLED` without a principal | `MissingPrincipal` |
| `APPROVED` / `REJECTED` without an authority and reason | `MissingAuthority` |
| pausing outside `IN_PROGRESS` / `VALIDATING` / `DEPLOYABLE` | `MissingApprovalContext` |
| resuming into a state other than `awaiting_from` | `WrongResumeState` |
| a malformed identifier | `InvalidIdentifier` — `identity/v1` `Id` form |

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

## Tests

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

## Open question

`state-machine.md` says `FAILED` is terminal and lists `AWAITING_APPROVAL -> FAILED`,
but never enumerates which other states may fail. This module permits `FAILED` from
`TASK_PLANNING`, `IN_PROGRESS`, `AWAITING_APPROVAL`, `VALIDATING`, and `DEPLOYABLE` —
the states where work exists to fail — and refuses it before `APPROVED`, where the
honest outcomes are `REJECTED`, `CANCELLED`, or `TIMED_OUT`. That reading needs
confirming in an RFC; see `states.FAILABLE`.
43 changes: 43 additions & 0 deletions packages/core/devfactory_core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""devfactory-core — governance-first control plane.

Phase 1: the job state machine, in memory. See ``packages/core/state-machine.md``.
"""

from .errors import (
ExecutionBeforeApproval,
InvalidIdentifier,
InvalidTransition,
JobStateMachineError,
MissingApprovalContext,
MissingAuthority,
MissingPrincipal,
MissingReason,
TerminalState,
WrongResumeState,
)
from .events import Event, EventType
from .identity import DEFAULT_TENANT, Principal
from .job import Job, TransitionRecord
from .states import TERMINAL, TRANSITIONS, JobState

__all__ = [
"DEFAULT_TENANT",
"TERMINAL",
"TRANSITIONS",
"Event",
"EventType",
"ExecutionBeforeApproval",
"InvalidIdentifier",
"InvalidTransition",
"Job",
"JobState",
"JobStateMachineError",
"MissingApprovalContext",
"MissingAuthority",
"MissingPrincipal",
"MissingReason",
"Principal",
"TerminalState",
"TransitionRecord",
"WrongResumeState",
]
117 changes: 117 additions & 0 deletions packages/core/devfactory_core/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Errors raised by the job state machine.

Every one of these is a refusal, not a fallback. The lifecycle exists so that
governance can be enforced; an engine that repairs a bad call instead of
rejecting it would defeat the reason for having it.
"""

from __future__ import annotations


class JobStateMachineError(Exception):
"""Base class, so a caller can catch every refusal from this module."""


class InvalidTransition(JobStateMachineError):
"""The requested edge is not in the transition table for the current state."""

def __init__(self, current: str, requested: str, allowed: list[str]) -> None:
self.current = current
self.requested = requested
self.allowed = allowed
permitted = ", ".join(allowed) if allowed else "nothing — this state is terminal"
super().__init__(
f"{current} -> {requested} is not a valid transition. Allowed: {permitted}"
)


class TerminalState(JobStateMachineError):
"""A transition was requested out of a terminal state.

Recovery from FAILED is a new job carrying ``supersedes_job_id``, never a
transition out of it — RFC-0007 keeps FAILED terminal so that recovery has
to pass GOVERNANCE_ANALYSIS again rather than resume under a stale APPROVED.
"""

def __init__(self, current: str) -> None:
self.current = current
super().__init__(
f"{current} is terminal. Recovery is a new job with supersedes_job_id, "
f"not a transition out of {current}."
)


class MissingReason(JobStateMachineError):
"""FAILED, CANCELLED, and TIMED_OUT each require reason metadata."""

def __init__(self, state: str) -> None:
self.state = state
super().__init__(f"{state} requires a reason — 'it stopped' is not an audit record")


class MissingPrincipal(JobStateMachineError):
"""CANCELLED records who cancelled it."""

def __init__(self, state: str) -> None:
self.state = state
super().__init__(
f"{state} requires the principal responsible — "
f"'someone stopped this' is not an audit record"
)


class MissingApprovalContext(JobStateMachineError):
"""AWAITING_APPROVAL cannot be entered without knowing where to return to."""

def __init__(self, current: str) -> None:
self.current = current
super().__init__(
f"AWAITING_APPROVAL cannot be entered from {current} — "
f"only IN_PROGRESS, VALIDATING, or DEPLOYABLE can pause for approval"
)


class WrongResumeState(JobStateMachineError):
"""A paused job tried to resume somewhere other than where it paused."""

def __init__(self, awaiting_from: str, requested: str) -> None:
self.awaiting_from = awaiting_from
self.requested = requested
super().__init__(
f"job paused in {awaiting_from} cannot resume into {requested} — "
f"resuming elsewhere would silently lose its place in the lifecycle"
)


class ExecutionBeforeApproval(JobStateMachineError):
"""The direction lock: no execution before an explicit APPROVE."""

def __init__(self, requested: str) -> None:
self.requested = requested
super().__init__(
f"cannot reach {requested} without passing APPROVED — "
f"execution is forbidden before governance approves"
)


class MissingAuthority(JobStateMachineError):
"""APPROVED and REJECTED are decisions and must name who made them."""

def __init__(self, state: str) -> None:
self.state = state
super().__init__(
f"{state} requires an accountable authority and a reason — "
f"an approval nobody signed is not auditable"
)


class InvalidIdentifier(JobStateMachineError):
"""Identifiers must match the identity/v1 Id form."""

def __init__(self, field: str, value: str) -> None:
self.field = field
self.value = value
super().__init__(
f"{field}={value!r} is not a valid identity/v1 Id "
f"(lowercase, leading alphanumeric, [a-z0-9_-], max 63 chars)"
)
97 changes: 97 additions & 0 deletions packages/core/devfactory_core/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Audit events emitted by the state machine.

Shaped to ``event/v1`` from agent-platform so that the payloads this engine
produces are the ones a conformance test will validate (issue #6). This module
deliberately does **not** validate — owning a copy of the schema here would be a
parallel schema, which RFC-0005 Rule 4 forbids. It builds the payload; the
contract judges it.

Invariants carried from RFC-0008 and enforced by construction rather than by
convention:

* ``job_id`` is always present on events this repository emits. The field is
optional in the schema and not optional in our behaviour.
* identifiers are never fabricated — every id here comes from a real object.
* ``metadata`` carries structured facts only. Private reasoning traces are not
audit records and must never be placed here.
"""

from __future__ import annotations

import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any

from .identity import Principal


class EventType(str, Enum):
"""The canonical vocabulary from RFC-0003.

RFC-0009 made this a required minimum rather than a closed set at the
contract level — agent-platform may add types. These are the ones the job
state machine itself emits.
"""

JOB_CREATED = "JOB_CREATED"
STATE_TRANSITION = "STATE_TRANSITION"
JOB_COMPLETED = "JOB_COMPLETED"


def new_event_id() -> str:
"""A fresh event id in the identity/v1 ``Id`` form.

``uuid4().hex`` is 32 lowercase hex characters, which satisfies the pattern
without needing to be reshaped.
"""
return uuid.uuid4().hex


def utc_now() -> datetime:
return datetime.now(timezone.utc)


@dataclass(frozen=True, slots=True)
class Event:
"""One audit record. Frozen — ``event/v1`` guarantees append-only."""

event_id: str
event_type: EventType
tenant_id: str
subject_type: str
subject_id: str
occurred_at: datetime
job_id: str
workspace_id: str | None = None
actor: Principal | None = None
transition: dict[str, Any] | None = None
metadata: dict[str, Any] = field(default_factory=dict)

def as_payload(self) -> dict[str, Any]:
"""Render to the ``event/v1`` wire shape.

``source.kind`` is ``internal`` because this engine is the origin. An
event arriving from another system keeps its own ``source`` — RFC-0008
requires an external event to stay identifiable as external forever.
"""
payload: dict[str, Any] = {
"event_id": self.event_id,
"event_type": self.event_type.value,
"tenant_id": self.tenant_id,
"subject_type": self.subject_type,
"subject_id": self.subject_id,
"job_id": self.job_id,
"occurred_at": self.occurred_at.isoformat(),
"source": {"kind": "internal", "system": "devfactory-core"},
}
if self.workspace_id is not None:
payload["workspace_id"] = self.workspace_id
if self.actor is not None:
payload["actor"] = self.actor.as_payload()
if self.transition is not None:
payload["transition"] = self.transition
if self.metadata:
payload["metadata"] = dict(self.metadata)
return payload
Loading
Loading