gabriel / musehub public
Closed #35
filed by gabriel human · 43 days ago

Proposals List: State Transition Queue — Mission Control Dashboard

0 Anchors
Blast radius
Churn 30d
0 Proposals

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=101 raises ValidationError (max=100)
  • limit=0 raises ValidationError (min=1)
  • state="unknown_state" raises ValidationError
  • sort="unknown_sort" raises ValidationError
  • domain=["code","midi"] stores both values
  • assigned_reviewer with control characters raises ValidationError

ProposalListEntry invariants (unit-test via direct construction)

  • is_blocked=True iff len(blocked_by) > 0 — assert the TypedDict enforces this via the enrichment logic
  • aggregate_risk_band correctly maps score ≥ 0.75"critical", ≥ 0.5"high", ≥ 0.25"medium", < 0.25"low", 0.0"none"
  • active_domains never includes a domain with domain_risk[domain] == 0.0
  • payment_settling is only True when both state == "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 .py or .html file

Edge cases (folded into unit)

  • Proposal with merge_conditions=Nonerequired_approvals falls back to repo default (2)
  • Draft proposal → author_type still resolved; no special-casing of is_draft in enrichment
  • sort=risk_desc on proposals all sharing the same score → stable secondary sort by created_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_by contains 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_review drops "code"
  • all_merge_conditions_met=True only when approval_count >= required_approvals

list_proposals new filter params

  • domain=["code"] → only proposals with "code" in active_domains returned
  • risk_band=["critical"] → only proposals with aggregate_risk_band="critical" returned
  • author_type="agent" → only agent-authored proposals returned
  • is_blocked=True → only blocked proposals returned
  • sort=merge_ready_first → ready proposals sort before non-ready ones
  • sort=risk_desc → highest aggregate_risk_score first
  • proposal_type=["state_merge"] → only state_merge proposals returned
  • assigned_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_heat returns code.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 in get_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-heat
  • GET /{owner}/{repo}/proposals?state=merged → 200, rows show merged proposals only
  • GET /{owner}/{repo}/proposals?sort=risk_desc → 200, highest-risk row appears first in HTML
  • GET /{owner}/{repo}/proposals?domain=code&risk_band=high → 200, filter params reflected in form state

HTMX fragment endpoints

  • GET /{owner}/{repo}/proposals/rows with HX-Request: true → returns bare fragment (no <html> tag)
  • GET /{owner}/{repo}/proposals/rows without HX-Request header → redirects or renders full page
  • GET /{owner}/{repo}/proposals/{id}/summary → 200, HTML contains touched symbols, risk breakdown
  • GET /{owner}/{repo}/proposals/{id}/summary for unknown id → 404

API endpoints

  • GET /api/repos/{id}/proposals with sort=merge_ready_first → JSON proposals list, ready proposals first
  • GET /api/repos/{id}/proposals/heat → JSON {"domains": {...}, "total_open": N}
  • GET /api/repos/{id}/proposals/readiness → JSON with ready, blocked, settling, needs_review
  • GET /api/repos/{id}/proposals with domain=../../etc/passwd → 422 validation error
  • GET /api/repos/{id}/proposals with assigned_reviewer=<script> → 400 before DB query

Auth

  • All list endpoints: unauthenticated request to private repo → 403 (not 404)
  • GET /api/repos/{id}/proposals without 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_batch on 20 proposals → under 100ms total (measured, not mocked)
  • get_domain_heat with 500 open proposals → under 30ms
  • get_merge_readiness with 200 open proposals → under 30ms
  • 50 concurrent GET /{owner}/{repo}/proposals requests → no deadlock, all complete under 200ms
  • sort=risk_desc on 1,000 proposals → index scan verified via EXPLAIN ANALYZE
  • domain=code&risk_band=critical compound 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_score in musehub_proposal_risk for that proposal and domain
  • approval_count == SELECT COUNT(*) FROM musehub_proposal_reviews WHERE proposal_id=X AND state="approved"
  • blocked_by contains only proposal numbers whose state is NOT "merged" or "abandoned"
  • aggregate_risk_score == weighted mean of domain_risk values (weights defined per domain in config)
  • active_domains ⊆ domains where musehub_proposal_risk.risk_score > 0.0
  • all_merge_conditions_met=False when approval_count < required_approvals
  • payment_settling agrees with state="settling" AND "pay" in active_domains in raw DB row
  • Heat bar count for "code" == len([p for p in open_proposals if "code" in p.active_domains]) — assert via direct DB count
  • required_approvals falls back to repo-level default when merge_conditions is 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) on musehub_proposals
  • (repo_id, proposal_type, state) on musehub_proposals
  • (proposal_id, state) on musehub_proposal_reviews
  • (depends_on_id) on musehub_proposal_dependencies

