Cost-Lens Governance Lifecycle
Status & scope
- Status: Draft — ready to implement. The lifecycle command layer + helpers are recovered
from prism's pre-split code (
git show 8099b68^:lens/governance/{lens,_helpers}.py), which was engine-agnostic; this spec cost-flavors it (drops participation/consent + federateLensBinding, which are entity-only) and re-homes persistence. - Module:
lens/governance/(lifecycle commands + helpers),lens/userspace/governance.py(cost-lens ES persistence),lens/schema.py(aliases),mcp_server/server.py(tool surface). - Engine boundary: prism is an import-linter leaf — this lifecycle is prism-owned and prism-internal; it never imports parallax. Shared transition primitives are a candidate for axonis-core extraction (the only firewall-legal sharing path — see [[component.prism.cost-primitives]] dedup note and parallax SPEC-27), tracked as an open question.
Purpose
Govern the lifecycle of a cost lens as a versioned, reviewed, auditable object — distinct from
its runtime ([[component.prism.operational-lifecycle]], Init→Execute→Sync) and from the declarative
governance level on the lens policy. A cost lens progresses through a review/approval state
machine before it is allowed to execute, and is frozen on approval so an executed decision is
reproducible against an immutable spec. This realizes the cost-engine half of
[[product.lens]] §Lifecycle ("each engine governs its own lenses"), resolving the
"governance convergence" open question (architect, 2026-06-09): cost lenses get a real lifecycle,
not just the flat full|lightweight|none flag.
State machine
draft ──submit──▶ submitted ──approve──▶ approved ──activate──▶ active ──retire──▶ retired (terminal)
▲ │ │ ▲
└───── reject ───────┘ └──── retire ──────────────────────────────┘
▲ │
└────────────────── revise (new version) ────┘
LENS_STATUSES = {"draft", "submitted", "approved", "active", "retired"}
LENS_TRANSITIONS = {
"draft": {"submitted"},
"submitted": {"approved", "draft"}, # approve, or reject back to draft
"approved": {"active", "retired", "draft"}, # activate, retire, or revise (new draft version)
"active": {"retired"},
"retired": set(), # terminal
}
LENS_REVIEW_STATUSES = {"pending", "approved", "rejected", "changes_requested"}
draft— editable;lens_updateallowed only here.submitted— under review; spec no longer editable, onlylens_review/lens_approve/lens_reject.approved— spec frozen (invariant CG-01); any change requireslens_revise→ a newversiondraft.active— first execution has occurred; the orchestrator ([[component.prism.scoring-engine]]) refuses to run a lens not inapproved/active.retired— terminal; cannot execute. Existing evidence blocks remain valid.
Governance level ↔ lifecycle rigor
The declarative policy.governance level (full | lightweight | none,
[[component.prism.universal-lens-parser]] V-rule on GovernanceLevel) is orthogonal to the state
machine and keys its rigor at the submitted → approved gate:
policy.governance |
Review required | Separation-of-duties | Notes |
|---|---|---|---|
full |
yes (≥1 approved LensReview) |
enforced (approver ≠ author) | default |
lightweight |
no | not enforced | author may self-approve |
none |
no | not enforced | review gate skipped; lens still versions + freezes on approved |
none is not "ungoverned" — the lens still moves through the state machine, versions, and
freezes on approval; it only skips the human review gate.
Command layer
lens/governance/lens.py — pure command functions over the persistence layer (recovered verbatim;
cost-flavored). Caller identity arrives via user_metadata from auth middleware
([[component.prism.service-interface]]); there is no actor-name exemption.
def lens_get(lens_id: str) -> dict
def lens_list(status: str | None = None, limit: int = 100) -> list[dict]
def lens_create(body: dict, user_metadata: dict) -> dict # → draft
def lens_update(lens_id: str, body: dict, user_metadata: dict) -> dict # draft only
def lens_submit(lens_id: str, user_metadata: dict, note: str = "") -> dict # draft → submitted
def lens_review(lens_id, comment, user_metadata, review_status="pending", checklist=None) -> dict
def lens_reject(lens_id: str, reason: str, user_metadata: dict, checklist=None) -> dict # submitted → draft
def lens_approve(lens_id: str, user_metadata: dict, note: str = "", checklist=None) -> dict # submitted → approved (writes an approved LensReview; +SoD if full)
def lens_activate(lens_id: str, user_metadata: dict, note: str = "") -> dict # approved → active
def lens_retire(lens_id: str, reason: str, user_metadata: dict) -> dict # approved/active → retired
def lens_revise(lens_id: str, user_metadata: dict) -> dict # from approved OR active → spawns a NEW draft (minor bump); source unchanged
lens_revise is a creation, not a transition: it copies the source spec into a brand-new lens in draft (parent_lens_id links back) while the source stays in its current state (approved/active). It is therefore not bound by LENS_TRANSITIONS (the source does not move).
def execute_cost_lens(lens_id, inputs, layer_data=None) -> dict # CG-05 gate (assert_executable) → run
Internal logic (recovered): _validate_transition, _check_separation_of_duties (keyed on
policy.governance == "full"), _bump_minor_semver ("1.0.0" → "1.1.0"), _extract_spec,
_write_review. Helpers in lens/governance/_helpers.py (recovered verbatim): now, actor_id,
require_authenticated_actor, unwrap_one, record_transition, new_id.
Dropped from the recovered code (entity-only — do NOT port): lens/governance/participation.py
(consent-scope, PARTICIPATION_TRANSITIONS) and lens/governance/binding.py (federate
LensBinding). Cost lenses bind to data layers, not federates, and have no multi-party consent.
Persistence
Cost-lens governance objects persist to ES via prism's PrismModel/UDS base
([[component.prism.serialization]]), revived from the deleted lens/userspace/fusion.py:
Lens— lifecycle doc (lens_id,status,status_history,version,spec,created_by/_at,submitted_by/_at,approved_by/_at,retired_by/_at,parent_lens_id).LensReview— standalone doc keyed bylens_id+lens_version,checklistpayload, status inLENS_REVIEW_STATUSES.
Schema aliases in lens/schema.py: LENS, LENS_REVIEW → cost-lens-dedicated indices
(cost_lenses, cost_lens_reviews) — NOT entity governance indices (those are parallax's
fusion_*) and NOT the old shared semantic-lens index. Enum values serialize as strings
(model_dump(mode="json")), per the parallax T2 serialization fix.
Bootstrap caveat (peer-confirmed 2026-06-09, parallax 078a35a9). axonis-core's
ElasticManager.bootstrap(LOCAL_INDICES) template-creates each index via
read_template('<base>_mapping.json') and crashes on any index without a shipped template
(parallax hit this on fusion_lenses/fusion_bindings — no template, no axonis fallback). If
cost_lenses/cost_lens_reviews go into a LOCAL_INDICES bootstrapped the same way, T10 hits the
identical failure. Fix in T10: ship a lens/templates/cost_lenses_mapping.json +
cost_lens_reviews_mapping.json per index. The deeper fix — make bootstrap() tolerate
template-less indices — is an axonis-core change logged to cross-repo-followups.
MCP surface
Register the lifecycle commands as prism MCP tools (in mcp_server/tools/governance.py, mounted by
register_all): create_cost_lens, update_cost_lens, submit_cost_lens, review_cost_lens,
approve_cost_lens, reject_cost_lens, activate_cost_lens, retire_cost_lens, revise_cost_lens,
get_cost_lens, list_cost_lenses, and execute_cost_lens (the governed-execution entry that
enforces the CG-05 gate, §command-layer) — 12 tools. Names are cost-prefixed so they never
collide with parallax's entity-lens governance tools (which T5 moved to parallax's MCP server). Each
derives the caller identity from the request-scoped Authenticator and dispatches to the command layer.
Invariants
- CG-01 — frozen-after-approval. Once
approved, thespecis immutable; the only path to a changed spec islens_revise→ a newversionindraft. - CG-02 — separation of duties (when
full). The approver (and any reviewer recordingapproved) MUST NOT be the lens author. Fails closed: anunknownactor is rejected. - CG-03 — versions are monotonic semver.
lens_revisebumps the minor (X.Y.Z→X.(Y+1).0). - CG-04 — append-only history. Every transition appends a
status_historyentry (from/to/actor/timestamp/note); entries are never mutated or removed. - CG-05 — execution gate. The orchestrator executes only
approved/activelenses;draft/submitted/retiredare refused. - CG-06 — firewall. No symbol in
lens/governance/importsparallax/athena; the lifecycle is prism-internal.
Validation rules
| Rule | Check | Enforced in |
|---|---|---|
| CG-V01 | Transition is in LENS_TRANSITIONS[current] |
_validate_transition |
| CG-V02 | lens_update only when status == "draft" |
lens_update |
| CG-V03 | SoD: approver ≠ author when policy.governance == "full" |
_check_separation_of_duties |
| CG-V04 | lens_approve writes an approved LensReview for audit + enforces SoD when full (2-party model: author + non-author approver; a prior independent reviewer is optional via lens_review) |
lens_approve / _check_separation_of_duties |
| CG-V05 | review_status ∈ LENS_REVIEW_STATUSES; comment non-empty |
lens_review |
| CG-V06 | lens_retire/lens_reject require a non-empty reason |
lens_retire/lens_reject |
Named tests
Pure invariant logic (no ES — always runs) — tests/governance/test_cost_lens_pure.py:
- test_transition_table_allows_legal / test_transition_table_rejects_illegal — every legal
(current, target) is accepted and every illegal one raises (CG-V01); test_retired_is_terminal.
- test_separation_of_duties_full_blocks_author / _full_allows_non_author /
_lightweight_allows_author_self_approve / _none_allows_author_self_approve /
_rejects_unknown_actor_even_when_not_full — CG-02 / CG-V03, both directions keyed on the level.
- test_bump_minor_semver — CG-03 (1.0.0→1.1.0, unparseable→1.1.0).
- test_extract_spec_* — explicit spec wins / non-lifecycle top-level / lifecycle-only → {}.
- test_record_transition_is_append_only — CG-04, prior entries untouched.
Lifecycle through the command wire (live ES) — tests/governance/test_cost_lens_lifecycle.py:
- test_full_lifecycle_walk — create→submit→review→approve→activate→retire, asserting status +
status_history + the CG-05 gate at each stage + CG-01 freeze (lens_update on approved raises)
+ terminal-retired.
- test_reject_returns_submitted_to_draft — lens_reject → draft, rejection_reason set.
- test_activate_rejects_non_approved — activate from draft/submitted raises (CG-V01).
- test_reject_and_retire_require_reason — CG-V06.
- test_lightweight_author_may_self_approve — the SoD admit half (CG-02 ↔ §level-rigor).
Persistence + gate + surface (live ES):
- tests/governance/test_cost_lens_persistence.py::test_lens_round_trip — status persists as the
string in the dedicated cost_lenses index; test_approve_writes_review_resolvable_by_lens_id_and_version
— review keyed by lens_id+version; test_separation_of_duties_blocks_author_self_approve_when_full.
- tests/governance/test_execution_gate.py::test_refuses_unapproved — CG-05 refuses draft/submitted, admits approved.
- tests/integration/test_cost_lens_governance_journey.py::test_governance_journey_through_mcp_wire
— A-Z through real mcp.call_tool; draft execution refused, active admitted.
- tests/server/test_mcp_cost_lens_tools.py::test_registers_cost_lens_governance +
test_tool_count_tracks_live_registry — the 12 *_cost_lens tools register; get_tool_count() tracks the live registry.
Definition of done
lens/governance/{lens,_helpers}.pyrecovered + cost-flavored (participation/binding NOT ported).lens/userspace/governance.pycost-lensLens/LensReviewES persistence +lens/schema.pyLENS/LENS_REVIEWaliases on dedicatedcost_*indices._check_separation_of_dutiesgated onpolicy.governancelevel per §Governance level ↔ rigor.mcp_server/server.pyexposes the*_cost_lenstools.- Orchestrator execution gate (CG-05) wired.
- All §Named tests green (live ES for the persistence test, not skipped).
- Firewall check (CG-06) green: no
parallax/athenaimport underlens/governance/.
Open questions
- axonis-core dedup. Both engines now run a
draft→…→retiredstate machine with SoD + semver + review. The shared transition logic (LENS_TRANSITIONS,_check_separation_of_duties,_bump_minor_semver,record_transition) is a candidate for one axonis-core primitive consumed by both — the only firewall-legal sharing path (parallax SPEC-27). Deferred: build prism's lifecycle first (recovered), extract second once both shapes are stable. - Review checklist content. The cost-lens review
checklistschema (what a reviewer attests to — weights sum to 1.0, thresholds ordered, evidence policy) is TBD; the entity engine's checklist is match-function-specific and not reusable.
Depends on: component.prism.serialization, component.prism.service-interface, component.prism.universal-lens-parser
Realizes: product.lens