feat: migrate refimpls from draft-00 to draft-01 claim names

- Rename `par` to `pred` (predecessor) in types, serialization, tests
- Remove `pol`, `pol_decision` from core payload; move to `ect_ext`
- Remove `sub` from payload (not part of ECT spec)
- Update `typ` from `wimse-exec+jwt` to `exec+jwt` (accept both)
- Rename MaxParLength to MaxPredLength everywhere
- Update testdata, demos, READMEs with migration table
- All Go tests pass, all 56 Python tests pass (90% coverage)
This commit is contained in:
2026-04-03 10:55:58 +02:00
parent ba044f6626
commit 884d2dc836
33 changed files with 416 additions and 481 deletions

View File

@@ -1,6 +1,6 @@
# WIMSE ECT — Python Reference Implementation
Python reference implementation of [Execution Context Tokens (ECTs)](../../draft-nennemann-wimse-execution-context-00.txt) for WIMSE. Implements ECT creation (ES256), verification (Section 7), DAG validation (Section 6), and an in-memory audit ledger (Section 9).
Python reference implementation of [Execution Context Tokens (ECTs)](../../draft-nennemann-wimse-execution-context-01.txt) for WIMSE. Implements ECT creation (ES256), verification (Section 7), DAG validation (Section 6), and an in-memory audit ledger (Section 9).
## Layout
@@ -42,7 +42,6 @@ from ect import (
verify,
VerifyOptions,
MemoryLedger,
POL_DECISION_APPROVED,
)
cfg = load_config_from_env()
@@ -54,9 +53,11 @@ payload = Payload(
exp=int(time.time()) + 600,
jti="550e8400-e29b-41d4-a716-446655440000",
exec_act="review_spec",
par=[],
pol="policy_v1",
pol_decision=POL_DECISION_APPROVED,
pred=[],
ext={
"pol": "policy_v1",
"pol_decision": "approved",
},
)
compact = create(payload, key, cfg.create_options("agent-a-key"))
@@ -83,6 +84,16 @@ cd refimpl/python && python3 -m pytest tests/ -v
Unit tests require **90% coverage** minimum (`pytest` is configured with `--cov-fail-under=90` in `pyproject.toml`). Install dev deps: `pip install -e ".[dev]"`. Uncovered lines are mainly abstract base methods and a few verify branches that need manually built tokens.
## draft-01 claim changes
| -00 (previous) | -01 (current) | Notes |
|----------------|---------------|-------|
| `par` | `pred` | Predecessor task IDs |
| `pol`, `pol_decision` | removed (use `ect_ext`) | Policy claims moved to extension object |
| `sub` | not defined | Standard JWT claim, not part of ECT spec |
| `typ: wimse-exec+jwt` | `typ: exec+jwt` (preferred) | Both accepted for backward compat |
| `max_par_length` | `max_pred_length` | Renamed to match `pred` claim |
## Production configuration (environment)
Same env vars as the Go refimpl: `ECT_IAT_MAX_AGE_MINUTES`, `ECT_IAT_MAX_FUTURE_SEC`, `ECT_DEFAULT_EXPIRY_MIN`, `ECT_JTI_REPLAY_CACHE_SIZE`, `ECT_JTI_REPLAY_TTL_MIN`.

View File

@@ -11,7 +11,6 @@ from ect import (
verify,
VerifyOptions,
MemoryLedger,
POL_DECISION_APPROVED,
)
def main():
@@ -33,9 +32,11 @@ def main():
jti=root_jti,
wid="wf-demo-001",
exec_act="review_requirements_spec",
par=[],
pol="spec_review_policy_v2",
pol_decision=POL_DECISION_APPROVED,
pred=[],
ext={
"pol": "spec_review_policy_v2",
"pol_decision": "approved",
},
)
ect_a = create(payload_a, key_a, CreateOptions(key_id=kid_a))
print("Agent A created root ECT (jti=550e8400-..., review_requirements_spec)")
@@ -56,7 +57,7 @@ def main():
ledger.append(ect_a, parsed.payload)
print("Agent B verified root ECT and appended to ledger")
# 3) Agent B creates child ECT (par contains parent jti values per spec)
# 3) Agent B creates child ECT (pred contains predecessor jti values per spec)
key_b = generate_key()
kid_b = "agent-b-key"
child_jti = "550e8400-e29b-41d4-a716-446655440002"
@@ -68,12 +69,14 @@ def main():
jti=child_jti,
wid="wf-demo-001",
exec_act="implement_module",
par=[root_jti],
pol="coding_standards_v3",
pol_decision=POL_DECISION_APPROVED,
pred=[root_jti],
ext={
"pol": "coding_standards_v3",
"pol_decision": "approved",
},
)
ect_b = create(payload_b, key_b, CreateOptions(key_id=kid_b))
print("Agent B created child ECT (jti=550e8400-...002, implement_module, par=[parent jti])")
print("Agent B created child ECT (jti=550e8400-...002, implement_module, pred=[predecessor jti])")
# 4) Verify child ECT with DAG
def resolver_b(kid):