Tier 7 — Security

  • assigned_reviewer with control chars, null bytes, or non-ASCII → 400 before any DB query
  • domain=../../etc/passwd → rejected by domain allowlist enum; never reaches SQL
  • proposal_type=malicious_type → 422 from Pydantic before handler runs
  • limit=99999 → 422 (exceeds max=100)
  • Forged/tampered cursor value → 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_first where max_agent_commit_ratio=0.0 merge 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

  1. Add ProposalListEntry, ProposalListFilters, DomainHeatResponse, MergeReadinessResponse to musehub/models/musehub.py — all with full field-level docstrings
  2. Implement enrich_proposal_list_entry(proposal, db) in musehub/services/musehub_proposals.py — no N+1: accepts prefetch maps, does zero DB I/O
  3. Implement enrich_proposal_list_batch(proposals, db) — pre-fetches all reviews, risk rows, dep edges in 3 queries, then fans out via asyncio.gather()
  4. Delete infer_list_risk_band from musehub/services/musehub_proposal_risk.py and all imports/call sites
  5. 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

  1. get_domain_heat(repo_id, state, db) — single aggregation query, see docstring template above
  2. get_merge_readiness(repo_id, db) — two-query bucketing pass, see docstring template above
  3. Extend list_proposals(...) signature: add proposal_type, domain, risk_band, author_type, is_blocked, assigned_reviewer, sort — all validated via ProposalListFilters; update existing docstring
  4. GET /api/repos/{id}/proposals/heat endpoint
  5. GET /api/repos/{id}/proposals/readiness endpoint
  6. Tier 2 (integration) + Tier 6 (performance) + Tier 4 (stress) tests

Phase 3 — UI Route Extensions

Branch: task/proposal-list-routes

  1. Extend proposal_list_page: call enrich_proposal_list_batch, pass full ProposalListEntry list to template; remove infer_list_risk_band call
  2. Add proposal_rows_fragment route — see docstring template above
  3. Add proposal_row_summary route — see docstring template above
  4. Add domain_heat fragment route for #domain-heat HTMX swap target
  5. Tier 3 (E2E) + Tier 7 (security) tests

Phase 4 — Template Rewrite

Branch: task/proposal-list-templates

  1. Rewrite musehub/templates/musehub/pages/proposal_list.html: domain heat bar, merge readiness widget, filter bar with all ProposalListFilters params, 7-state tab bar
  2. Rewrite musehub/templates/musehub/fragments/proposal_rows.html: domain dot row, risk minibar, approval dots, dependency lock icon, author archetype signal, merge-ready indicator
  3. Create musehub/templates/musehub/fragments/proposal_row_detail.html: touched symbols list, full dimensional risk breakdown, dependency chain
  4. Create musehub/templates/musehub/fragments/domain_heat.html: standalone heat bar fragment for HTMX swap
  5. Delete all risk_band string references that came from infer_list_risk_band

Phase 5 — TypeScript Controller

Branch: task/proposal-list-ts

Extend src/ts/pages/proposal-list.ts:

  1. Handle HTMX after-swap on #proposal-rows — re-sync URL params, restart stagger animations
  2. Animate #domain-heat bar width changes with CSS transitions on swap
  3. Persist filter selections in URL params via history.replaceState
  4. Toggle row expansion: aria-expanded, height animation on detail panel
  5. Poll /readiness every 30s when any proposal is in settling state

Phase 6 — MCP Context Extension

