Proposals List: State Transition Queue — Mission Control Dashboard
Proposals List: State Transition Queue — Mission Control Dashboard
Context
This ticket is the companion to issue #2 (Proposal Reimagination). That issue redesigns what a proposal is. This issue redesigns how a list of proposals is scanned, filtered, and acted upon.
The current list page (/{owner}/{repo}/proposals) renders proposals as simple rows with a title, branch names, author, timestamp, and a risk_band string inferred by matching the branch name prefix:
def infer_list_risk_band(from_branch: str) -> str:
prefix = from_branch.split("/")[0].lower().strip()
if prefix in ("breaking", "major"):
return "critical"
if prefix in ("feat", "feature", "refactor"):
return "medium"
...
return "medium"
That is a branch-naming convention check posing as risk analysis. Delete it on sight.
The redesigned list is a mission control dashboard for state transitions — a scannable queue that surfaces dimensional activity, cryptographic review status, dependency graph position, and merge readiness for every proposal at once.
What the List Must Communicate Per Row
A proposals list row is a compressed proposal manifest. Every row must answer seven questions at a glance without requiring the user to open the proposal:
1. What type of transition is this? ← proposal_type badge
2. Which dimensions are in motion? ← domain activity dot row
3. How risky is it, per domain? ← dimensional risk minibar
4. Where is it in the review lifecycle? ← state pill + per-domain approval dots
5. Is it blocked by something else? ← dependency lock icon
6. Who proposed it — human or agent? ← author archetype signal
7. Is it ready to merge right now? ← merge-ready indicator
ASCII Layout Art
Layout A — Full Proposal Row (wide viewport)
╔══════════════════════════════════════════════════════════════════════════════════════╗
║ PROPOSAL QUEUE · gabriel/muse ║
║ ──────────────────────────────────────────────────────────────────────────────── ║
║ ┌── STATE TABS ──────────────────────────────────────────────────────────────────┐ ║
║ │ OPEN 4 IN_REVIEW 2 APPROVED 1 DRAFTING 3 MERGED 47 │ ║
║ └────────────────────────────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌── DOMAIN HEAT (open proposals) ───────────────────────────────────────────────┐ ║
║ │ CODE ████████ 6 │ MIDI ████░░░░ 3 │ PAY █░░░░░░░ 1 │ STEMS ░░ 0 │ ║
║ └────────────────────────────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌── FILTERS ─────────────────────────────────────────────────────────────────────┐ ║
║ │ TYPE: [all ▾] DOMAIN: [all ▾] RISK: [all ▾] AUTHOR: [all ▾] SORT: [▾] │ ║
║ └────────────────────────────────────────────────────────────────────────────────┘ ║
╠══════════════════════════════════════════════════════════════════════════════════════╣
║ ║
║ ┌──────────────────────────────────────────────────────────────────────────────┐ ║
║ │ #14 [STATE_MERGE] ████ CRITICAL ⛔ BLOCKED by #11 │ ║
║ │ feat: auth v2 + MSign Ed25519 key rotation │ ║
║ │ ●CODE ●MIDI ─STEMS ─PROSE ─PAY │ ║
║ │ HIGH MED │ ║
║ │ feat/auth-v2 → main [✓]code [✗]midi needs 1 more domain │ ║
║ │ @gabriel · 2h ago ⊕ 1/2 approvals · breakage: 2 · gap: 3 │ ║
║ └──────────────────────────────────────────────────────────────────────────────┘ ║
║ ║
║ ┌──────────────────────────────────────────────────────────────────────────────┐ ║
║ │ #13 [MIDI_EVOLUTION] ▓▓▓ MEDIUM ✅ MERGE READY │ ║
║ │ Piano arrangement v3 — minor key shift bars 1–32 │ ║
║ │ ─CODE ●MIDI ─STEMS ─PROSE ─PAY │ ║
║ │ MED │ ║
║ │ feat/piano-v3 → main [✓]midi │ ║
║ │ @mix-engine-7 [AGENT] · 4h ago ⊕ 2/2 approvals · tracks: +1 │ ║
║ └──────────────────────────────────────────────────────────────────────────────┘ ║
╚══════════════════════════════════════════════════════════════════════════════════════╝
Layout B — Compact Row (narrow viewport / mobile)
╔══════════════════════════════════════════════════════╗
║ ●OPEN 4 ●IN_REVIEW 2 ●APPROVED 1 ║
╠══════════════════════════════════════════════════════╣
║ #14 ████ feat: auth v2 + MSign key rotation ║
║ ●C ●M feat/auth-v2→main @gabriel 2h ║
║ ⛔ BLOCKED · 1/2 · ✗midi ║
╠══════════════════════════════════════════════════════╣
║ #13 ▓▓▓ Piano arrangement v3 ║
║ ●M feat/piano-v3→main @mix-engine-7 4h ║
║ ✅ READY · 2/2 · AGENT ║
╚══════════════════════════════════════════════════════╝
Layout C — Domain Heat Header
╔══════════════════════════════════════════════════════════════════════════════╗
║ ACTIVE DOMAIN HEAT (6 open proposals) ║
║ CODE ████████████████████░░░░░░░░ 5/6 avg risk: HIGH ║
║ MIDI ████████░░░░░░░░░░░░░░░░░░░░ 2/6 avg risk: MED ║
║ PAY ████░░░░░░░░░░░░░░░░░░░░░░░░ 1/6 avg risk: LOW ║
╚══════════════════════════════════════════════════════════════════════════════╝
Layout D — Merge Readiness Widget
╔══════════════════════════════════════════════════════╗
║ MERGE READINESS ║
║ ✅ READY NOW 1 #13 piano-v3 ║
║ ⛔ BLOCKED 1 #14 auth-v2 ║
║ ⏳ SETTLING 1 #12 payments ║
║ 👁 NEEDS REVIEW 3 (2 code, 1 midi) ║
╚══════════════════════════════════════════════════════╝
The Row Data Model
class ProposalListEntry(TypedDict):
"""Enriched row for the proposals list view.
Produced by ``enrich_proposal_list_entry()`` from a single ``MusehubProposal``
ORM row. All computed fields are derived server-side; none are passed from
the client.
Fields prefixed ``domain_`` are only meaningful when the corresponding domain
appears in ``active_domains``. Callers must check membership before accessing
per-domain dicts.
Invariants:
- ``is_blocked`` is always ``len(blocked_by) > 0``
- ``aggregate_risk_score`` is always in ``[0.0, 1.0]``
- ``active_domains`` never contains a domain whose risk score is 0.0
- ``all_merge_conditions_met`` is ``False`` when ``approval_count < required_approvals``
- ``payment_settling`` is ``True`` only when ``state == "settling"``
and ``"pay" in active_domains``
"""
# ── Core (from ProposalResponse) ─────────────────────────────────────────
proposal_id: str
proposal_number: int
title: str
state: str # 7-state machine value
proposal_type: str # 7 proposal types
from_branch: str
to_branch: str
author: str
author_type: str # "human" | "agent" | "org"
created_at: str # ISO-8601
merged_at: str | None
is_draft: bool
# ── Dimensional activity ──────────────────────────────────────────────────
active_domains: list[str] # domains with actual diff content
domain_risk: dict[str, float] # per-domain 0.0–1.0 risk score
domain_risk_band: dict[str, str] # per-domain "critical"|"high"|"medium"|"low"
aggregate_risk_score: float # 0.0–1.0 weighted aggregate
aggregate_risk_band: str # "critical"|"high"|"medium"|"low"|"none"
# ── Review status ─────────────────────────────────────────────────────────
approval_count: int # count of distinct approved reviews
required_approvals: int # from merge_conditions or repo default
domains_approved: list[str] # domains with ≥1 approved review
domains_pending_review: list[str] # domains needing approval without it
all_merge_conditions_met: bool # True iff every merge_condition passes
# ── Dependency position ───────────────────────────────────────────────────
blocked_by: list[int] # proposal_numbers this depends on (unmerged)
blocks: list[int] # proposal_numbers this blocks
is_blocked: bool # len(blocked_by) > 0
# ── Code domain summary ───────────────────────────────────────────────────
symbols_changed: int
breakage_count: int
test_gap_count: int
touched_symbols_preview: list[str] # top 3 symbol addresses for hover tooltip
# ── MIDI domain summary ───────────────────────────────────────────────────
midi_tracks_changed: int
midi_notes_delta: int
harmonic_tension_delta: float | None
# ── Payment domain summary ────────────────────────────────────────────────
payment_claim_count: int
payment_ledger_delta_nano: int
payment_avax_address: str | None
payment_settling: bool # True when state="settling" and pay in active_domains
# ── Agent metadata ────────────────────────────────────────────────────────
agent_model: str | None
agent_spawned_by: str | None # parent human handle
Required Docstrings
Every new symbol must carry a complete docstring before the PR is merge-ready. Templates:
enrich_proposal_list_entry
async def enrich_proposal_list_entry(
proposal: MusehubProposal,
db: AsyncSession,
) -> ProposalListEntry:
"""Compute all display-facing fields for a single proposal list row.
This is the single source of truth for what the proposals list view renders
per row. It is designed to be called in parallel via
``enrich_proposal_list_batch()`` — all DB reads for a full page are
pre-fetched in one pass before this function runs, so no N+1 queries occur.
Computed fields (all server-side, none from client):
- active_domains: domains with non-zero risk or diff content
- domain_risk / domain_risk_band: read from ``musehub_proposal_risk``
- aggregate_risk_score: weighted mean of domain_risk values
- approval_count / domains_approved / domains_pending_review: derived
from ``musehub_proposal_reviews`` for this proposal_id
- all_merge_conditions_met: full MergeConditions evaluation
- blocked_by / blocks / is_blocked: from ``musehub_proposal_dependencies``
- per-domain summary fields (code / midi / payment)
- author_type: resolved from ``MusehubIdentity.identity_type``
Performance contract:
This function must not issue any DB queries. All DB reads for a page
of proposals must be batched and passed in via the ``prefetch`` arg
(see ``enrich_proposal_list_batch``). Typical latency: <5ms per row
when prefetch data is warm; <50ms for a 20-row page in total.
Args:
proposal: ORM row for this proposal. Must have repo_id set.
db: Async session shared across all rows in this page render.
Returns:
``ProposalListEntry`` with all fields populated. Never returns None.
Domains with zero risk are excluded from ``active_domains``.
Raises:
ValueError: If ``proposal.repo_id`` is absent or if any
``dimensional_risk`` value is outside ``[0.0, 1.0]``.
"""
enrich_proposal_list_batch
async def enrich_proposal_list_batch(
proposals: list[MusehubProposal],
db: AsyncSession,
) -> list[ProposalListEntry]:
"""Enrich a full page of proposal rows in a single parallel pass.
Executes one pre-fetch round to load all reviews, risk rows, and dependency
edges for every proposal in the batch. Then fans out to
``enrich_proposal_list_entry()`` via ``asyncio.gather()`` — each call reads
only from the in-memory prefetch maps, not from the DB.
Concurrency model:
Pre-fetch is sequential (3–4 queries). Per-row enrichment is
parallelised via gather() since each row is independent. No locks
or shared mutable state are used.
Args:
proposals: ORM rows for this page (typically ≤20).
db: Async session shared across the batch.
Returns:
List of ``ProposalListEntry`` in the same order as ``proposals``.
Performance target: <50ms for 20 proposals (measured in Tier 4 tests).
"""
get_domain_heat
async def get_domain_heat(
repo_id: str,
state: str,
db: AsyncSession,
) -> DomainHeatResponse:
"""Return per-domain proposal counts and average risk for the heat bar.
Runs a single aggregation query against ``musehub_proposal_risk`` joined
to ``musehub_proposals`` filtered by ``repo_id`` and ``state``.
The ``avg_risk`` per domain is the arithmetic mean of all non-zero
``risk_score`` values for proposals in the given state. Domains with
zero matching proposals are omitted from the response dict.
Args:
repo_id: Repository to query.
state: Proposal state to filter on (e.g. ``"open"``). Pass
``"open"`` for the standard heat bar.
db: Async session.
Returns:
``DomainHeatResponse`` with a ``domains`` dict mapping domain name
to ``{"count": int, "avg_risk": float}`` and a ``total_open`` int.
Performance target: <20ms (single aggregation, no per-row work).
"""
get_merge_readiness
async def get_merge_readiness(
repo_id: str,
db: AsyncSession,
) -> MergeReadinessResponse:
"""Bucket all non-merged proposals into readiness categories.
Categories:
ready: ``all_merge_conditions_met=True`` and ``is_blocked=False``
blocked: ``is_blocked=True`` (regardless of condition status)
settling: ``state="settling"``
needs_review: not blocked, not settling, conditions not fully met
Runs in two queries: one for proposal states, one for dependency edges.
No per-row enrichment — this is a lightweight bucketing pass.
Args:
repo_id: Repository to query.
db: Async session.
Returns:
``MergeReadinessResponse`` with ``ready``, ``blocked``, ``settling``,
and ``needs_review`` lists of proposal numbers.
Performance target: <20ms.
"""
proposal_rows_fragment
async def proposal_rows_fragment(
request: Request,
owner: SlugParam,
repo_slug: SlugParam,
filters: ProposalListFilters = Depends(),
db: AsyncSession = Depends(get_db),
) -> Response:
"""Return the bare ``#proposal-rows`` fragment for HTMX filter/sort swaps.
Identical to the rows section of ``proposal_list_page`` but returns only
the ``musehub/fragments/proposal_rows.html`` partial — no page shell,
nav, or heat bar. The client swaps ``hx-target="#proposal-rows"``.
Expects ``HX-Request: true`` header (enforced by FastAPI dependency).
Used by filter dropdowns, sort controls, and state tab clicks.
Args:
request: Starlette request (used for HTMX header detection).
owner: Validated repo owner slug.
repo_slug: Validated repo slug.
filters: Parsed query parameters via ``ProposalListFilters`` model.
db: Async session.
Returns:
HTML fragment (``Content-Type: text/html``) containing only the
proposal rows. Query params are reflected in the rendered filter
state so the client can reconstruct URL params.
"""
proposal_row_summary
async def proposal_row_summary(
request: Request,
owner: SlugParam,
repo_slug: SlugParam,
proposal_id: str,
db: AsyncSession = Depends(get_db),
) -> Response:
"""Return the inline expansion panel for a single proposal row.
Triggered by row click; swapped into ``#proposal-row-{id}-detail``.
Renders the full touched-symbols list, dimensional risk breakdown,
and dependency chain without requiring a page navigation.
Args:
request: Starlette request.
owner: Validated repo owner slug.
repo_slug: Validated repo slug.
proposal_id: Full proposal ID (``sha256:...``).
db: Async session.
Returns:
HTML fragment for the expansion panel.
Raises:
HTTPException(404): If ``proposal_id`` is not found in this repo.
Performance target: <20ms (single proposal enrichment, no batch needed).
"""
Filtering & Sorting Model
class ProposalListFilters(BaseModel):
"""Query parameters for the proposals list page and rows fragment.
All fields have safe defaults so the page renders correctly with zero
query params. ``state`` defaults to ``"open"`` (the most common view).
``limit`` is capped at 100 server-side; clients must not rely on values
above that being honoured.
``sort`` values:
newest: created_at DESC (default — most recent first)
oldest: created_at ASC
risk_desc: aggregate_risk_score DESC — critical proposals first
risk_asc: aggregate_risk_score ASC — safest proposals first
merge_ready_first: all_merge_conditions_met=True first, then risk_desc
``domain`` is repeatable (``?domain=code&domain=midi`` matches proposals
touching *either* domain). An empty list means all domains.
``proposal_type`` is likewise repeatable.
"""
state: Literal[
"open","in_review","approved","drafting","settling","merged","abandoned","all"
] = "open"
proposal_type: list[ProposalType] | None = None
domain: list[str] | None = None
risk_band: list[str] | None = None
author_type: Literal["human","agent","org","all"] = "all"
is_blocked: bool | None = None
assigned_reviewer: str | None = None
limit: int = Field(default=20, ge=1, le=100)
cursor: str | None = None
sort: Literal[
"newest","oldest","risk_desc","risk_asc","merge_ready_first"
] = "newest"
HTMX Architecture
Layer 1: SSR full page
GET /{owner}/{repo}/proposals?state=open
→ full HTML: domain heat bar, filter bar, row list
Layer 2: HTMX tab/filter swap (replaces #proposal-rows only)
hx-get="/{owner}/{repo}/proposals/rows"
hx-target="#proposal-rows"
hx-push-url="true"
hx-trigger="click, change"
Layer 3: HTMX row expansion (inline detail, no navigation)
hx-get="/{owner}/{repo}/proposals/{id}/summary"
hx-target="#proposal-row-{id}-detail"
hx-swap="innerHTML"
hx-trigger="click"
Fragment endpoints:
GET /{owner}/{repo}/proposals/rows ← #proposal-rows (filter/sort)
GET /{owner}/{repo}/proposals/heat ← #domain-heat (after filter)
GET /{owner}/{repo}/proposals/{id}/summary ← single-row expansion panel
New / Extended API Endpoints
GET /api/repos/{repo_id}/proposals
NEW params: type, domain, risk_band, author_type, is_blocked,
assigned_reviewer, sort
GET /api/repos/{repo_id}/proposals/heat
Returns: { "domains": {"code": {"count": 5, "avg_risk": 0.7}, ...}, "total_open": 6 }
GET /api/repos/{repo_id}/proposals/readiness
Returns: { "ready": [...], "blocked": [...], "settling": [...], "needs_review": [...] }
Symbol Map
| Symbol | File | Action |
|---|---|---|
ProposalListEntry |
musehub/models/musehub.py |
Add |
ProposalListFilters |
musehub/models/musehub.py |
Add |
DomainHeatResponse |
musehub/models/musehub.py |
Add |
MergeReadinessResponse |
musehub/models/musehub.py |
Add |
enrich_proposal_list_entry |
musehub/services/musehub_proposals.py |
Add |
enrich_proposal_list_batch |
musehub/services/musehub_proposals.py |
Add |
get_domain_heat |
musehub/services/musehub_proposals.py |
Add |
get_merge_readiness |
musehub/services/musehub_proposals.py |
Add |
list_proposals |
musehub/services/musehub_proposals.py |
Extend (new filter params) |
proposal_list_page |
musehub/api/routes/musehub/ui_proposals.py |
Extend |
proposal_rows_fragment |
musehub/api/routes/musehub/ui_proposals.py |
Add |
proposal_row_summary |
musehub/api/routes/musehub/ui_proposals.py |
Add |
infer_list_risk_band |
musehub/services/musehub_proposal_risk.py |
DELETE |
7-Tier Test Plan
Tier 1 — Unit
Pure function tests. No database. All assertions are synchronous.
ProposalListFilters shape
- Default
state="open",sort="newest",limit=20,author_type="all" limit=101raisesValidationError(max=100)limit=0raisesValidationError(min=1)state="unknown_state"raisesValidationErrorsort="unknown_sort"raisesValidationErrordomain=["code","midi"]stores both valuesassigned_reviewerwith control characters raisesValidationError
ProposalListEntry invariants (unit-test via direct construction)
is_blocked=Trueifflen(blocked_by) > 0— assert the TypedDict enforces this via the enrichment logicaggregate_risk_bandcorrectly mapsscore ≥ 0.75→"critical",≥ 0.5→"high",≥ 0.25→"medium",< 0.25→"low",0.0→"none"active_domainsnever includes a domain withdomain_risk[domain] == 0.0payment_settlingis onlyTruewhen bothstate == "settling"AND"pay" in active_domains
infer_list_risk_band is deleted
- Confirm the symbol is not importable from
musehub.services.musehub_proposal_risk - Confirm no call sites remain in any
.pyor.htmlfile
Edge cases (folded into unit)
- Proposal with
merge_conditions=None→required_approvalsfalls back to repo default (2) - Draft proposal →
author_typestill resolved; no special-casing ofis_draftin enrichment sort=risk_descon proposals all sharing the same score → stable secondary sort bycreated_at DESC- Very long title (500 chars) → truncated to 80 chars with
…in the list row template macro active_domains=[]→aggregate_risk_score=0.0,aggregate_risk_band="none", domain dots render as─blocked_bycontains only unmerged proposal numbers — merged deps are excluded
Tier 2 — Integration
All tests hit the real test database via AsyncSession. Use the project's db_session and client fixtures.
Enrichment correctness
- Create proposal with code+midi dimensional_risk rows →
active_domains=["code","midi"] - Proposal with
depends_on=[p2]where p2 is open →is_blocked=True,blocked_by=[p2.number] - Merge p2 → re-enrich p1 →
is_blocked=False,blocked_by=[] - Submit approved review for code domain →
domains_approved=["code"],domains_pending_reviewdrops"code" all_merge_conditions_met=Trueonly whenapproval_count >= required_approvals
list_proposals new filter params
domain=["code"]→ only proposals with"code"inactive_domainsreturnedrisk_band=["critical"]→ only proposals withaggregate_risk_band="critical"returnedauthor_type="agent"→ only agent-authored proposals returnedis_blocked=True→ only blocked proposals returnedsort=merge_ready_first→ ready proposals sort before non-ready onessort=risk_desc→ highestaggregate_risk_scorefirstproposal_type=["state_merge"]→ only state_merge proposals returnedassigned_reviewer="alice"→ only proposals where alice has a pending review- Cursor-based pagination with new filters: cursor carries through filter params correctly
Heat and readiness
- Create 3 open proposals: 2 code, 1 midi →
get_domain_heatreturnscode.count=2,midi.count=1 - Merge 1 code proposal → heat count drops to
code.count=1 - Proposal with all conditions met → appears in
get_merge_readiness().ready - Blocked proposal → appears in
get_merge_readiness().blocked state="settling"proposal → appears inget_merge_readiness().settling
Tier 3 — End-to-End
Full HTTP request/response cycle through the ASGI app. Tests use AsyncClient against the running FastAPI app.
SSR page renders
GET /{owner}/{repo}/proposals→ 200, HTML contains#proposal-rows,#domain-heatGET /{owner}/{repo}/proposals?state=merged→ 200, rows show merged proposals onlyGET /{owner}/{repo}/proposals?sort=risk_desc→ 200, highest-risk row appears first in HTMLGET /{owner}/{repo}/proposals?domain=code&risk_band=high→ 200, filter params reflected in form state
HTMX fragment endpoints
GET /{owner}/{repo}/proposals/rowswithHX-Request: true→ returns bare fragment (no<html>tag)GET /{owner}/{repo}/proposals/rowswithoutHX-Requestheader → redirects or renders full pageGET /{owner}/{repo}/proposals/{id}/summary→ 200, HTML contains touched symbols, risk breakdownGET /{owner}/{repo}/proposals/{id}/summaryfor unknown id → 404
API endpoints
GET /api/repos/{id}/proposalswithsort=merge_ready_first→ JSONproposalslist, ready proposals firstGET /api/repos/{id}/proposals/heat→ JSON{"domains": {...}, "total_open": N}GET /api/repos/{id}/proposals/readiness→ JSON withready,blocked,settling,needs_reviewGET /api/repos/{id}/proposalswithdomain=../../etc/passwd→ 422 validation errorGET /api/repos/{id}/proposalswithassigned_reviewer=<script>→ 400 before DB query
Auth
- All list endpoints: unauthenticated request to private repo → 403 (not 404)
GET /api/repos/{id}/proposalswithout auth headers → 401
Tier 4 — Stress
Concurrency and volume tests. Seed data via direct DB inserts (not HTTP). Use pytest-asyncio with asyncio.gather.
- 1,000 proposals in repo →
list_proposals(limit=20)returns in under 50ms (p99) enrich_proposal_list_batchon 20 proposals → under 100ms total (measured, not mocked)get_domain_heatwith 500 open proposals → under 30msget_merge_readinesswith 200 open proposals → under 30ms- 50 concurrent
GET /{owner}/{repo}/proposalsrequests → no deadlock, all complete under 200ms sort=risk_descon 1,000 proposals → index scan verified viaEXPLAIN ANALYZEdomain=code&risk_band=criticalcompound filter on 1,000 proposals → under 30ms
Tier 5 — Data Integrity
Assert that computed fields in ProposalListEntry are consistent with raw DB state.
domain_risk["code"]==risk_scoreinmusehub_proposal_riskfor that proposal and domainapproval_count==SELECT COUNT(*) FROM musehub_proposal_reviews WHERE proposal_id=X AND state="approved"blocked_bycontains only proposal numbers whose state is NOT"merged"or"abandoned"aggregate_risk_score== weighted mean ofdomain_riskvalues (weights defined per domain in config)active_domains⊆ domains wheremusehub_proposal_risk.risk_score > 0.0all_merge_conditions_met=Falsewhenapproval_count < required_approvalspayment_settlingagrees withstate="settling" AND "pay" in active_domainsin raw DB row- Heat bar
countfor"code"==len([p for p in open_proposals if "code" in p.active_domains])— assert via direct DB count required_approvalsfalls back to repo-level default whenmerge_conditionsis NULL
Tier 6 — Performance
Benchmarks with measured assertions. All run against a seeded test DB, not mocked.
| Scenario | Target |
|---|---|
enrich_proposal_list_entry (single, prefetch warm) |
< 5ms |
enrich_proposal_list_batch (20 proposals) |
< 50ms |
Full proposal_list_page SSR (20 rows) |
< 150ms |
proposal_rows_fragment (HTMX, rows only) |
< 80ms |
proposal_row_summary (single expansion) |
< 20ms |
get_domain_heat |
< 20ms |
get_merge_readiness |
< 20ms |
All list queries must use index scans — assert via EXPLAIN ANALYZE output in test (no Seq Scan on large tables).
Required DB indexes (Phase 7):
(repo_id, state, aggregate_risk_score DESC)onmusehub_proposals(repo_id, proposal_type, state)onmusehub_proposals(proposal_id, state)onmusehub_proposal_reviews(depends_on_id)onmusehub_proposal_dependencies
Tier 7 — Security
assigned_reviewerwith control chars, null bytes, or non-ASCII → 400 before any DB querydomain=../../etc/passwd→ rejected by domain allowlist enum; never reaches SQLproposal_type=malicious_type→ 422 from Pydantic before handler runslimit=99999→ 422 (exceeds max=100)- Forged/tampered
cursorvalue → graceful 400 or empty result; never 500 - Private repo proposals → anonymous request returns 403, not 404 (no existence leakage)
- Draft proposals → non-author cannot see them via the API (filtered server-side)
sort=merge_ready_firstwheremax_agent_commit_ratio=0.0merge condition is set → proposals blocked by that condition do not appear as ready- SQL injection attempt in
assigned_reviewer(e.g.' OR 1=1 --) → parameterised query, zero rows returned, no error
7-Phase Implementation Plan
Phase 1 — Data Models & Enrichment
Branch: task/proposal-list-models
- Add
ProposalListEntry,ProposalListFilters,DomainHeatResponse,MergeReadinessResponsetomusehub/models/musehub.py— all with full field-level docstrings - Implement
enrich_proposal_list_entry(proposal, db)inmusehub/services/musehub_proposals.py— no N+1: accepts prefetch maps, does zero DB I/O - Implement
enrich_proposal_list_batch(proposals, db)— pre-fetches all reviews, risk rows, dep edges in 3 queries, then fans out viaasyncio.gather() - Delete
infer_list_risk_bandfrommusehub/services/musehub_proposal_risk.pyand all imports/call sites - Tier 1 (unit) + Tier 5 (data integrity) tests
Docstring checklist: enrich_proposal_list_entry ✓ Args ✓ Returns ✓ Raises ✓ Performance contract ✓ Invariants
Phase 2 — Aggregate Query Functions
Branch: task/proposal-list-aggregates
get_domain_heat(repo_id, state, db)— single aggregation query, see docstring template aboveget_merge_readiness(repo_id, db)— two-query bucketing pass, see docstring template above- Extend
list_proposals(...)signature: addproposal_type,domain,risk_band,author_type,is_blocked,assigned_reviewer,sort— all validated viaProposalListFilters; update existing docstring GET /api/repos/{id}/proposals/heatendpointGET /api/repos/{id}/proposals/readinessendpoint- Tier 2 (integration) + Tier 6 (performance) + Tier 4 (stress) tests
Phase 3 — UI Route Extensions
Branch: task/proposal-list-routes
- Extend
proposal_list_page: callenrich_proposal_list_batch, pass fullProposalListEntrylist to template; removeinfer_list_risk_bandcall - Add
proposal_rows_fragmentroute — see docstring template above - Add
proposal_row_summaryroute — see docstring template above - Add
domain_heatfragment route for#domain-heatHTMX swap target - Tier 3 (E2E) + Tier 7 (security) tests
Phase 4 — Template Rewrite
Branch: task/proposal-list-templates
- Rewrite
musehub/templates/musehub/pages/proposal_list.html: domain heat bar, merge readiness widget, filter bar with allProposalListFiltersparams, 7-state tab bar - Rewrite
musehub/templates/musehub/fragments/proposal_rows.html: domain dot row, risk minibar, approval dots, dependency lock icon, author archetype signal, merge-ready indicator - Create
musehub/templates/musehub/fragments/proposal_row_detail.html: touched symbols list, full dimensional risk breakdown, dependency chain - Create
musehub/templates/musehub/fragments/domain_heat.html: standalone heat bar fragment for HTMX swap - Delete all
risk_bandstring references that came frominfer_list_risk_band
Phase 5 — TypeScript Controller
Branch: task/proposal-list-ts
Extend src/ts/pages/proposal-list.ts:
- Handle HTMX
after-swapon#proposal-rows— re-sync URL params, restart stagger animations - Animate
#domain-heatbar width changes with CSS transitions on swap - Persist filter selections in URL params via
history.replaceState - Toggle row expansion:
aria-expanded, height animation on detail panel - Poll
/readinessevery 30s when any proposal is insettlingstate
Phase 6 — MCP Context Extension
Branch: task/proposal-list-mcp
- Extend
ActiveProposalContextinmusehub/models/musehub_context.pywith list-relevant fields:active_domains,aggregate_risk_band,is_blocked,blocked_by,all_merge_conditions_met - Add
list_proposals_context(repo_id, filters)MCP tool — returns enriched list for agent consumption with full docstring - Agents can now scan the proposal queue as structured data and act (approve, simulate, comment) on specific proposals
Phase 7 — Index Optimisation
Branch: task/proposal-list-indexes
- Alembic migration: composite index
(repo_id, state, aggregate_risk_score DESC)onmusehub_proposals - Alembic migration: composite index
(repo_id, proposal_type, state)onmusehub_proposals - Alembic migration: composite index
(proposal_id, state)onmusehub_proposal_reviews - Alembic migration: index
(depends_on_id)onmusehub_proposal_dependencies - Add
EXPLAIN ANALYZEharness in Tier 6 test suite; assert noSeq Scanonmusehub_proposalsfor any filter combination
What Makes This Page Uniquely Muse
GitHub's PR list shows: title, branch, author, date, CI badge, review count.
The Muse proposals list shows:
- Which dimensions of reality are in motion — CODE + MIDI + PAY active simultaneously, per row, with per-domain risk scores derived from actual breakage counts and blast radius — not branch name prefixes.
- Dependency position in the DAG — blocked proposals surface their blocker inline; the queue is merge-orderable at a glance.
- Agent-authored proposals are cryptographically first-class —
author_typeis derived fromMusehubIdentity.identity_type, not a username pattern match. - Payment settlement proposals carry live on-chain status —
settlingstate shows the AVAX tx hash and live confirmation status. - Domain heat at the page level — before scanning any row, you see which dimensions are most active. No other VCS surfaces this.
Assignee
@aaronrene — data models and service functions from issue #2 Phase 1–3 are prerequisites. Coordinate with @gabriel on enrich_proposal_list_entry design before starting Phase 3 templates.
Phase 1 + Phase 2 complete ✓
Both phases are merged to dev and pushed to local.
Phase 1 — Data Models & Enrichment (sha256:5c66b2dba61f)
- Added
ProposalListEntry,ProposalListFilters,DomainHeatEntry,DomainHeatResponse,MergeReadinessResponsetomusehub/models/musehub.py - Implemented zero-N+1 enrichment in
musehub_proposals.py:_prefetch_for_batch(2 DB queries for a full page),_enrich_one(synchronous, zero DB I/O per row),enrich_proposal_list_batch,enrich_proposal_list_entry,get_domain_heat,get_merge_readiness - Deleted
infer_list_risk_bandfrommusehub_proposal_risk.pyand all 3 call sites (import in ui_proposals, call site in proposal_list_page, import + test class in test_merge_proposals) - 64 Tier-1 (unit) + Tier-5 (data integrity) tests — all passing
Phase 2 — Aggregate Query Functions (sha256:7861339a2946)
- Extended
list_proposals()withProposalListFilters:risk_band(score-range SQL predicates),domain(code domain viarisk_score > 0),author_type(LEFT JOIN tomusehub_identities),assigned_reviewer(EXISTS sub-select on reviews),sort(newest / oldest / risk_desc / risk_asc / merge_ready_first) - Fixed count query to reuse the data query's join — no Cartesian product on author_type filter
- Added
GET /api/repos/{id}/proposals/heat→DomainHeatResponse - Added
GET /api/repos/{id}/proposals/readiness→MergeReadinessResponse - Extended
GET /api/repos/{id}/proposalswith all filter params as query parameters - 32 Tier-2 (integration) + Tier-6 (performance) tests — all passing
Starting Phase 3 now — UI Route Extensions (task/proposal-list-routes)
Phase 3 complete ✓
Phase 3 — UI Route Extensions (sha256:b8c0baa7b01f)
Routes added/extended in ui_proposals.py:
proposal_list_page— now accepts allProposalListFiltersparams; callsenrich_proposal_list_batchso every rendered row carries the fullProposalListEntrypayload; heat + readiness fetched in parallel viaasyncio.gatherGET /{owner}/{repo}/proposals/rows— dedicated HTMX swap target for filter/sort/tab changes; non-HTMX → 302 redirectGET /{owner}/{repo}/proposals/{id}/summary— single-row expansion panel; 404 on unknown proposalGET /{owner}/{repo}/proposals/heat—#domain-heatHTMX fragment;stateparam supported
Templates added:
fragments/proposal_row_detail.html— risk breakdown, approval status, touched symbols, dep chainfragments/domain_heat.html— per-domain count + avg-risk heat bars
Stale test removed: test_proposal_detail_merge_button_has_hx_post (the HTMX merge form was removed in the MSign read-only sweep); replaced with test_proposal_detail_shows_cli_hint.
29 Tier-3 (E2E) + Tier-7 (security) tests — all passing.
Starting Phase 4 now — Template Rewrite (task/proposal-list-templates)
Phase 4 complete — Template rewrite ✅
Both proposal list templates fully rewritten to use ProposalListEntry fields.
proposal_list.html
- Domain heat bar (
#domain-heat) with HTMX outerHTML swap, 30s auto-poll when.prl-settlingpresent - Merge readiness widget: ready / blocked / settling / needs-review chip counts
- Extended state tabs: open / merged / abandoned / all
- 5 sort options: newest, oldest, risk ↓, risk ↑, ready first
- Author-type filter chips: all / human / agent
proposal_rows.html
aggregate_risk_bandreplaces the deletedinfer_list_risk_bandrisk_band- Domain activity dots (colour-coded by per-domain risk band)
- Approval pip dots (filled/empty per approval_count / required_approvals)
- Risk minibar (width = aggregate_risk_score %, band colour)
- Merge-ready ✅ indicator (
all_merge_conditions_met) - Dependency lock 🔒 icon with blocked-by numbers in tooltip
- Agent author 🤖 badge with model name tooltip
- Payment settling ⏳ badge (triggers HTMX 30s poll on page)
- Breakage count badge on title row
- HTMX lazy-load detail panel per row (loads
/proposals/{id}/summaryon first click) - Abandoned state label (was 'Closed')
Tests
29/29 E2E + security tests pass, 7/7 SSR tests pass.
Next: Phase 5 — TypeScript controller (proposal-list.ts)
Phases 5 & 6 complete ✅
Phase 5 — TypeScript controller (proposal-list.ts)
syncFilterBar()— now syncs author_type filter chips in addition to tabs and sorthookRowExpansion()/wireExpansion()— click handler toggles.prl-row--expandedanddetailWrap.hidden; HTMX lazy-loads panel content on first expand (hx-trigger once), subsequent clicks toggle show/hide.dataset.expansionWiredguard prevents duplicate listeners after HTMX row swapsmaybeStartReadinessPoll()— starts/stops a 30s native interval based on presence of.prl-settlingelements; independent from domain-heat HTMX auto-pollrefreshReadiness()— fetches/api/repos/{repoId}/proposals/readinessand updates ready/blocked/settling/needs-review chips; readsrepoIdfrom page data passed intoinitProposalList()
Phase 6 — MCP context extension
EnrichedProposalContext(ActiveProposalContext)inmusehub_context.py: adds risk band, risk score, active domains, domain risk bands, approval count, required approvals, merge-ready flag, blocked status, author type, breakage countmusehub_list_proposals_contextMCP tool: single call returns enriched triage data for up to 50 proposals — no per-proposal follow-up needed. Uses same inline select+sort pattern asui_proposals.py(no new service function)- Tool count: 119 → 120; all 43 MCP dispatcher tests pass
Next: Phase 7 — Index optimization (Alembic migrations + EXPLAIN ANALYZE harness)
Phase 7 complete — Issue #35 fully shipped ✅
Phase 7 — Index optimization
Migration 0044 adds 4 CONCURRENTLY-built composite indexes:
| Index | Columns | Pattern |
|---|---|---|
ix_musehub_proposals_repo_state_created |
(repo_id, state, created_at DESC) |
newest/oldest sort — 90% case |
ix_musehub_proposals_repo_state_risk |
(repo_id, state, risk_score DESC NULLS LAST) |
risk_desc/risk_asc sort |
ix_musehub_proposal_reviews_proposal_state |
(proposal_id, state) |
approval prefetch IN query |
ix_musehub_identities_handle_type |
(handle, identity_type) |
author_type JOIN filter |
ORM __table_args__ updated in MusehubProposal, MusehubProposalReview, MusehubIdentity.
EXPLAIN harness: 5 tests assert pg_catalog membership + index-based access plans.
Full issue #35 delivery summary
| Phase | What shipped |
|---|---|
| 1 | ProposalListEntry, ProposalListFilters, enrichment service (_enrich_one, _prefetch_for_batch), domain heat + merge readiness models |
| 2 | Extended list_proposals() with all filter/sort/pagination; heat + readiness API routes |
| 3 | UI routes: list page, rows fragment, row summary, heat fragment; HTMX swap targets |
| 4 | Template rewrite: proposal_list.html (heat bar, readiness widget, tabs, sort, author filter) + proposal_rows.html (domain dots, approval pips, risk minibar, all new indicators) |
| 5 | TypeScript controller: author_type sync, row expansion with lazy HTMX detail load, 30s readiness poll |
| 6 | MCP musehub_list_proposals_context tool — enriched triage in one call |
| 7 | 4 composite indexes + Alembic migration 0044 + EXPLAIN harness |
Test coverage: 64 unit + 32 integration + 29 E2E + 5 performance + 43 MCP dispatcher = 173 tests, all passing.
Shipped. Proposal list now shows all 7 signals at a glance: type, risk, merge strategy, blocked-by, ready-to-merge, settling, and draft state — all as prl-kv key:value chips. Right column has 3 rows: semver impact, health (breakages + gaps), and provenance (spectral sigil + author + timestamp). All tab is the default, nav badge counts all active states, page load dropped from ~9s to <250ms, light theme softened with violet-tinted base, and row layout is properly structured with no stretching hacks.
Ticket updated (see revised description). Restructured test plan into 7 canonical tiers — Unit, Integration, E2E, Stress, Data Integrity, Performance, Security. Edge cases folded into Tier 1. Docstring tier removed as a test tier and replaced with verbatim docstring templates woven into each phase of the implementation plan. Added grounding from code intel: infer_list_risk_band confirmed at musehub/services/musehub_proposal_risk.py:192, list_proposals confirmed at musehub/services/musehub_proposals.py:173. Ticket is now implementation-ready.