View File

@@ -1,13 +1,10 @@
# WIMSE Execution Context Tokens (ECT) — Python reference implementation
# draft-nennemann-wimse-execution-context-00
# draft-nennemann-wimse-execution-context-01
from ect.types import (
ECT_TYPE,
POL_DECISION_APPROVED,
POL_DECISION_REJECTED,
POL_DECISION_PENDING_HUMAN_REVIEW,
ECT_TYPE_LEGACY,
Payload,
valid_pol_decision,
)
from ect.create import create, generate_key, CreateOptions, default_create_options
from ect.verify import (
@@ -30,11 +27,8 @@ from ect.jti_cache import JTICache, new_jti_cache
__all__ = [
"ECT_TYPE",
"POL_DECISION_APPROVED",
"POL_DECISION_REJECTED",
"POL_DECISION_PENDING_HUMAN_REVIEW",
"ECT_TYPE_LEGACY",
"Payload",
"valid_pol_decision",
"create",
"generate_key",
"CreateOptions",

View File

@@ -10,9 +10,9 @@ from typing import Optional
import jwt
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
from ect.types import Payload, valid_pol_decision
from ect.types import ECT_TYPE, Payload
from ect.validate import (
DEFAULT_MAX_PAR_LENGTH,
DEFAULT_MAX_PRED_LENGTH,
validate_ext,
validate_hash_format,
valid_uuid,
@@ -25,7 +25,7 @@ class CreateOptions:
iat_max_age_sec: int = 900 # 15 min
default_expiry_sec: int = 600 # 10 min
validate_uuids: bool = False
max_par_length: int = 0 # 0 = no limit; use DEFAULT_MAX_PAR_LENGTH for 100
max_pred_length: int = 0 # 0 = no limit; use DEFAULT_MAX_PRED_LENGTH for 100
def default_create_options() -> CreateOptions:
@@ -46,22 +46,14 @@ def _validate_payload(p: Payload, opts: CreateOptions) -> None:
raise ValueError("ect: jti must be UUID format")
if p.wid and not valid_uuid(p.wid):
raise ValueError("ect: wid must be UUID format when set")
max_par = opts.max_par_length or 0
if max_par > 0 and len(p.par) > max_par:
raise ValueError("ect: par exceeds max length")
max_pred = opts.max_pred_length or 0
if max_pred > 0 and len(p.pred) > max_pred:
raise ValueError("ect: pred exceeds max length")
if p.inp_hash:
validate_hash_format(p.inp_hash)
if p.out_hash:
validate_hash_format(p.out_hash)
validate_ext(p.ext)
# pol/pol_decision OPTIONAL; if either set, both must be present and valid
if p.pol or p.pol_decision:
if not p.pol or not p.pol_decision:
raise ValueError("ect: pol and pol_decision must both be present when either is set")
if not valid_pol_decision(p.pol_decision):
raise ValueError(
"ect: pol_decision must be approved, rejected, or pending_human_review"
)
# compensation in ext per spec
if p.ext and p.ext.get("compensation_reason") and not p.ext.get("compensation_required"):
raise ValueError("ect: ext.compensation_reason requires ext.compensation_required true")
@@ -73,8 +65,7 @@ def create(
opts: CreateOptions,
) -> str:
"""Build and sign an ECT. Payload must have required claims; iat/exp can be 0 for defaults.
create() may modify the payload in place (iat, exp, sub, par) when filling defaults;
pass a copy if the original must stay unchanged.
create() works on a deep copy so the caller's payload is not modified.
"""
if not opts.key_id:
raise ValueError("ect: KeyID required")
@@ -87,16 +78,14 @@ def create(
payload.iat = now
if payload.exp == 0:
payload.exp = now + (opts.default_expiry_sec or 600)
if not payload.sub:
payload.sub = payload.iss
if payload.par is None:
payload.par = []
if payload.pred is None:
payload.pred = []
_validate_payload(payload, opts)
claims = payload.to_claims()
headers = {
"typ": "wimse-exec+jwt",
"typ": ECT_TYPE,
"alg": "ES256",
"kid": opts.key_id,
}

View File

@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ect.types import Payload
from ect.validate import DEFAULT_MAX_PAR_LENGTH
from ect.validate import DEFAULT_MAX_PRED_LENGTH
DEFAULT_CLOCK_SKEW_TOLERANCE = 30
DEFAULT_MAX_ANCESTOR_LIMIT = 10000
@@ -31,11 +31,11 @@ class DAGConfig:
self,
clock_skew_tolerance: int = DEFAULT_CLOCK_SKEW_TOLERANCE,
max_ancestor_limit: int = DEFAULT_MAX_ANCESTOR_LIMIT,
max_par_length: int = 0,
max_pred_length: int = 0,
):
self.clock_skew_tolerance = clock_skew_tolerance or DEFAULT_CLOCK_SKEW_TOLERANCE
self.max_ancestor_limit = max_ancestor_limit or DEFAULT_MAX_ANCESTOR_LIMIT
self.max_par_length = max_par_length or 0
self.max_pred_length = max_pred_length or 0
def default_dag_config() -> DAGConfig:
@@ -44,22 +44,22 @@ def default_dag_config() -> DAGConfig:
def _has_cycle(
target_tid: str,
parent_ids: list[str],
pred_ids: list[str],
store: ECTStore,
visited: set[str],
max_depth: int,
) -> bool:
if len(visited) >= max_depth:
return True
for parent_id in parent_ids:
if parent_id == target_tid:
for pred_id in pred_ids:
if pred_id == target_tid:
return True
if parent_id in visited:
if pred_id in visited:
continue
visited.add(parent_id)
parent = store.get_by_tid(parent_id)
if parent is not None:
if _has_cycle(target_tid, parent.par, store, visited, max_depth):
visited.add(pred_id)
pred = store.get_by_tid(pred_id)
if pred is not None:
if _has_cycle(target_tid, pred.pred, store, visited, max_depth):
return True
return False
@@ -69,29 +69,28 @@ def validate_dag(
store: ECTStore,
cfg: DAGConfig,
) -> None:
"""Section 6.2: uniqueness (by jti), parent existence, temporal ordering, acyclicity, parent policy."""
if cfg.max_par_length > 0 and len(payload.par) > cfg.max_par_length:
raise ValueError("ect: par exceeds max length")
"""Section 6.2: uniqueness (by jti), predecessor existence, temporal ordering, acyclicity, predecessor policy."""
if cfg.max_pred_length > 0 and len(payload.pred) > cfg.max_pred_length:
raise ValueError("ect: pred exceeds max length")
if store.contains(payload.jti, payload.wid or ""):
raise ValueError(f"ect: task ID (jti) already exists: {payload.jti}")
from ect.types import POL_DECISION_REJECTED, POL_DECISION_PENDING_HUMAN_REVIEW
for parent_id in payload.par:
parent = store.get_by_tid(parent_id)
if parent is None:
raise ValueError(f"ect: parent task not found: {parent_id}")
if parent.iat >= payload.iat + cfg.clock_skew_tolerance:
raise ValueError(f"ect: parent task not earlier than current: {parent_id}")
for pred_id in payload.pred:
pred = store.get_by_tid(pred_id)
if pred is None:
raise ValueError(f"ect: predecessor task not found: {pred_id}")
if pred.iat >= payload.iat + cfg.clock_skew_tolerance:
raise ValueError(f"ect: predecessor task not earlier than current: {pred_id}")
visited: set[str] = set()
if _has_cycle(payload.jti, payload.par, store, visited, cfg.max_ancestor_limit):
if _has_cycle(payload.jti, payload.pred, store, visited, cfg.max_ancestor_limit):
raise ValueError("ect: circular dependency or depth limit exceeded")
# Parent policy decision: only when parent has policy claims per spec
for parent_id in payload.par:
parent = store.get_by_tid(parent_id)
if parent and parent.has_policy_claims() and parent.pol_decision in (POL_DECISION_REJECTED, POL_DECISION_PENDING_HUMAN_REVIEW):
# Predecessor policy decision: only when predecessor has policy claims in ext per -01
for pred_id in payload.pred:
pred = store.get_by_tid(pred_id)
if pred and pred.has_policy_claims() and pred.pol_decision() in ("rejected", "pending_human_review"):
if not payload.compensation_required():
raise ValueError(
"ect: parent has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true"
"ect: predecessor has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true"
)

View File

@@ -23,7 +23,7 @@ class LedgerEntry:
task_id: str
agent_id: str
action: str
parents: list[str]
predecessors: list[str]
ect_jws: str
signature_verified: bool
verification_timestamp: float
@@ -70,7 +70,7 @@ class MemoryLedger(Ledger):
task_id=payload.jti,
agent_id=payload.iss,
action=payload.exec_act,
parents=list(payload.par) if payload.par else [],
predecessors=list(payload.pred) if payload.pred else [],
ect_jws=ect_jws,
signature_verified=True,
verification_timestamp=now,

View File

@@ -1,4 +1,4 @@
"""ECT payload and claim types per draft Section 4."""
"""ECT payload and claim types per draft-nennemann-wimse-ect-01 Section 4."""
from __future__ import annotations
@@ -6,19 +6,9 @@ import json
from dataclasses import dataclass, field
from typing import Any
ECT_TYPE = "wimse-exec+jwt"
POL_DECISION_APPROVED = "approved"
POL_DECISION_REJECTED = "rejected"
POL_DECISION_PENDING_HUMAN_REVIEW = "pending_human_review"
def valid_pol_decision(s: str) -> bool:
return s in (
POL_DECISION_APPROVED,
POL_DECISION_REJECTED,
POL_DECISION_PENDING_HUMAN_REVIEW,
)
# Preferred typ per -01; legacy accepted for backward compatibility.
ECT_TYPE = "exec+jwt"
ECT_TYPE_LEGACY = "wimse-exec+jwt"
def _audience_serialize(aud: list[str]) -> str | list[str]:
@@ -45,20 +35,15 @@ class Payload:
exp: int
jti: str
exec_act: str
par: list[str]
pol: str = ""
pol_decision: str = ""
sub: str = ""
pred: list[str] # predecessor jti values (renamed from par in -01)
wid: str = ""
pol_enforcer: str = ""
pol_timestamp: int = 0
inp_hash: str = ""
out_hash: str = ""
inp_classification: str = ""
ext: dict[str, Any] = field(default_factory=dict)
def to_claims(self) -> dict[str, Any]:
"""Export as JWT claims. Compensation in ext per spec."""
"""Export as JWT claims. Policy and compensation in ext per -01 spec."""
out: dict[str, Any] = {
"iss": self.iss,
"aud": _audience_serialize(self.aud),
@@ -66,20 +51,10 @@ class Payload:
"exp": self.exp,
"jti": self.jti,
"exec_act": self.exec_act,
"par": self.par,
"pred": self.pred,
}
if self.sub:
out["sub"] = self.sub
if self.wid:
out["wid"] = self.wid
if self.pol:
out["pol"] = self.pol
if self.pol_decision:
out["pol_decision"] = self.pol_decision
if self.pol_enforcer:
out["pol_enforcer"] = self.pol_enforcer
if self.pol_timestamp:
out["pol_timestamp"] = self.pol_timestamp
if self.inp_hash:
out["inp_hash"] = self.inp_hash
if self.out_hash:
@@ -87,13 +62,13 @@ class Payload:
if self.inp_classification:
out["inp_classification"] = self.inp_classification
if self.ext:
out["ext"] = dict(self.ext)
out["ect_ext"] = dict(self.ext)
return out
@classmethod
def from_claims(cls, claims: dict[str, Any]) -> Payload:
"""Build Payload from JWT claims. Compensation read from ext per spec."""
ext = claims.get("ext") or {}
"""Build Payload from JWT claims. Policy claims read from ext per -01 spec."""
ext = claims.get("ect_ext") or {}
return cls(
iss=claims["iss"],
aud=_audience_deserialize(claims["aud"]),
@@ -101,13 +76,8 @@ class Payload:
exp=int(claims["exp"]),
jti=claims["jti"],
exec_act=claims["exec_act"],
par=claims.get("par") or [],
pol=claims.get("pol", ""),
pol_decision=claims.get("pol_decision", ""),
sub=claims.get("sub", ""),
pred=claims.get("pred") or [],
wid=claims.get("wid", ""),
pol_enforcer=claims.get("pol_enforcer", ""),
pol_timestamp=int(claims.get("pol_timestamp") or 0),
inp_hash=claims.get("inp_hash", ""),
out_hash=claims.get("out_hash", ""),
inp_classification=claims.get("inp_classification", ""),
@@ -124,5 +94,13 @@ class Payload:
return bool(self.ext.get("compensation_required"))
def has_policy_claims(self) -> bool:
"""True if both pol and pol_decision are present (optional pair per spec)."""
return bool(self.pol and self.pol_decision)
"""True if both pol and pol_decision are present in ext (per -01, moved to extension)."""
if not self.ext:
return False
return bool(self.ext.get("pol")) and bool(self.ext.get("pol_decision"))
def pol_decision(self) -> str:
"""Return pol_decision from ext, or empty string."""
if not self.ext:
return ""
return str(self.ext.get("pol_decision", ""))

View File

@@ -9,7 +9,7 @@ from typing import Any
EXT_MAX_SIZE = 4096
EXT_MAX_DEPTH = 5
DEFAULT_MAX_PAR_LENGTH = 100
DEFAULT_MAX_PRED_LENGTH = 100
_UUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"

View File

@@ -10,7 +10,7 @@ from typing import Callable, Optional
import jwt
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
from ect.types import ECT_TYPE, Payload, valid_pol_decision
from ect.types import ECT_TYPE, ECT_TYPE_LEGACY, Payload
from ect.dag import ECTStore, DAGConfig, validate_dag
from ect.validate import validate_ext, validate_hash_format, valid_uuid
@@ -37,7 +37,7 @@ class VerifyOptions:
jti_seen: Optional[Callable[[str], bool]] = None
wit_subject: str = ""
validate_uuids: bool = False
max_par_length: int = 0 # 0 = no limit
max_pred_length: int = 0 # 0 = no limit
on_verify_attempt: Optional[Callable[[str, Optional[Exception]], None]] = None # (jti, err) for observability
@@ -83,8 +83,8 @@ def verify(compact: str, opts: VerifyOptions) -> ParsedECT:
def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str], None]) -> ParsedECT:
header = jwt.get_unverified_header(compact)
typ = header.get("typ") or ""
# Constant-time comparison for typ
if not hmac.compare_digest(typ, ECT_TYPE):
# Constant-time comparison for typ; accept both preferred and legacy values
if not hmac.compare_digest(typ, ECT_TYPE) and not hmac.compare_digest(typ, ECT_TYPE_LEGACY):
raise ValueError("ect: invalid typ parameter")
alg = header.get("alg")
if alg in ("none", "HS256", "HS384", "HS512"):
@@ -114,8 +114,8 @@ def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str],
set_log_jti(payload.jti)
validate_ext(payload.ext)
if opts.max_par_length > 0 and len(payload.par) > opts.max_par_length:
raise ValueError("ect: par exceeds max length")
if opts.max_pred_length > 0 and len(payload.pred) > opts.max_pred_length:
raise ValueError("ect: pred exceeds max length")
if opts.validate_uuids:
if not valid_uuid(payload.jti):
raise ValueError("ect: jti must be UUID format")
@@ -139,17 +139,11 @@ def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str],
if payload.iat > now + opts.iat_max_future_sec:
raise ValueError("ect: iat in the future")
# Required claims per spec: jti, exec_act, par. par may be set to [] when missing (from_claims already uses []).
# Required claims per spec: jti, exec_act, pred. pred may be set to [] when missing (from_claims already uses []).
if not payload.jti or not payload.exec_act:
raise ValueError("ect: missing required claims (jti, exec_act, par)")
if payload.par is None:
payload.par = []
# If pol or pol_decision present, both must be present and valid
if payload.pol or payload.pol_decision:
if not payload.pol or not payload.pol_decision:
raise ValueError("ect: pol and pol_decision must both be present when either is set")
if not valid_pol_decision(payload.pol_decision):
raise ValueError("ect: invalid pol_decision value")
raise ValueError("ect: missing required claims (jti, exec_act, pred)")
if payload.pred is None:
payload.pred = []
if opts.store is not None and opts.dag is not None:
validate_dag(payload, opts.store, opts.dag)