Branch: task/proposal-list-mcp

  1. Extend ActiveProposalContext in musehub/models/musehub_context.py with list-relevant fields: active_domains, aggregate_risk_band, is_blocked, blocked_by, all_merge_conditions_met
  2. Add list_proposals_context(repo_id, filters) MCP tool — returns enriched list for agent consumption with full docstring
  3. 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

  1. Alembic migration: composite index (repo_id, state, aggregate_risk_score DESC) on musehub_proposals
  2. Alembic migration: composite index (repo_id, proposal_type, state) on musehub_proposals
  3. Alembic migration: composite index (proposal_id, state) on musehub_proposal_reviews
  4. Alembic migration: index (depends_on_id) on musehub_proposal_dependencies
  5. Add EXPLAIN ANALYZE harness in Tier 6 test suite; assert no Seq Scan on musehub_proposals for 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:

  1. 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.
  2. Dependency position in the DAG — blocked proposals surface their blocker inline; the queue is merge-orderable at a glance.
  3. Agent-authored proposals are cryptographically first-classauthor_type is derived from MusehubIdentity.identity_type, not a username pattern match.
  4. Payment settlement proposals carry live on-chain statussettling state shows the AVAX tx hash and live confirmation status.
  5. 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.

Activity7
gabriel opened this issue 43 days ago
gabriel 42 days ago

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.

gabriel 42 days ago

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, MergeReadinessResponse to musehub/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_band from musehub_proposal_risk.py and 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() with ProposalListFilters: risk_band (score-range SQL predicates), domain (code domain via risk_score > 0), author_type (LEFT JOIN to musehub_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/heatDomainHeatResponse
  • Added GET /api/repos/{id}/proposals/readinessMergeReadinessResponse
  • Extended GET /api/repos/{id}/proposals with 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)

gabriel 42 days ago

Phase 3 complete ✓

Phase 3 — UI Route Extensions (sha256:b8c0baa7b01f)

Routes added/extended in ui_proposals.py:

  • proposal_list_page — now accepts all ProposalListFilters params; calls enrich_proposal_list_batch so every rendered row carries the full ProposalListEntry payload; heat + readiness fetched in parallel via asyncio.gather
  • GET /{owner}/{repo}/proposals/rows — dedicated HTMX swap target for filter/sort/tab changes; non-HTMX → 302 redirect
  • GET /{owner}/{repo}/proposals/{id}/summary — single-row expansion panel; 404 on unknown proposal
  • GET /{owner}/{repo}/proposals/heat#domain-heat HTMX fragment; state param supported

Templates added:

  • fragments/proposal_row_detail.html — risk breakdown, approval status, touched symbols, dep chain
  • fragments/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)

gabriel 42 days ago

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-settling present
  • 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_band replaces the deleted infer_list_risk_band risk_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}/summary on 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)

gabriel 42 days ago

Phases 5 & 6 complete ✅

Phase 5 — TypeScript controller (proposal-list.ts)

  • syncFilterBar() — now syncs author_type filter chips in addition to tabs and sort
  • hookRowExpansion() / wireExpansion() — click handler toggles .prl-row--expanded and detailWrap.hidden; HTMX lazy-loads panel content on first expand (hx-trigger once), subsequent clicks toggle show/hide. dataset.expansionWired guard prevents duplicate listeners after HTMX row swaps
  • maybeStartReadinessPoll() — starts/stops a 30s native interval based on presence of .prl-settling elements; independent from domain-heat HTMX auto-poll
  • refreshReadiness() — fetches /api/repos/{repoId}/proposals/readiness and updates ready/blocked/settling/needs-review chips; reads repoId from page data passed into initProposalList()

Phase 6 — MCP context extension

  • EnrichedProposalContext(ActiveProposalContext) in musehub_context.py: adds risk band, risk score, active domains, domain risk bands, approval count, required approvals, merge-ready flag, blocked status, author type, breakage count
  • musehub_list_proposals_context MCP tool: single call returns enriched triage data for up to 50 proposals — no per-proposal follow-up needed. Uses same inline select+sort pattern as ui_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)

gabriel 42 days ago

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.

gabriel 42 days ago

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.