Skip to content

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 + federate LensBinding, 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_update allowed only here.
  • submitted — under review; spec no longer editable, only lens_review/lens_approve/lens_reject.
  • approvedspec frozen (invariant CG-01); any change requires lens_revise → a new version draft.
  • active — first execution has occurred; the orchestrator ([[component.prism.scoring-engine]]) refuses to run a lens not in approved/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 by lens_id + lens_version, checklist payload, status in LENS_REVIEW_STATUSES.

Schema aliases in lens/schema.py: LENS, LENS_REVIEWcost-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, the spec is immutable; the only path to a changed spec is lens_revise → a new version in draft.
  • CG-02 — separation of duties (when full). The approver (and any reviewer recording approved) MUST NOT be the lens author. Fails closed: an unknown actor is rejected.
  • CG-03 — versions are monotonic semver. lens_revise bumps the minor (X.Y.ZX.(Y+1).0).
  • CG-04 — append-only history. Every transition appends a status_history entry (from/to/actor/timestamp/note); entries are never mutated or removed.
  • CG-05 — execution gate. The orchestrator executes only approved/active lenses; draft/ submitted/retired are refused.
  • CG-06 — firewall. No symbol in lens/governance/ imports parallax/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_statusLENS_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.01.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_draftlens_rejectdraft, 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_tripstatus 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

  1. lens/governance/{lens,_helpers}.py recovered + cost-flavored (participation/binding NOT ported).
  2. lens/userspace/governance.py cost-lens Lens/LensReview ES persistence + lens/schema.py LENS/LENS_REVIEW aliases on dedicated cost_* indices.
  3. _check_separation_of_duties gated on policy.governance level per §Governance level ↔ rigor.
  4. mcp_server/server.py exposes the *_cost_lens tools.
  5. Orchestrator execution gate (CG-05) wired.
  6. All §Named tests green (live ES for the persistence test, not skipped).
  7. Firewall check (CG-06) green: no parallax/athena import under lens/governance/.

Open questions

  • axonis-core dedup. Both engines now run a draft→…→retired state 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 checklist schema (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