View File

@@ -1 +1 @@
{"iss":"spiffe://example.com/agent/clinical","sub":"spiffe://example.com/agent/clinical","aud":"spiffe://example.com/agent/safety","iat":1772064150,"exp":1772064750,"jti":"7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8","wid":"a0b1c2d3-e4f5-6789-abcd-ef0123456789","exec_act":"recommend_treatment","par":[],"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"}
{"iss":"spiffe://example.com/agent/clinical","aud":"spiffe://example.com/agent/safety","iat":1772064150,"exp":1772064750,"jti":"7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8","wid":"a0b1c2d3-e4f5-6789-abcd-ef0123456789","exec_act":"recommend_treatment","pred":[],"ect_ext":{"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"}}

View File

@@ -13,7 +13,6 @@ from ect import (
CreateOptions,
verify,
VerifyOptions,
POL_DECISION_APPROVED,
)
@@ -27,9 +26,7 @@ def test_create_roundtrip():
exp=now + 600,
jti="e4f5a6b7-c8d9-0123-ef01-234567890abc",
exec_act="review_spec",
par=[],
pol="spec_review_policy_v2",
pol_decision=POL_DECISION_APPROVED,
pred=[],
)
compact = create(payload, key, CreateOptions(key_id="agent-a-key-1"))
assert compact

