statespec 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: ruff check src tests
21
+ - run: pytest -q
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ Kernel tag series: `statespec-vX.Y.Z`. Contract changes are also recorded in
4
+ `docs/policy-artifact-contract.md`.
5
+
6
+ ## 0.4.0 — tag `statespec-v0.4.0` (2026-07-03)
7
+
8
+ Contract-defect fix (freeze exception): `canonical()` now includes `title`, so
9
+ `presentation_digest` is independently verifiable from the artifact and
10
+ `diff()` can describe a title change. `semantic_digest`,
11
+ `presentation_digest`, and envelope `schema_version: 2` are all unchanged —
12
+ existing approval bindings are unaffected. Adds the golden contract fixture
13
+ (`tests/fixtures/golden.policy.json`) as a canonicalisation tripwire.
14
+
15
+ ## 0.3.0
16
+
17
+ Baseline as extracted from `ConceptPending/baseplate` tag `statespec-v0.3.0`
18
+ (commit `2aca24e`, 2026-06-10). Policy artifact envelope `schema_version: 2`
19
+ (`semantic_digest` + `presentation_digest`). No behavioural changes at
20
+ extraction: import rename `app.statespec` → `statespec` and doc paths only.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nick Williamson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: statespec
3
+ Version: 0.4.0
4
+ Summary: Declarative lifecycle state-machine specs: one enforcement path, policy identity digests, semantic diffs
5
+ Project-URL: Repository, https://github.com/ConceptPending/statespec
6
+ Author-email: Nick Williamson <nick@nickw.info>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.12
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest; extra == 'dev'
17
+ Requires-Dist: ruff; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # statespec
21
+
22
+ Declarative lifecycle policy for entities whose `status` moves through reviewable steps.
23
+ A spec is plain data — states, named transitions (roles + guards), invariants — and the
24
+ same artifact is human-readable (Mermaid / plain-English table), machine-checkable
25
+ (`validate`), enforced through a single interpreter (`apply` / `fire`), and identified
26
+ by content digests (`identity`) so that behavioural change is mechanically
27
+ distinguishable from wording change.
28
+
29
+ - `statespec.core` — the engine: `StateSpec`, `Transition`, `Invariant`, `apply`, `fire`, `validate`.
30
+ - `statespec.expr` — closed guard/invariant expression grammar (compare + boolean), with a
31
+ versioned `Opaque` escape hatch whose body is hashed into the policy identity.
32
+ - `statespec.identity` — `canonical()`, `semantic_digest` / `presentation_digest`,
33
+ `change_kind`, `diff`, and `policy_record()` (the versioned `.policy.json` envelope —
34
+ see `docs/policy-artifact-contract.md`, the authoritative copy of the contract).
35
+ - `statespec.render` — Mermaid, tables, and the generated lifecycle doc.
36
+
37
+ Pure Python, stdlib-only, no dependencies.
38
+
39
+ ## Quick start
40
+
41
+ ```bash
42
+ pip install statespec
43
+ ```
44
+
45
+ ```python
46
+ from statespec import StateSpec, Transition, apply, validate
47
+ from statespec.expr import field
48
+
49
+ spec = StateSpec(
50
+ name="review", title="Review",
51
+ states={"draft": "Being written", "submitted": "Awaiting review",
52
+ "approved": "Done"},
53
+ fields={"amount": "int"},
54
+ initial="draft", terminal=frozenset({"approved"}),
55
+ transitions=(
56
+ Transition("submit", ("draft",), "submitted",
57
+ roles=frozenset({"author"})),
58
+ Transition("approve", ("submitted",), "approved",
59
+ roles=frozenset({"reviewer"}),
60
+ guard=field("amount").le(100)),
61
+ ),
62
+ )
63
+
64
+ assert validate(spec, known_roles=frozenset({"author", "reviewer"})) == []
65
+ apply(spec, "approve", "submitted", frozenset({"reviewer"}), {"amount": 40})
66
+ # -> "approved". Wrong role -> PermissionDenied; amount 500 -> GuardRejected;
67
+ # fire() returns the same decision plus structured evidence per condition.
68
+ ```
69
+
70
+ Every claim above is exercised by the test suite: the refusal taxonomy and
71
+ its precedence in `tests/test_core.py`, the expression grammar and
72
+ typechecking in `tests/test_expr.py`, digests/diff in `tests/test_identity.py`,
73
+ and canonicalisation byte-stability against a golden artifact in
74
+ `tests/test_contract_golden.py`.
75
+
76
+ ## Boundaries (what this does not do)
77
+
78
+ statespec sits *above* concurrency and persistence. It decides whether a
79
+ transition is legal; it does not make the write atomic or durable. It will
80
+ not stop a race between two concurrent transitions (pair it with optimistic
81
+ locking or a version column at the persistence layer), a forgotten lock, an
82
+ out-of-band mutation that sets `status` around the interpreter, or a
83
+ cross-system inconsistency. The "single enforcement path" property holds
84
+ only if every status write in the application routes through `apply`/`fire`
85
+ — enforce that with a repo-level convention or CI gate, not by trust. The
86
+ digests identify *policy* change; they do not verify that deployed code
87
+ matches the artifact — that check belongs to CI or a control plane.
88
+
89
+ ## Provenance
90
+
91
+ Extracted from [`ConceptPending/baseplate`](https://github.com/ConceptPending/baseplate)
92
+ branch `example/state-machine` at tag `statespec-v0.3.0` (commit `2aca24e`, 2026-06-10),
93
+ where the kernel was developed and frozen. The only changes at extraction were the import
94
+ rename `app.statespec` → `statespec` and doc-path adjustments. This repository is the
95
+ kernel's authoritative home from `statespec-v0.4.0` onward; the in-tree copy in baseplate
96
+ remains frozen at v0.3.0 as part of the reference example.
97
+
98
+ ## Versioning and freeze discipline
99
+
100
+ Kernel tags form their own series (`statespec-vX.Y.Z`), separate from any consuming
101
+ product's versions. The canonicalisation and digest rules are a **frozen contract**:
102
+ any change to the policy-artifact envelope bumps `schema_version` and the kernel tag
103
+ together, and is recorded in `docs/policy-artifact-contract.md`'s changelog. Changes
104
+ that alter existing digests are breaking and require explicit re-binding by consumers
105
+ (e.g. approval planes). Exceptions to the freeze: genuine bugs, security issues, or
106
+ contract defects.
107
+
108
+ Known consumers: `baseplate` (reference example, v0.3.0), `baseplate-control`
109
+ (approval plane; vendors this kernel byte-identically and verifies digests independently).
110
+
111
+ ## Development
112
+
113
+ ```bash
114
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
115
+ .venv/bin/pytest
116
+ .venv/bin/ruff check src tests
117
+ ```
@@ -0,0 +1,98 @@
1
+ # statespec
2
+
3
+ Declarative lifecycle policy for entities whose `status` moves through reviewable steps.
4
+ A spec is plain data — states, named transitions (roles + guards), invariants — and the
5
+ same artifact is human-readable (Mermaid / plain-English table), machine-checkable
6
+ (`validate`), enforced through a single interpreter (`apply` / `fire`), and identified
7
+ by content digests (`identity`) so that behavioural change is mechanically
8
+ distinguishable from wording change.
9
+
10
+ - `statespec.core` — the engine: `StateSpec`, `Transition`, `Invariant`, `apply`, `fire`, `validate`.
11
+ - `statespec.expr` — closed guard/invariant expression grammar (compare + boolean), with a
12
+ versioned `Opaque` escape hatch whose body is hashed into the policy identity.
13
+ - `statespec.identity` — `canonical()`, `semantic_digest` / `presentation_digest`,
14
+ `change_kind`, `diff`, and `policy_record()` (the versioned `.policy.json` envelope —
15
+ see `docs/policy-artifact-contract.md`, the authoritative copy of the contract).
16
+ - `statespec.render` — Mermaid, tables, and the generated lifecycle doc.
17
+
18
+ Pure Python, stdlib-only, no dependencies.
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ pip install statespec
24
+ ```
25
+
26
+ ```python
27
+ from statespec import StateSpec, Transition, apply, validate
28
+ from statespec.expr import field
29
+
30
+ spec = StateSpec(
31
+ name="review", title="Review",
32
+ states={"draft": "Being written", "submitted": "Awaiting review",
33
+ "approved": "Done"},
34
+ fields={"amount": "int"},
35
+ initial="draft", terminal=frozenset({"approved"}),
36
+ transitions=(
37
+ Transition("submit", ("draft",), "submitted",
38
+ roles=frozenset({"author"})),
39
+ Transition("approve", ("submitted",), "approved",
40
+ roles=frozenset({"reviewer"}),
41
+ guard=field("amount").le(100)),
42
+ ),
43
+ )
44
+
45
+ assert validate(spec, known_roles=frozenset({"author", "reviewer"})) == []
46
+ apply(spec, "approve", "submitted", frozenset({"reviewer"}), {"amount": 40})
47
+ # -> "approved". Wrong role -> PermissionDenied; amount 500 -> GuardRejected;
48
+ # fire() returns the same decision plus structured evidence per condition.
49
+ ```
50
+
51
+ Every claim above is exercised by the test suite: the refusal taxonomy and
52
+ its precedence in `tests/test_core.py`, the expression grammar and
53
+ typechecking in `tests/test_expr.py`, digests/diff in `tests/test_identity.py`,
54
+ and canonicalisation byte-stability against a golden artifact in
55
+ `tests/test_contract_golden.py`.
56
+
57
+ ## Boundaries (what this does not do)
58
+
59
+ statespec sits *above* concurrency and persistence. It decides whether a
60
+ transition is legal; it does not make the write atomic or durable. It will
61
+ not stop a race between two concurrent transitions (pair it with optimistic
62
+ locking or a version column at the persistence layer), a forgotten lock, an
63
+ out-of-band mutation that sets `status` around the interpreter, or a
64
+ cross-system inconsistency. The "single enforcement path" property holds
65
+ only if every status write in the application routes through `apply`/`fire`
66
+ — enforce that with a repo-level convention or CI gate, not by trust. The
67
+ digests identify *policy* change; they do not verify that deployed code
68
+ matches the artifact — that check belongs to CI or a control plane.
69
+
70
+ ## Provenance
71
+
72
+ Extracted from [`ConceptPending/baseplate`](https://github.com/ConceptPending/baseplate)
73
+ branch `example/state-machine` at tag `statespec-v0.3.0` (commit `2aca24e`, 2026-06-10),
74
+ where the kernel was developed and frozen. The only changes at extraction were the import
75
+ rename `app.statespec` → `statespec` and doc-path adjustments. This repository is the
76
+ kernel's authoritative home from `statespec-v0.4.0` onward; the in-tree copy in baseplate
77
+ remains frozen at v0.3.0 as part of the reference example.
78
+
79
+ ## Versioning and freeze discipline
80
+
81
+ Kernel tags form their own series (`statespec-vX.Y.Z`), separate from any consuming
82
+ product's versions. The canonicalisation and digest rules are a **frozen contract**:
83
+ any change to the policy-artifact envelope bumps `schema_version` and the kernel tag
84
+ together, and is recorded in `docs/policy-artifact-contract.md`'s changelog. Changes
85
+ that alter existing digests are breaking and require explicit re-binding by consumers
86
+ (e.g. approval planes). Exceptions to the freeze: genuine bugs, security issues, or
87
+ contract defects.
88
+
89
+ Known consumers: `baseplate` (reference example, v0.3.0), `baseplate-control`
90
+ (approval plane; vendors this kernel byte-identically and verifies digests independently).
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
96
+ .venv/bin/pytest
97
+ .venv/bin/ruff check src tests
98
+ ```
@@ -0,0 +1,131 @@
1
+ # The policy artifact: a versioned contract
2
+
3
+ **Authoritative home:** this repository (`ConceptPending/statespec`) as of
4
+ `statespec-v0.4.0`. The copy in `baseplate` is frozen at v0.3.0 alongside its
5
+ reference example.
6
+
7
+ **Changelog:**
8
+
9
+ - `statespec-v0.3.0` (2026-06-10, baseplate `2aca24e`) — kernel freeze;
10
+ envelope `schema_version: 2` (`semantic_digest` + `presentation_digest`).
11
+ - `statespec-v0.4.0` (2026-07-03, this repo) — `spec` gains `title`. Both
12
+ digests and `schema_version: 2` are **unchanged** (`title` never enters the
13
+ semantic projection; the presentation projection already hashed it). Fixes a
14
+ contract defect found while integrating the control plane: previously
15
+ `presentation_digest` hashed `spec.title` but the artifact omitted it, so the
16
+ digest was not independently verifiable and `diff()` could not describe a
17
+ title change. Consumers may now verify `presentation_digest` from the
18
+ artifact alone whenever `"title" in spec`.
19
+
20
+ **Freeze discipline:** changes are limited to genuine bugs, security issues, or
21
+ contract defects. The next envelope change bumps `schema_version` and the
22
+ kernel tag together.
23
+
24
+ In a consuming repo, `make spec-doc` (or equivalent) writes
25
+ `docs/specs/<name>.policy.json` for every lifecycle spec.
26
+ This file is the **stable, machine-readable contract** an external consumer (a
27
+ control plane, a policy-review GitHub App, an auditor tool) reads. This document
28
+ defines it so that contract can be relied on across repos and over time.
29
+
30
+ ## Status of the artifact
31
+
32
+ It is a **committed / declared baseline** — the policy the repository currently
33
+ declares. It is **not** an *approved* policy. Approval is an independent record
34
+ (who authorised which `semantic_digest`, when) that lives in the control plane,
35
+ never in the repository. A repo can change the spec and its `.policy.json` in one commit;
36
+ that makes it the *candidate* policy, not the *authorised* one.
37
+
38
+ ## Shape (schema_version 2)
39
+
40
+ ```jsonc
41
+ {
42
+ "schema_version": 2, // version of THIS envelope format (not the policy)
43
+ "spec_version": 1, // the author-asserted policy version
44
+ "semantic_digest": "c90feaf9c431…", // sha256 over executable behaviour — what approval binds to
45
+ "presentation_digest": "a5765188d595…", // sha256 over wording — tracked, non-invalidating
46
+ "spec": { // the canonical, content-only spec
47
+ "name": "submission",
48
+ "title": "Submission moderation", // added in v0.4.0 (presentation; not in the semantic hash)
49
+ "version": 1,
50
+ "states": { "pending": "…", "approved": "…", … },
51
+ "initial": "pending",
52
+ "terminal": ["approved", "rejected", "expired"],
53
+ "fields": { "status": "str", "age_days": "decimal" },
54
+ "transitions": [
55
+ {
56
+ "id": "approve", // control_id, else the action name
57
+ "name": "approve",
58
+ "from": ["pending"], "to": "approved",
59
+ "roles": ["reviewer"],
60
+ "label": "…",
61
+ "guard": { "kind": "compare", "op": "eq",
62
+ "left": {"kind":"field","name":"error_count"},
63
+ "right": {"kind":"literal","type":"int","value":0} },
64
+ "opaque": {} // {name:version -> source-hash} for opaque guards
65
+ }
66
+ ],
67
+ "invariants": [
68
+ { "id": "status_declared", "name": "status_declared", "label": "…",
69
+ "condition": { … expr tree … }, "opaque": {} }
70
+ ]
71
+ }
72
+ }
73
+ ```
74
+
75
+ ## Guarantees the contract makes
76
+
77
+ - **Two digests, both deterministic and content-only.** Each is `sha256` over a
78
+ canonical serialisation (sorted keys, stable arrays, normalised `Decimal`).
79
+ `semantic_digest` covers **executable behaviour** — states, transitions,
80
+ roles, guard/invariant expressions, and **opaque-body** hashes; it is what
81
+ approval binds to, and bumping `spec_version` is keyed to it. `presentation_digest`
82
+ covers the **human-facing wording** — title, state descriptions, and
83
+ transition/invariant labels. A copy-edit moves `presentation_digest` only, so
84
+ a behavioural approval survives a reword; a behavioural change moves
85
+ `semantic_digest` (and almost always both). Splitting them is what makes
86
+ "wording vs behaviour" a mechanical check (`identity.change_kind`) instead of
87
+ a human judgement on every diff.
88
+ - **Conditions are data, not code.** `guard`/`condition` are expression trees
89
+ (compare + boolean), so a consumer can render, diff, and reason about them
90
+ without executing the application. The grammar is closed (see
91
+ `statespec-expressions.md`).
92
+ - **Opaque code is identified, not hidden.** A guard that escapes to Python
93
+ appears as `{"kind":"opaque","name":…,"version":…}` and its source hash is in
94
+ the transition/invariant's `opaque` map, so an opaque body change is visible
95
+ in the digest.
96
+ - **Control IDs are stable.** Each transition/invariant carries an `id`
97
+ (`control_id`, defaulting to the name) — the handle a control plane maps to an
98
+ owner and an approval requirement. It survives a label reword.
99
+
100
+ ## What a consumer should do, not do
101
+
102
+ - **Do**: read the artifact, compute/compare digests, diff two artifacts
103
+ semantically (see `identity.diff`), map `control_id`s to owners, and gate on
104
+ GitHub events.
105
+ - **Don't**: parse the application's models, import its code, or re-run the
106
+ guard functions. The artifact + GitHub events are sufficient.
107
+
108
+ ## Versioning
109
+
110
+ - `schema_version` versions the **envelope/format**. Bump it only when this
111
+ shape changes; a consumer keys its parser off it.
112
+ - `spec_version` is the **policy's** author-asserted version. Bump it when the
113
+ **`semantic_digest`** changes. `identity.diff` names the change; consuming
114
+ repos typically add a CI check that warns when the semantic digest changed
115
+ but the version did not (baseplate's `scripts/statespec.py diff` is the
116
+ reference). A wording-only change does not require a bump.
117
+ - The two are independent: a format migration doesn't touch policy versions, and
118
+ a policy change doesn't touch the format version.
119
+
120
+ ## Note on labels (the semantic/presentation split)
121
+
122
+ `spec` still includes labels/descriptions in full — a reword is never lost. But
123
+ identity is split: a reword moves `presentation_digest` only, leaving
124
+ `semantic_digest` (the digest approval binds to) untouched. So an editorial
125
+ change is **non-invalidating by default** — `diff` still names it ("transition
126
+ X: label changed") and a consumer routes it to the general policy owner for a
127
+ lightweight ack, rather than re-triggering control-owner approval. This was a
128
+ deliberate pre-v1 split, made while there were zero live approval bindings to
129
+ migrate; doing it after the control plane accumulated approvals would have been
130
+ a breaking re-binding. Mechanically: compare the two records with
131
+ `identity.change_kind` → `semantic` | `presentation` | `none`.