diff --git a/SESSION_FOCUS.md b/SESSION_FOCUS.md index 3a833af2..90b7d92d 100644 --- a/SESSION_FOCUS.md +++ b/SESSION_FOCUS.md @@ -2,7 +2,7 @@ *Current sprint, SDK status, and active work. Updated by operator and autonomous sessions.* -*Last updated: 2026-05-13 (Sprint 48)* +*Last updated: 2026-05-14 (Sprint 50)* --- @@ -10,6 +10,18 @@ **See `docs/SPRINT.md` for full sprint plan and task details.** Do not duplicate sprint content here — SPRINT.md is the source of truth for task scope, status, and dependencies. +### Sprint 50 Summary: Add SocietyRole + RoleAssignment to Python SDK (COMPLETE) + +| Task | Status | Notes | +|------|--------|-------| +| T1: SocietyRole + RoleAssignment + bootstrap_society_roles | DONE | Implements Sprint 49 audit P1+P2+P3. New `web4/role.py` module: `SocietyRole` enum (7 base-mandatory + 2 context-mandatory), `RoleAssignment` dataclass (role-LCT binding, T3/V3, rotation, multi-holder, to_dict/from_dict), `bootstrap_society_roles()` (solo-founder genesis). 4 new exports (368 total), 43 new tests (2656 total), 1 new module. Cross-language parity with `web4-core/src/role.rs`. | + +### Sprint 49 Summary: Cross-Language Society/Role/ATP/R6 Alignment Audit (COMPLETE) + +| Task | Status | Notes | +|------|--------|-------| +| T1: Cross-language alignment audit for Society/Role/ATP/R6 | DONE | 14 items: 1 CRITICAL (Python SDK missing SocietyRole), 3 HIGH, 3 MEDIUM, 4 LOW. Prioritized fix queue P1-P7. P1-P3 resolved by Sprint 50. P4 needs operator decision (MetabolicState). P5 now unblocked. | + ### Sprint 48 Summary: Parameter Governance Index (COMPLETE) | Task | Status | Notes | @@ -183,10 +195,10 @@ See `docs/SPRINT.md` for full history. Highlights: JSON-LD serialization for all ## SDK Status - **Version**: 0.26.0 -- **Modules**: 22 library modules + MCP server entry point (trust, lct, atp, federation, r6, mrh, acp, dictionary, entity, capability, errors, metabolic, binding, society, reputation, security, protocol, mcp, attestation, validation, deserialize, generate, mcp_server) -- **Tests**: 2613 passing (97.8% coverage) +- **Modules**: 23 library modules + MCP server entry point (trust, lct, atp, federation, r6, mrh, acp, dictionary, entity, capability, errors, metabolic, binding, society, role, reputation, security, protocol, mcp, attestation, validation, deserialize, generate, mcp_server) +- **Tests**: 2656 passing - **CLI**: `web4 info/validate/list-schemas/roundtrip/generate/selftest/trust` (7 subcommands) -- **Exports**: 364 symbols via `web4/__init__.py` +- **Exports**: 368 symbols via `web4/__init__.py` - **from_dict()**: 58 classmethods across 10 modules — all classes with to_dict()/as_dict() have matching from_dict() - **Dispatcher**: 23 types via `web4.from_jsonld()` (19 class-based + 3 function-based + TrustQuery) - **Generator**: 23 types via `web4.generate()` — minimal valid JSON-LD documents @@ -244,10 +256,11 @@ Work undertaken in early 2026 toward what was at the time a planned ARIA grant s ## Open PRs -Sprint 49 T1 PR pending. +Sprint 50 T1 PR pending. ### Closed PRs (recent) +- PR #184 MERGED — Sprint 49 T1: Cross-language Society/Role/ATP/R6 alignment audit - PR #183 MERGED — Sprint 48 T1: Parameter governance index - PR #182 MERGED — Sprint 47 T1: Cross-language T3/V3 alignment audit - PR #181 MERGED — Sprint 46 T1: Clarify CI canonicity (audit item #10) @@ -259,7 +272,7 @@ Sprint 49 T1 PR pending. ## Completeness Summary -- All 49 sprints COMPLETE (Sprints 1-49, all merged or PR pending) +- All 50 sprints COMPLETE (Sprints 1-50, all merged or PR pending) - All 9 JSON-LD schemas with cross-language validation vectors (278 total, in pytest) - All `to_jsonld()` functions have `from_jsonld()` inverses (API symmetry complete) - All `to_dict()`/`as_dict()` methods have `from_dict()` inverses (58 round-trip methods total) @@ -270,9 +283,9 @@ Sprint 49 T1 PR pending. - MCP server: 8 tools exposing SDK data operations + behavioral trust/reputation resolution to MCP clients - TrustQuery: to_jsonld() for dispatcher + to_dict() for schema validation (trust-query.schema.json) - `process_action_outcome()` — action consequence pipeline composing R7Action + ReputationEngine + TrustProfile + ATPAccount -- All 22 submodules have `__all__` declarations, 364 root exports +- All 23 submodules have `__all__` declarations, 368 root exports - All public methods have docstrings and return type annotations -- `mypy --strict` passes with 0 errors across 25 source files +- `mypy --strict` passes with 0 errors across 26 source files - Test coverage: 97.8% overall (4 modules at 100%, 16 at 95%+, __main__.py at 90.6%) - Schema validation via `web4.validation.validate()` with `pip install web4[validation]` - CLI via `web4 info/validate/list-schemas/roundtrip/generate/selftest/trust` (7 subcommands) @@ -285,10 +298,11 @@ Sprint 49 T1 PR pending. --- -- **web4-core Society/Role/ATP/R6 alignment**: Cross-language audit identified 14 items (1 CRITICAL: Python SDK missing SocietyRole, 3 HIGH, 3 MEDIUM, 4 LOW) — see `docs/audits/cross-language-society-role-atp-r6-alignment-2026-05-14.md` +- **Society Roles added**: `SocietyRole` enum + `RoleAssignment` dataclass + `bootstrap_society_roles()` — resolves CRITICAL audit finding (P1), HIGH role-LCT binding (P2), and HIGH solo-founder genesis (P3) +- **web4-core Society/Role/ATP/R6 alignment**: Cross-language audit identified 14 items (1 CRITICAL: Python SDK missing SocietyRole, 3 HIGH, 3 MEDIUM, 4 LOW) — see `docs/audits/cross-language-society-role-atp-r6-alignment-2026-05-14.md`. P1-P3 resolved by Sprint 50. P4 (MetabolicState) and P7 (role integration) need operator decisions. P5 (validate_minimum_viable) now unblocked. - **web4-trust-core T3/V3 alignment**: Cross-language T3/V3 audit identified 8 divergences (1 CRITICAL, 4 HIGH) between Rust/WASM and spec/Python SDK — see `docs/audits/cross-language-t3v3-alignment-2026-05-13.md` - **Parameter governance**: All trust/value/energy parameters classified into three tiers (protocol-invariant, society-configurable, simulation-only) — see `web4-standard/core-spec/t3-v3-tensors.md` §10 --- -*Updated by autonomous session, 2026-05-14 (Sprint 49 — cross-language Society/Role/ATP/R6 audit)* +*Updated by autonomous session, 2026-05-14 (Sprint 50 — SocietyRole + RoleAssignment added to Python SDK)* diff --git a/docs/SPRINT.md b/docs/SPRINT.md index 1f6b4935..bf869909 100644 --- a/docs/SPRINT.md +++ b/docs/SPRINT.md @@ -1,12 +1,49 @@ # Web4 Sprint Plan **Created**: 2026-03-14 -**Updated**: 2026-05-14 (Sprint 49) +**Updated**: 2026-05-14 (Sprint 50) **Phase**: Development **Track**: web4 (Legion) --- +## Sprint 50: Add SocietyRole + RoleAssignment to Python SDK (2026-05-14) + +Implements the top 3 items from the Sprint 49 cross-language audit fix queue: +P1 (CRITICAL: add SocietyRole enum), P2 (HIGH: RoleAssignment with role-LCT +binding), P3 (HIGH: solo-founder bootstrap). Achieves cross-language parity +with `web4-core/src/role.rs` for the core role types. + +### T1: SocietyRole enum + RoleAssignment dataclass + bootstrap_society_roles() +**Status**: DONE +**Completed**: 2026-05-14 +**Authorized by**: Sprint 49 audit fix queue P1+P2+P3. Policy-reviewed and approved. +**Scope**: +New `web4/role.py` module implementing: +- `SocietyRole` enum: 7 base-mandatory roles (Sovereign, LawOracle, PolicyEntity, + Treasurer, Administrator, Archivist, Citizen) + 2 context-mandatory (Witness, + Auditor). `str` mixin for JSON-friendly serialization. `is_base_mandatory` + property, `description` property. +- `RoleAssignment` dataclass: role-LCT binding (authority binds to role, not + filling entity), T3/V3 per role, `rotate()` (entity rotation preserving + role-LCT), `add_holder()`/`remove_holder()` (committee/federation pattern), + `is_authorized()`, `to_dict()`/`from_dict()` round-trip serialization. +- `bootstrap_society_roles()`: solo-founder genesis per `inter-society-protocol.md` + §2.1 — creates 7 base-mandatory role assignments with the founder filling every + role. Custom `role_lct_factory` support. Resolves the `len(founders) >= 2` + contradiction in `create_society()`. +- `BASE_MANDATORY_ROLES` constant: ordered list of the 7 base-mandatory roles. + +**Result**: 4 new exports (368 total), 43 new tests (2656 total), 1 new module +(23 total + MCP server). mypy --strict clean, ruff lint/format clean. Cross-language +parity with `web4-core/src/role.rs::SocietyRole` and `RoleAssignment`. + +**Remaining from audit**: P4 (MetabolicState reconciliation — needs operator +decision), P5 (validate_minimum_viable — depends on P1, now unblocked), P6 +(Constraint hard flag), P7 (SocietyState integration — needs operator decision). + +--- + ## Sprint 49: Cross-Language Society/Role/ATP/R6 Alignment Audit (2026-05-14) Operator commits `82438958` and `8857ab09` added 4 new Rust modules to `web4-core` diff --git a/web4-standard/implementation/sdk/tests/test_role.py b/web4-standard/implementation/sdk/tests/test_role.py new file mode 100644 index 00000000..7ea2fe88 --- /dev/null +++ b/web4-standard/implementation/sdk/tests/test_role.py @@ -0,0 +1,494 @@ +""" +Tests for web4.role — society role taxonomy per society-roles.md. + +Tests cover: +1. SocietyRole enum — members, values, base-mandatory classification +2. SocietyRole descriptions — all roles have descriptions +3. RoleAssignment — construction, defaults, T3/V3 +4. RoleAssignment.rotate() — entity rotation preserves role-LCT +5. RoleAssignment.add_holder() — committee/federation pattern +6. RoleAssignment.remove_holder() — holder removal +7. RoleAssignment.is_authorized() — primary + additional holders +8. RoleAssignment.to_dict() / from_dict() — round-trip serialization +9. bootstrap_society_roles() — solo-founder genesis produces 7 role assignments +10. bootstrap_society_roles() — custom role_lct_factory +11. BASE_MANDATORY_ROLES constant — correct count and membership +""" + +from web4.role import ( + BASE_MANDATORY_ROLES, + RoleAssignment, + SocietyRole, + bootstrap_society_roles, +) +from web4.trust import T3, V3 + +# ── SocietyRole enum ──────────────────────────────────────────── + + +class TestSocietyRole: + """Tests for the SocietyRole enum.""" + + def test_has_seven_base_mandatory(self) -> None: + """Spec requires exactly 7 base-mandatory roles.""" + base = [r for r in SocietyRole if r.is_base_mandatory] + assert len(base) == 7 + + def test_base_mandatory_members(self) -> None: + """All 7 spec-defined base-mandatory roles are present.""" + expected = { + "sovereign", + "law_oracle", + "policy_entity", + "treasurer", + "administrator", + "archivist", + "citizen", + } + actual = {r.value for r in SocietyRole if r.is_base_mandatory} + assert actual == expected + + def test_context_mandatory_not_base(self) -> None: + """Witness and Auditor are context-mandatory, not base-mandatory.""" + assert not SocietyRole.WITNESS.is_base_mandatory + assert not SocietyRole.AUDITOR.is_base_mandatory + + def test_total_enum_members(self) -> None: + """9 total roles: 7 base-mandatory + 2 context-mandatory.""" + assert len(SocietyRole) == 9 + + def test_string_values(self) -> None: + """All roles have snake_case string values for JSON serialization.""" + for role in SocietyRole: + assert isinstance(role.value, str) + assert role.value == role.value.lower() + + def test_str_enum_mixin(self) -> None: + """SocietyRole is a str enum — value is accessible as string.""" + assert SocietyRole.SOVEREIGN.value == "sovereign" + assert SocietyRole("sovereign") == SocietyRole.SOVEREIGN + + def test_all_roles_have_descriptions(self) -> None: + """Every role has a non-empty human-readable description.""" + for role in SocietyRole: + desc = role.description + assert isinstance(desc, str) + assert len(desc) > 10 + + def test_sovereign_description(self) -> None: + assert "charter" in SocietyRole.SOVEREIGN.description.lower() + + def test_treasurer_description(self) -> None: + assert ( + "treasury" in SocietyRole.TREASURER.description.lower() + or "atp" in SocietyRole.TREASURER.description.lower() + ) + + +# ── RoleAssignment ────────────────────────────────────────────── + + +class TestRoleAssignment: + """Tests for the RoleAssignment dataclass.""" + + def test_construction(self) -> None: + """Basic construction with required fields.""" + ra = RoleAssignment( + role=SocietyRole.SOVEREIGN, + role_lct_id="role-lct-001", + filling_entity_lct_id="entity-001", + assigned_by="entity-001", + ) + assert ra.role == SocietyRole.SOVEREIGN + assert ra.role_lct_id == "role-lct-001" + assert ra.filling_entity_lct_id == "entity-001" + assert ra.assigned_by == "entity-001" + + def test_default_trust(self) -> None: + """Default T3/V3 are neutral (0.5).""" + ra = RoleAssignment( + role=SocietyRole.CITIZEN, + role_lct_id="role-lct-001", + filling_entity_lct_id="entity-001", + assigned_by="entity-001", + ) + assert ra.role_trust.talent == 0.5 + assert ra.role_trust.training == 0.5 + assert ra.role_trust.temperament == 0.5 + assert ra.role_value.valuation == 0.5 + + def test_default_not_multi_holder(self) -> None: + """Default is single-holder.""" + ra = RoleAssignment( + role=SocietyRole.CITIZEN, + role_lct_id="role-lct-001", + filling_entity_lct_id="entity-001", + assigned_by="entity-001", + ) + assert not ra.multi_holder + assert ra.additional_holders == [] + + def test_custom_trust(self) -> None: + """Can set custom T3/V3 at construction.""" + ra = RoleAssignment( + role=SocietyRole.TREASURER, + role_lct_id="role-lct-001", + filling_entity_lct_id="entity-001", + assigned_by="entity-001", + role_trust=T3(talent=0.9, training=0.8, temperament=0.7), + role_value=V3(valuation=0.6, veracity=0.5, validity=0.4), + ) + assert ra.role_trust.talent == 0.9 + assert ra.role_value.valuation == 0.6 + + +class TestRoleAssignmentRotation: + """Tests for role rotation — entity changes, role-LCT stays.""" + + def test_rotate_changes_filler(self) -> None: + """Rotation changes the filling entity.""" + ra = RoleAssignment( + role=SocietyRole.POLICY_ENTITY, + role_lct_id="role-lct-pe", + filling_entity_lct_id="entity-a", + assigned_by="sovereign-001", + ) + ra.rotate("entity-b", "sovereign-001", "2026-05-14T00:00:00Z") + assert ra.filling_entity_lct_id == "entity-b" + + def test_rotate_preserves_role_lct(self) -> None: + """The role-LCT must NOT change during rotation — core invariant.""" + ra = RoleAssignment( + role=SocietyRole.POLICY_ENTITY, + role_lct_id="role-lct-pe", + filling_entity_lct_id="entity-a", + assigned_by="sovereign-001", + ) + original_lct = ra.role_lct_id + ra.rotate("entity-b", "sovereign-001") + assert ra.role_lct_id == original_lct + + def test_rotate_updates_assigned_by(self) -> None: + ra = RoleAssignment( + role=SocietyRole.ADMINISTRATOR, + role_lct_id="role-lct-admin", + filling_entity_lct_id="entity-a", + assigned_by="sovereign-001", + ) + ra.rotate("entity-b", "admin-002") + assert ra.assigned_by == "admin-002" + + def test_rotate_updates_timestamp(self) -> None: + ra = RoleAssignment( + role=SocietyRole.ARCHIVIST, + role_lct_id="role-lct-arch", + filling_entity_lct_id="entity-a", + assigned_by="sovereign-001", + assigned_at="2026-01-01T00:00:00Z", + ) + ra.rotate("entity-b", "sovereign-001", "2026-06-01T00:00:00Z") + assert ra.assigned_at == "2026-06-01T00:00:00Z" + + def test_rotate_preserves_trust(self) -> None: + """Role trust metrics persist across rotation — it's the role's history.""" + ra = RoleAssignment( + role=SocietyRole.TREASURER, + role_lct_id="role-lct-tres", + filling_entity_lct_id="entity-a", + assigned_by="sovereign-001", + role_trust=T3(talent=0.9, training=0.8, temperament=0.7), + ) + ra.rotate("entity-b", "sovereign-001") + assert ra.role_trust.talent == 0.9 + assert ra.role_trust.training == 0.8 + + def test_authorization_after_rotation(self) -> None: + """After rotation, old entity is NOT authorized; new entity IS.""" + ra = RoleAssignment( + role=SocietyRole.POLICY_ENTITY, + role_lct_id="role-lct-pe", + filling_entity_lct_id="entity-a", + assigned_by="sovereign-001", + ) + assert ra.is_authorized("entity-a") + assert not ra.is_authorized("entity-b") + + ra.rotate("entity-b", "sovereign-001") + assert not ra.is_authorized("entity-a") + assert ra.is_authorized("entity-b") + + +class TestRoleAssignmentMultiHolder: + """Tests for committee/federation pattern — multiple holders per role.""" + + def test_add_holder(self) -> None: + """Adding a holder enables multi_holder flag.""" + ra = RoleAssignment( + role=SocietyRole.WITNESS, + role_lct_id="role-lct-wit", + filling_entity_lct_id="witness-a", + assigned_by="sovereign-001", + ) + assert not ra.multi_holder + result = ra.add_holder("witness-b") + assert result is True + assert ra.multi_holder + assert "witness-b" in ra.additional_holders + + def test_add_holder_duplicate_rejected(self) -> None: + """Cannot add the same holder twice.""" + ra = RoleAssignment( + role=SocietyRole.WITNESS, + role_lct_id="role-lct-wit", + filling_entity_lct_id="witness-a", + assigned_by="sovereign-001", + ) + ra.add_holder("witness-b") + result = ra.add_holder("witness-b") + assert result is False + assert len(ra.additional_holders) == 1 + + def test_add_primary_as_additional_rejected(self) -> None: + """Cannot add the primary filler as an additional holder.""" + ra = RoleAssignment( + role=SocietyRole.WITNESS, + role_lct_id="role-lct-wit", + filling_entity_lct_id="witness-a", + assigned_by="sovereign-001", + ) + result = ra.add_holder("witness-a") + assert result is False + + def test_multiple_holders_all_authorized(self) -> None: + """All holders (primary + additional) are authorized.""" + ra = RoleAssignment( + role=SocietyRole.WITNESS, + role_lct_id="role-lct-wit", + filling_entity_lct_id="witness-a", + assigned_by="sovereign-001", + ) + ra.add_holder("witness-b") + ra.add_holder("witness-c") + assert ra.is_authorized("witness-a") + assert ra.is_authorized("witness-b") + assert ra.is_authorized("witness-c") + assert not ra.is_authorized("witness-d") + + def test_remove_holder(self) -> None: + """Removing a holder updates the list.""" + ra = RoleAssignment( + role=SocietyRole.WITNESS, + role_lct_id="role-lct-wit", + filling_entity_lct_id="witness-a", + assigned_by="sovereign-001", + ) + ra.add_holder("witness-b") + ra.add_holder("witness-c") + result = ra.remove_holder("witness-b") + assert result is True + assert "witness-b" not in ra.additional_holders + assert ra.is_authorized("witness-c") + + def test_remove_last_additional_clears_multi_holder(self) -> None: + """Removing the last additional holder clears the multi_holder flag.""" + ra = RoleAssignment( + role=SocietyRole.WITNESS, + role_lct_id="role-lct-wit", + filling_entity_lct_id="witness-a", + assigned_by="sovereign-001", + ) + ra.add_holder("witness-b") + assert ra.multi_holder + ra.remove_holder("witness-b") + assert not ra.multi_holder + + def test_remove_nonexistent_holder(self) -> None: + """Removing a non-holder returns False.""" + ra = RoleAssignment( + role=SocietyRole.WITNESS, + role_lct_id="role-lct-wit", + filling_entity_lct_id="witness-a", + assigned_by="sovereign-001", + ) + result = ra.remove_holder("witness-x") + assert result is False + + def test_all_holders_property(self) -> None: + """all_holders returns primary + additional in order.""" + ra = RoleAssignment( + role=SocietyRole.WITNESS, + role_lct_id="role-lct-wit", + filling_entity_lct_id="witness-a", + assigned_by="sovereign-001", + ) + ra.add_holder("witness-b") + ra.add_holder("witness-c") + assert ra.all_holders == ["witness-a", "witness-b", "witness-c"] + + +class TestRoleAssignmentSerialization: + """Tests for to_dict() / from_dict() round-trip.""" + + def test_round_trip(self) -> None: + """to_dict → from_dict produces equivalent object.""" + original = RoleAssignment( + role=SocietyRole.TREASURER, + role_lct_id="role-lct-tres", + filling_entity_lct_id="entity-001", + assigned_by="sovereign-001", + assigned_at="2026-05-14T06:00:00Z", + role_trust=T3(talent=0.9, training=0.8, temperament=0.7), + role_value=V3(valuation=0.6, veracity=0.5, validity=0.4), + multi_holder=False, + ) + d = original.to_dict() + restored = RoleAssignment.from_dict(d) + assert restored.role == original.role + assert restored.role_lct_id == original.role_lct_id + assert restored.filling_entity_lct_id == original.filling_entity_lct_id + assert restored.assigned_by == original.assigned_by + assert restored.assigned_at == original.assigned_at + assert restored.role_trust.talent == original.role_trust.talent + assert restored.role_trust.training == original.role_trust.training + assert restored.role_trust.temperament == original.role_trust.temperament + assert restored.role_value.valuation == original.role_value.valuation + assert restored.multi_holder == original.multi_holder + + def test_round_trip_with_holders(self) -> None: + """Round-trip preserves additional_holders.""" + original = RoleAssignment( + role=SocietyRole.WITNESS, + role_lct_id="role-lct-wit", + filling_entity_lct_id="witness-a", + assigned_by="sovereign-001", + ) + original.add_holder("witness-b") + original.add_holder("witness-c") + + d = original.to_dict() + restored = RoleAssignment.from_dict(d) + assert restored.multi_holder is True + assert restored.additional_holders == ["witness-b", "witness-c"] + + def test_to_dict_role_value(self) -> None: + """to_dict stores role as string value, not enum.""" + ra = RoleAssignment( + role=SocietyRole.SOVEREIGN, + role_lct_id="role-lct-001", + filling_entity_lct_id="entity-001", + assigned_by="entity-001", + ) + d = ra.to_dict() + assert d["role"] == "sovereign" + assert isinstance(d["role"], str) + + def test_from_dict_minimal(self) -> None: + """from_dict works with minimal required fields.""" + d = { + "role": "citizen", + "role_lct_id": "role-lct-cit", + "filling_entity_lct_id": "entity-001", + "assigned_by": "sovereign-001", + } + ra = RoleAssignment.from_dict(d) + assert ra.role == SocietyRole.CITIZEN + assert ra.role_trust.talent == 0.5 # default + assert ra.multi_holder is False + + +# ── bootstrap_society_roles ───────────────────────────────────── + + +class TestBootstrapSocietyRoles: + """Tests for solo-founder genesis role creation.""" + + def test_produces_seven_assignments(self) -> None: + """Bootstrap creates exactly 7 role assignments (one per base-mandatory).""" + assignments = bootstrap_society_roles("founder-001") + assert len(assignments) == 7 + + def test_all_base_mandatory_roles_covered(self) -> None: + """All 7 base-mandatory roles are represented.""" + assignments = bootstrap_society_roles("founder-001") + roles = {a.role for a in assignments} + expected = { + SocietyRole.SOVEREIGN, + SocietyRole.LAW_ORACLE, + SocietyRole.POLICY_ENTITY, + SocietyRole.TREASURER, + SocietyRole.ADMINISTRATOR, + SocietyRole.ARCHIVIST, + SocietyRole.CITIZEN, + } + assert roles == expected + + def test_founder_fills_all_roles(self) -> None: + """The solo founder is the filling entity for every role.""" + assignments = bootstrap_society_roles("founder-001") + for a in assignments: + assert a.filling_entity_lct_id == "founder-001" + + def test_founder_is_assigner(self) -> None: + """The solo founder assigned all roles (self-bootstrapped).""" + assignments = bootstrap_society_roles("founder-001") + for a in assignments: + assert a.assigned_by == "founder-001" + + def test_each_role_has_unique_lct(self) -> None: + """Each role gets its own LCT ID — different from each other and from the founder.""" + assignments = bootstrap_society_roles("founder-001") + lct_ids = [a.role_lct_id for a in assignments] + assert len(set(lct_ids)) == 7 # all unique + for lct_id in lct_ids: + assert lct_id != "founder-001" # different from founder + + def test_default_lct_ids_are_deterministic(self) -> None: + """Default LCT IDs encode the role for debuggability.""" + assignments = bootstrap_society_roles("founder-001") + sovereign = next(a for a in assignments if a.role == SocietyRole.SOVEREIGN) + assert sovereign.role_lct_id == "founder-001:role:sovereign" + + def test_custom_lct_factory(self) -> None: + """Custom role_lct_factory is called for each role.""" + counter = {"n": 0} + + def factory() -> str: + counter["n"] += 1 + return f"custom-lct-{counter['n']}" + + assignments = bootstrap_society_roles("founder-001", role_lct_factory=factory) + assert assignments[0].role_lct_id == "custom-lct-1" + assert assignments[6].role_lct_id == "custom-lct-7" + assert counter["n"] == 7 + + def test_timestamp_propagated(self) -> None: + """Timestamp is set on all assignments.""" + ts = "2026-05-14T06:00:00Z" + assignments = bootstrap_society_roles("founder-001", timestamp=ts) + for a in assignments: + assert a.assigned_at == ts + + def test_default_trust_neutral(self) -> None: + """Bootstrap roles start with neutral T3/V3 (0.5).""" + assignments = bootstrap_society_roles("founder-001") + for a in assignments: + assert a.role_trust.talent == 0.5 + assert a.role_value.valuation == 0.5 + + +# ── BASE_MANDATORY_ROLES constant ────────────────────────────── + + +class TestBaseMandatoryRolesConstant: + """Tests for the BASE_MANDATORY_ROLES module-level constant.""" + + def test_count(self) -> None: + assert len(BASE_MANDATORY_ROLES) == 7 + + def test_all_base_mandatory(self) -> None: + for role in BASE_MANDATORY_ROLES: + assert role.is_base_mandatory + + def test_context_mandatory_excluded(self) -> None: + assert SocietyRole.WITNESS not in BASE_MANDATORY_ROLES + assert SocietyRole.AUDITOR not in BASE_MANDATORY_ROLES diff --git a/web4-standard/implementation/sdk/web4/__init__.py b/web4-standard/implementation/sdk/web4/__init__.py index 8e4749d7..170c4227 100644 --- a/web4-standard/implementation/sdk/web4/__init__.py +++ b/web4-standard/implementation/sdk/web4/__init__.py @@ -18,6 +18,7 @@ - Metabolic states — society operational modes with energy, trust, and witness effects - Multi-device binding — device constellation management, trust computation, and recovery - Society — core organizational primitive composing federation, treasury, ledger, and trust +- Society Roles — the 7 base-mandatory role taxonomy with role-LCT binding - Security primitives — crypto suite definitions, W4ID identifiers, key policies, VCs - Core protocol — handshake, transport, discovery, and Web4 URI types - MCP protocol types — Web4 context headers, resources, sessions, ATP metering @@ -26,7 +27,7 @@ - Deserialization — generic JSON-LD dispatcher for all Web4 types - Generation — produce minimal valid JSON-LD documents for any Web4 type -22 modules + MCP server, 364 exports, 2627 tests, 3 behavioral functions, 8 MCP tools, 7 CLI subcommands. +23 modules + MCP server, 368 exports, 2627 tests, 3 behavioral functions, 8 MCP tools, 7 CLI subcommands. v0.26.0: CI quality gates (strict mypy, ruff lint, ruff format) enforced on every PR. These modules define the canonical data types and algorithms specified in the web4-standard. They work offline (no network services required) and are designed to be @@ -391,6 +392,14 @@ society_ancestry, ) +# ── Society Roles ───────────────────────────────────────────── +from .role import ( + SocietyRole, + RoleAssignment, + bootstrap_society_roles, + BASE_MANDATORY_ROLES, +) + # ── Security Primitives ─────────────────────────────────────── from .security import ( CryptoSuiteId, @@ -804,6 +813,11 @@ "incorporate_child", "society_depth", "society_ancestry", + # society roles + "SocietyRole", + "RoleAssignment", + "bootstrap_society_roles", + "BASE_MANDATORY_ROLES", # security "CryptoSuiteId", "CryptoSuite", diff --git a/web4-standard/implementation/sdk/web4/role.py b/web4-standard/implementation/sdk/web4/role.py new file mode 100644 index 00000000..58935818 --- /dev/null +++ b/web4-standard/implementation/sdk/web4/role.py @@ -0,0 +1,334 @@ +""" +Web4 Society Roles — the role taxonomy per society-roles.md. + +Every Web4 society MUST fill 7 base-mandatory roles: +Sovereign, LawOracle, PolicyEntity, Treasurer, Administrator, Archivist, Citizen. + +A role: +- Has its own LCT (authority binds to role, not filling entity) +- Can be filled by a single entity, a sub-society, or a federation +- Carries its own T3/V3 trust metrics (performance of the role) +- Can be rotated without breaking accountability chains + +The role taxonomy has three tiers: +- **Base-mandatory** (7): Must exist in every society +- **Context-mandatory**: Required when certain conditions hold + (e.g., Witness is mandatory when outward roles exist) +- **Optional**: Societies may define additional roles + +Reference: web4-standard/core-spec/society-roles.md +Cross-language parity: web4-core/src/role.rs +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional + +from web4.trust import T3, V3 + +__all__ = [ + # Classes + "SocietyRole", + "RoleAssignment", + # Functions + "bootstrap_society_roles", + # Constants + "BASE_MANDATORY_ROLES", +] + + +class SocietyRole(str, Enum): + """Society roles per society-roles.md §2-§4. + + The 7 base-mandatory roles that every Web4 society must fill, + plus context-mandatory and optional roles. + + Uses str mixin for JSON-friendly serialization (value is the string key). + """ + + # ── Base-mandatory (7) ────────────────────────────────────── + + SOVEREIGN = "sovereign" + """Final authority for charter amendment, identity recovery, + and extraordinary inter-society decisions.""" + + LAW_ORACLE = "law_oracle" + """Publishes machine-readable laws, signs interpretations, + answers compliance queries, maps laws to R6/R7 action grammar. + NOT a decision-maker — an oracle that the PolicyEntity consults.""" + + POLICY_ENTITY = "policy_entity" + """Takes R6/R7 action requests, evaluates against Law Oracle's laws, + returns approve/deny/escalate with reasoning. The enforcement arm.""" + + TREASURER = "treasurer" + """Operates Treasury: mints ATP, allocates per law, accounts for + ATP/ADP movements. Conservation invariant: sum(ATP) + sum(ADP) = const.""" + + ADMINISTRATOR = "administrator" + """Operational execution: citizen lifecycle management, R6/R7 dispatch + routing, infrastructure liveness, day-to-day society operations.""" + + ARCHIVIST = "archivist" + """Maintains ledger writes, cryptographic chain integrity, retention + policy enforcement, historical queries. The society's memory.""" + + CITIZEN = "citizen" + """Base membership role. Every entity holds Citizen first; additional + roles layer on top. Citizen is the genesis role — immutable once granted.""" + + # ── Context-mandatory ─────────────────────────────────────── + + WITNESS = "witness" + """Independent attestation of other roles' actions. Mandatory when + outward-facing roles exist (inter-society interactions).""" + + AUDITOR = "auditor" + """T3/V3 validation and trust auditing. Mandatory when the society + issues trust attestations consumed by other societies.""" + + @property + def is_base_mandatory(self) -> bool: + """Returns True if this is one of the 7 base-mandatory roles.""" + return self in _BASE_MANDATORY_SET + + @property + def description(self) -> str: + """Human-readable description of the role's responsibility.""" + return _ROLE_DESCRIPTIONS[self] + + +# Frozen set for O(1) lookup +_BASE_MANDATORY_SET = frozenset( + { + SocietyRole.SOVEREIGN, + SocietyRole.LAW_ORACLE, + SocietyRole.POLICY_ENTITY, + SocietyRole.TREASURER, + SocietyRole.ADMINISTRATOR, + SocietyRole.ARCHIVIST, + SocietyRole.CITIZEN, + } +) + +#: Ordered list of the 7 base-mandatory roles. +BASE_MANDATORY_ROLES: List[SocietyRole] = [ + SocietyRole.SOVEREIGN, + SocietyRole.LAW_ORACLE, + SocietyRole.POLICY_ENTITY, + SocietyRole.TREASURER, + SocietyRole.ADMINISTRATOR, + SocietyRole.ARCHIVIST, + SocietyRole.CITIZEN, +] + +_ROLE_DESCRIPTIONS: Dict[SocietyRole, str] = { + SocietyRole.SOVEREIGN: "Final authority for charter amendment and identity recovery", + SocietyRole.LAW_ORACLE: "Publishes and interprets machine-readable laws", + SocietyRole.POLICY_ENTITY: "Evaluates action requests against law, returns signed decisions", + SocietyRole.TREASURER: "Operates treasury, mints ATP, enforces conservation", + SocietyRole.ADMINISTRATOR: "Citizen lifecycle, dispatch routing, operations", + SocietyRole.ARCHIVIST: "Ledger integrity, chain maintenance, historical queries", + SocietyRole.CITIZEN: "Base membership role — genesis role, immutable once granted", + SocietyRole.WITNESS: "Independent attestation of other roles' actions", + SocietyRole.AUDITOR: "T3/V3 validation and trust auditing", +} + + +@dataclass +class RoleAssignment: + """A role assignment — binds a role to its own LCT and tracks the filling entity. + + Key principle from society-roles.md §5: authority binds to ``role_lct_id``, + not ``filling_entity_lct_id``. When the filling entity rotates, accountability + chains remain intact because the role's LCT (and its signature history) + doesn't change. + + Cross-language parity with ``web4-core/src/role.rs::RoleAssignment``. + """ + + role: SocietyRole + """The role being assigned.""" + + role_lct_id: str + """The role's own LCT — authority binds here.""" + + filling_entity_lct_id: str + """The entity currently filling this role.""" + + assigned_by: str + """Who assigned this role (typically Sovereign or Administrator LCT ID).""" + + assigned_at: str = "" + """ISO timestamp of when this assignment was made.""" + + role_trust: T3 = field(default_factory=T3) + """Trust metrics for this role's performance.""" + + role_value: V3 = field(default_factory=V3) + """Value metrics for this role's contributions.""" + + multi_holder: bool = False + """Whether the role is filled by multiple entities simultaneously + (e.g., a committee of Witnesses).""" + + additional_holders: List[str] = field(default_factory=list) + """Additional entities filling this role (for committee/federation patterns).""" + + def rotate(self, new_entity_lct_id: str, rotated_by: str, timestamp: str = "") -> None: + """Rotate the entity filling this role. The role-LCT stays the same. + + This is the core principle: authority binds to the role, not the person. + When a Treasurer is replaced, the Treasury's signature chain continues + uninterrupted under the same role-LCT. + + Args: + new_entity_lct_id: LCT ID of the entity now filling the role. + rotated_by: LCT ID of the entity authorizing the rotation + (typically Sovereign or Administrator). + timestamp: ISO timestamp of the rotation. + """ + self.filling_entity_lct_id = new_entity_lct_id + self.assigned_by = rotated_by + if timestamp: + self.assigned_at = timestamp + + def add_holder(self, entity_lct_id: str) -> bool: + """Add an additional holder (committee/federation pattern). + + For roles like Witness where multiple entities may need to act + in the same role simultaneously. + + Returns False if the entity is already a holder or is the primary filler. + """ + if entity_lct_id == self.filling_entity_lct_id: + return False + if entity_lct_id in self.additional_holders: + return False + self.additional_holders.append(entity_lct_id) + self.multi_holder = True + return True + + def remove_holder(self, entity_lct_id: str) -> bool: + """Remove an additional holder. + + Returns False if the entity is not an additional holder. + Cannot remove the primary filling entity — use rotate() for that. + """ + if entity_lct_id not in self.additional_holders: + return False + self.additional_holders.remove(entity_lct_id) + if not self.additional_holders: + self.multi_holder = False + return True + + def is_authorized(self, entity_lct_id: str) -> bool: + """Check if an entity is authorized to act in this role. + + Returns True if the entity is the primary filler or an additional holder. + """ + return entity_lct_id == self.filling_entity_lct_id or entity_lct_id in self.additional_holders + + @property + def all_holders(self) -> List[str]: + """All entities authorized for this role (primary + additional).""" + return [self.filling_entity_lct_id] + list(self.additional_holders) + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dictionary.""" + return { + "role": self.role.value, + "role_lct_id": self.role_lct_id, + "filling_entity_lct_id": self.filling_entity_lct_id, + "assigned_by": self.assigned_by, + "assigned_at": self.assigned_at, + "role_trust": { + "talent": self.role_trust.talent, + "training": self.role_trust.training, + "temperament": self.role_trust.temperament, + }, + "role_value": { + "valuation": self.role_value.valuation, + "veracity": self.role_value.veracity, + "validity": self.role_value.validity, + }, + "multi_holder": self.multi_holder, + "additional_holders": list(self.additional_holders), + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> RoleAssignment: + """Deserialize from dictionary.""" + rt = data.get("role_trust", {}) + rv = data.get("role_value", {}) + return cls( + role=SocietyRole(data["role"]), + role_lct_id=data["role_lct_id"], + filling_entity_lct_id=data["filling_entity_lct_id"], + assigned_by=data["assigned_by"], + assigned_at=data.get("assigned_at", ""), + role_trust=T3( + talent=rt.get("talent", 0.5), + training=rt.get("training", 0.5), + temperament=rt.get("temperament", 0.5), + ), + role_value=V3( + valuation=rv.get("valuation", 0.5), + veracity=rv.get("veracity", 0.5), + validity=rv.get("validity", 0.5), + ), + multi_holder=data.get("multi_holder", False), + additional_holders=list(data.get("additional_holders", [])), + ) + + +# ── Bootstrap (solo-founder genesis) ───────────────────────── + + +def bootstrap_society_roles( + founder_lct_id: str, + timestamp: str = "", + role_lct_factory: Optional[Any] = None, +) -> List[RoleAssignment]: + """Create role assignments for solo-founder genesis. + + Per inter-society-protocol.md §2.1: a single entity MAY found a society. + "Solo founder wears many hats." The founder fills all 7 base-mandatory + roles. Each role gets its own LCT ID (supplied by ``role_lct_factory`` + or generated as deterministic strings). + + This resolves the cross-language gap where the Python SDK's + ``create_society()`` required ``len(founders) >= 2`` while the spec + and Rust SDK support solo-founder genesis. + + Args: + founder_lct_id: LCT ID of the founding entity. + timestamp: ISO timestamp of the bootstrap. + role_lct_factory: Optional callable that returns a new LCT ID string. + If None, generates deterministic IDs as ``{founder_lct_id}:role:{role_value}``. + + Returns: + List of 7 RoleAssignment objects, one per base-mandatory role, + all with the founder as both assigner and filler. + """ + assignments: List[RoleAssignment] = [] + + for role in BASE_MANDATORY_ROLES: + if role_lct_factory is not None: + role_lct_id = str(role_lct_factory()) + else: + role_lct_id = f"{founder_lct_id}:role:{role.value}" + + assignments.append( + RoleAssignment( + role=role, + role_lct_id=role_lct_id, + filling_entity_lct_id=founder_lct_id, + assigned_by=founder_lct_id, + assigned_at=timestamp, + ) + ) + + return assignments