View File

@@ -4,7 +4,7 @@ import time
import pytest
from ect import Payload, create, generate_key, CreateOptions, default_create_options, POL_DECISION_APPROVED
from ect import Payload, create, generate_key, CreateOptions, default_create_options
def test_default_create_options():
@@ -14,7 +14,7 @@ def test_default_create_options():
def test_create_errors():
key = generate_key()
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[], pol="p", pol_decision=POL_DECISION_APPROVED)
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[])
with pytest.raises(ValueError, match="KeyID|required"):
create(p, key, CreateOptions(key_id=""))
with pytest.raises((ValueError, TypeError, AttributeError)):
@@ -26,7 +26,7 @@ def test_create_optional_pol():
now = int(time.time())
p = Payload(
iss="iss", aud=["a"], iat=now, exp=now + 3600,
jti="jti-nopol", exec_act="act", par=[],
jti="jti-nopol", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
assert compact
@@ -34,7 +34,7 @@ def test_create_optional_pol():
def test_create_validation_errors():
key = generate_key()
base = dict(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[])
base = dict(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[])
with pytest.raises(ValueError, match="iss"):
create(Payload(**{**base, "iss": ""}), key, CreateOptions(key_id="k"))
with pytest.raises(ValueError, match="aud"):
@@ -43,16 +43,12 @@ def test_create_validation_errors():
create(Payload(**{**base, "jti": ""}), key, CreateOptions(key_id="k"))
with pytest.raises(ValueError, match="exec_act"):
create(Payload(**{**base, "exec_act": ""}), key, CreateOptions(key_id="k"))
with pytest.raises(ValueError, match="pol and pol_decision"):
create(Payload(**{**base, "pol": "p", "pol_decision": ""}), key, CreateOptions(key_id="k"))
with pytest.raises(ValueError, match="pol_decision"):
create(Payload(**{**base, "pol": "p", "pol_decision": "bad"}), key, CreateOptions(key_id="k"))
def test_create_ext_compensation_reason_requires_required():
key = generate_key()
p = Payload(
iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[],
iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[],
ext={"compensation_reason": "rollback", "compensation_required": False},
)
with pytest.raises(ValueError, match="compensation_required"):
@@ -61,7 +57,7 @@ def test_create_ext_compensation_reason_requires_required():
def test_create_zero_expiry_uses_default():
key = generate_key()
p = Payload(iss="i", aud=["a"], iat=0, exp=0, jti="j", exec_act="e", par=[])
p = Payload(iss="i", aud=["a"], iat=0, exp=0, jti="j", exec_act="e", pred=[])
compact = create(p, key, CreateOptions(key_id="k", default_expiry_sec=300))
assert compact
# create() works on a copy; decode the token to verify defaults were applied
@@ -73,17 +69,17 @@ def test_create_zero_expiry_uses_default():
def test_create_validate_uuids_rejects_non_uuid_jti():
key = generate_key()
now = int(time.time())
p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="not-a-uuid", exec_act="e", par=[])
p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="not-a-uuid", exec_act="e", pred=[])
with pytest.raises(ValueError, match="jti must be UUID"):
create(p, key, CreateOptions(key_id="k", validate_uuids=True))
def test_create_max_par_length():
def test_create_max_pred_length():
key = generate_key()
now = int(time.time())
p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", par=["p1", "p2"])
with pytest.raises(ValueError, match="par exceeds max length"):
create(p, key, CreateOptions(key_id="k", max_par_length=1))
p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", pred=["p1", "p2"])
with pytest.raises(ValueError, match="pred exceeds max length"):
create(p, key, CreateOptions(key_id="k", max_pred_length=1))
def test_create_ext_size_rejected():
@@ -91,7 +87,7 @@ def test_create_ext_size_rejected():
key = generate_key()
now = int(time.time())
p = Payload(
iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", par=[],
iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", pred=[],
ext={"x": "y" * (EXT_MAX_SIZE - 5)},
)
with pytest.raises(ValueError, match="ext exceeds max size"):

