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.
- statespec-0.4.0/.github/workflows/ci.yml +21 -0
- statespec-0.4.0/.gitignore +7 -0
- statespec-0.4.0/CHANGELOG.md +20 -0
- statespec-0.4.0/LICENSE +21 -0
- statespec-0.4.0/PKG-INFO +117 -0
- statespec-0.4.0/README.md +98 -0
- statespec-0.4.0/docs/policy-artifact-contract.md +131 -0
- statespec-0.4.0/docs/statespec-expressions.md +446 -0
- statespec-0.4.0/pyproject.toml +38 -0
- statespec-0.4.0/src/statespec/__init__.py +57 -0
- statespec-0.4.0/src/statespec/core.py +401 -0
- statespec-0.4.0/src/statespec/expr.py +473 -0
- statespec-0.4.0/src/statespec/identity.py +283 -0
- statespec-0.4.0/src/statespec/render.py +132 -0
- statespec-0.4.0/tests/fixtures/golden.policy.json +87 -0
- statespec-0.4.0/tests/test_contract_golden.py +70 -0
- statespec-0.4.0/tests/test_core.py +184 -0
- statespec-0.4.0/tests/test_expr.py +174 -0
- statespec-0.4.0/tests/test_identity.py +139 -0
|
@@ -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,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.
|
statespec-0.4.0/LICENSE
ADDED
|
@@ -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.
|
statespec-0.4.0/PKG-INFO
ADDED
|
@@ -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`.
|