View File

@@ -4,7 +4,7 @@ import time
import pytest
from ect import Payload, MemoryLedger, validate_dag, default_dag_config, POL_DECISION_APPROVED
from ect import Payload, MemoryLedger, validate_dag, default_dag_config
def test_validate_dag_root():
@@ -16,9 +16,7 @@ def test_validate_dag_root():
exp=0,
jti="jti-001",
exec_act="",
par=[],
pol="",
pol_decision=POL_DECISION_APPROVED,
pred=[],
wid="wf-1",
)
validate_dag(payload, store, default_dag_config())
@@ -33,9 +31,7 @@ def test_validate_dag_duplicate_jti():
exp=0,
jti="jti-001",
exec_act="a",
par=[],
pol="p",
pol_decision=POL_DECISION_APPROVED,
pred=[],
wid="wf-1",
)
store.append("dummy-jws", p)
@@ -46,16 +42,14 @@ def test_validate_dag_duplicate_jti():
exp=0,
jti="jti-001",
exec_act="",
par=[],
pol="",
pol_decision=POL_DECISION_APPROVED,
pred=[],
wid="wf-1",
)
with pytest.raises(ValueError, match="task ID.*already exists"):
validate_dag(payload, store, default_dag_config())
def test_validate_dag_parent_exists():
def test_validate_dag_pred_exists():
store = MemoryLedger()
now = int(time.time())
p = Payload(
@@ -65,9 +59,7 @@ def test_validate_dag_parent_exists():
exp=now + 600,
jti="jti-001",
exec_act="a",
par=[],
pol="p",
pol_decision=POL_DECISION_APPROVED,
pred=[],
wid="wf-1",
)
store.append("jws1", p)
@@ -78,15 +70,13 @@ def test_validate_dag_parent_exists():
exp=now + 600,
jti="jti-002",
exec_act="b",
par=["jti-001"],
pol="p",
pol_decision=POL_DECISION_APPROVED,
pred=["jti-001"],
wid="wf-1",
)
validate_dag(payload, store, default_dag_config())
def test_validate_dag_parent_not_found():
def test_validate_dag_pred_not_found():
store = MemoryLedger()
now = int(time.time())
payload = Payload(
@@ -96,26 +86,24 @@ def test_validate_dag_parent_not_found():
exp=now + 600,
jti="jti-002",
exec_act="",
par=["jti-missing"],
pol="",
pol_decision=POL_DECISION_APPROVED,
pred=["jti-missing"],
)
with pytest.raises(ValueError, match="parent task not found"):
with pytest.raises(ValueError, match="predecessor task not found"):
validate_dag(payload, store, default_dag_config())
def test_validate_dag_parent_policy_rejected_requires_compensation():
from ect import POL_DECISION_REJECTED
def test_validate_dag_pred_policy_rejected_requires_compensation():
store = MemoryLedger()
now = int(time.time())
p = Payload(
iss="x", aud=["y"], iat=now - 60, exp=now + 600,
jti="jti-rej", exec_act="a", par=[], pol="p", pol_decision=POL_DECISION_REJECTED, wid="wf-1",
jti="jti-rej", exec_act="a", pred=[], wid="wf-1",
ext={"pol": "p", "pol_decision": "rejected"},
)
store.append("jws1", p)
payload = Payload(
iss="", aud=[], iat=now, exp=now + 600,
jti="jti-child", exec_act="b", par=["jti-rej"], pol="p", pol_decision=POL_DECISION_APPROVED, wid="wf-1",
jti="jti-child", exec_act="b", pred=["jti-rej"], wid="wf-1",
)
with pytest.raises(ValueError, match="compensation"):
validate_dag(payload, store, default_dag_config())

View File

@@ -4,12 +4,12 @@ import time
import pytest
from ect import Payload, MemoryLedger, ErrTaskIDExists, POL_DECISION_APPROVED
from ect import Payload, MemoryLedger, ErrTaskIDExists
def test_ledger_append_and_get():
m = MemoryLedger()
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="act", par=[])
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="act", pred=[])
seq = m.append("jws1", p)
assert seq == 1
assert m.get_by_tid("j1").jti == "j1"
@@ -17,7 +17,7 @@ def test_ledger_append_and_get():
def test_ledger_err_task_id_exists():
m = MemoryLedger()
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j-dup", exec_act="e", par=[])
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j-dup", exec_act="e", pred=[])
m.append("jws1", p)
with pytest.raises(ErrTaskIDExists):
m.append("jws2", p)
@@ -25,7 +25,7 @@ def test_ledger_err_task_id_exists():
def test_ledger_contains_wid():
m = MemoryLedger()
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="e", par=[], wid="wf1")
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="e", pred=[], wid="wf1")
m.append("jws", p)
assert m.contains("j1", "") is True
assert m.contains("j1", "wf1") is True

View File

@@ -2,62 +2,63 @@
import pytest
from ect import Payload, POL_DECISION_APPROVED
from ect.types import valid_pol_decision
def test_valid_pol_decision():
assert valid_pol_decision("approved") is True
assert valid_pol_decision("rejected") is True
assert valid_pol_decision("pending_human_review") is True
assert valid_pol_decision("invalid") is False
from ect import Payload
def test_payload_contains_audience():
p = Payload(iss="", aud=["a", "b"], iat=0, exp=0, jti="", exec_act="", par=[])
p = Payload(iss="", aud=["a", "b"], iat=0, exp=0, jti="", exec_act="", pred=[])
assert p.contains_audience("a") is True
assert p.contains_audience("c") is False
def test_payload_compensation_required():
p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", par=[])
p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[])
assert p.compensation_required() is False
p.ext = {"compensation_required": True}
assert p.compensation_required() is True
def test_payload_has_policy_claims():
p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", par=[], pol="p", pol_decision=POL_DECISION_APPROVED)
p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[],
ext={"pol": "p", "pol_decision": "approved"})
assert p.has_policy_claims() is True
p.pol = ""
p.ext = {"pol_decision": "approved"}
assert p.has_policy_claims() is False
p.ext = None
assert p.has_policy_claims() is False
def test_payload_pol_decision():
p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[],
ext={"pol_decision": "rejected"})
assert p.pol_decision() == "rejected"
p.ext = None
assert p.pol_decision() == ""
def test_payload_to_claims_optional():
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[], wid="wf")
p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[], wid="wf")
claims = p.to_claims()
assert claims["wid"] == "wf"
assert "ext" not in claims or not claims.get("ext")
assert "ect_ext" not in claims or not claims.get("ect_ext")
def test_payload_from_claims_aud_string():
claims = {"iss": "i", "aud": "single", "iat": 1, "exp": 2, "jti": "j", "exec_act": "e", "par": []}
claims = {"iss": "i", "aud": "single", "iat": 1, "exp": 2, "jti": "j", "exec_act": "e", "pred": []}
p = Payload.from_claims(claims)
assert p.aud == ["single"]
def test_payload_to_claims_all_optional():
p = Payload(
iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[],
sub="s", wid="w", pol="p", pol_decision="approved", pol_enforcer="e",
pol_timestamp=1, inp_hash="h", out_hash="o", inp_classification="c",
iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[],
wid="w", inp_hash="h", out_hash="o", inp_classification="c",
ext={"pol": "p", "pol_decision": "approved"},
)
claims = p.to_claims()
assert claims["sub"] == "s"
assert claims["wid"] == "w"
assert claims["pol"] == "p"
assert claims["pol_enforcer"] == "e"
assert claims["pol_timestamp"] == 1
assert claims["inp_hash"] == "h"
assert claims["out_hash"] == "o"
assert claims["inp_classification"] == "c"
assert claims["ect_ext"]["pol"] == "p"
assert claims["ect_ext"]["pol_decision"] == "approved"

View File

@@ -13,7 +13,6 @@ from ect import (
verify,
VerifyOptions,
default_verify_options,
POL_DECISION_APPROVED,
)
@@ -22,7 +21,7 @@ def test_parse():
now = int(time.time())
p = Payload(
iss="iss", aud=["a"], iat=now, exp=now + 3600,
jti="jti-parse", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED,
jti="jti-parse", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
parsed = parse(compact)
@@ -41,7 +40,7 @@ def test_verify_expired():
now = int(time.time())
p = Payload(
iss="iss", aud=["v"], iat=now - 3600, exp=now - 60,
jti="jti-exp", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED,
jti="jti-exp", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
@@ -54,7 +53,7 @@ def test_verify_replay():
now = int(time.time())
p = Payload(
iss="iss", aud=["v"], iat=now, exp=now + 3600,
jti="jti-replay", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED,
jti="jti-replay", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
@@ -76,7 +75,7 @@ def test_verify_audience_mismatch():
now = int(time.time())
p = Payload(
iss="iss", aud=["other"], iat=now, exp=now + 3600,
jti="jti-a", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED,
jti="jti-a", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
@@ -89,7 +88,7 @@ def test_verify_wit_subject_mismatch():
now = int(time.time())
p = Payload(
iss="wrong-iss", aud=["v"], iat=now, exp=now + 3600,
jti="jti-w", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED,
jti="jti-w", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
@@ -104,7 +103,7 @@ def test_verify_iat_too_old():
now = int(time.time())
p = Payload(
iss="iss", aud=["v"], iat=now - 2000, exp=now + 3600,
jti="jti-old", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED,
jti="jti-old", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
@@ -119,7 +118,7 @@ def test_verify_unknown_key():
now = int(time.time())
p = Payload(
iss="iss", aud=["v"], iat=now, exp=now + 3600,
jti="jti-k", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED,
jti="jti-k", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda kid: None # unknown key
@@ -132,7 +131,7 @@ def test_verify_resolve_key_required():
now = int(time.time())
p = Payload(
iss="iss", aud=["v"], iat=now, exp=now + 3600,
jti="jti-r", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED,
jti="jti-r", exec_act="act", pred=[],
)
compact = create(p, key, CreateOptions(key_id="kid"))
with pytest.raises(ValueError, match="ResolveKey"):
@@ -146,7 +145,7 @@ def test_verify_with_dag():
now = int(time.time())
root = Payload(
iss="iss", aud=["v"], iat=now, exp=now + 3600,
jti="jti-root", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED,
jti="jti-root", exec_act="act", pred=[],
)
compact_root = create(root, key, CreateOptions(key_id="kid"))
resolver = lambda kid: key.public_key() if kid == "kid" else None
@@ -155,7 +154,7 @@ def test_verify_with_dag():
ledger.append(compact_root, parsed.payload)
child = Payload(
iss="iss", aud=["v"], iat=now + 1, exp=now + 3600,
jti="jti-child", exec_act="act2", par=["jti-root"], pol="p", pol_decision=POL_DECISION_APPROVED,
jti="jti-child", exec_act="act2", pred=["jti-root"],
)
compact_child = create(child, key, CreateOptions(key_id="kid"))
parsed2 = verify(compact_child, opts)
@@ -166,7 +165,7 @@ def test_on_verify_attempt_callback():
"""Observability: on_verify_attempt is called with jti and error (or None)."""
key = generate_key()
now = int(time.time())
p = Payload(iss="i", aud=["v"], iat=now, exp=now + 3600, jti="jti-obs", exec_act="a", par=[])
p = Payload(iss="i", aud=["v"], iat=now, exp=now + 3600, jti="jti-obs", exec_act="a", pred=[])
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda k: key.public_key() if k == "kid" else None
seen = []
@@ -183,7 +182,7 @@ def test_on_verify_attempt_callback():
def test_on_verify_attempt_called_on_failure():
key = generate_key()
now = int(time.time())
p = Payload(iss="i", aud=["v"], iat=now, exp=now - 1, jti="jti-fail", exec_act="a", par=[])
p = Payload(iss="i", aud=["v"], iat=now, exp=now - 1, jti="jti-fail", exec_act="a", pred=[])
compact = create(p, key, CreateOptions(key_id="kid"))
resolver = lambda k: key.public_key() if k == "kid" else None
seen = []
@@ -193,5 +192,3 @@ def test_on_verify_attempt_called_on_failure():
assert len(seen) == 1
assert seen[0][0] == "jti-fail"
assert seen[0][1] is not None