Skip to content

ux verify --contracts --managed: empty-error RBAC failures on ops_dashboard (Bug A: false failure, Bug B: unloggable error) #1072

@manwithacat

Description

@manwithacat

Symptom

$ cd examples/ops_dashboard
$ dazzle ux verify --contracts --managed
Generated 30 contracts
Running contract verification...
Contracts: 16 passed, 2 failed, 12 pending
  FAIL rbac:Alert:ops_engineer:list — 
  FAIL rbac:Alert:ops_engineer:create — 

Two RBAC contracts fail. Error messages are empty (the line is FAIL rbac:Alert:ops_engineer:list — with nothing after the em-dash). The same --contracts run against a live dazzle serve --local --test-mode server (no --managed) reports 18 passed, 0 failed, 12 pending.

The 2 contracts were generated by a recent contract-generation pass (count grew 28 → 30 between cycle 107 and now) and target the persona ops_engineer's ability to list/create Alert.

Direct managed-mode repro shows the behaviour is correct

import asyncio
from pathlib import Path
from dazzle.e2e.runner import ModeRunner
from dazzle.e2e.modes import get_mode
from dazzle.testing.ux.htmx_client import HtmxClient

async def run():
    async with ModeRunner(
        mode_spec=get_mode("a"),
        project_root=Path("examples/ops_dashboard"),
        personas=["ops_engineer"],
        db_policy="fresh",
    ) as conn:
        client = HtmxClient(base_url=conn.site_url)
        ok = await client.authenticate("ops_engineer")
        # auth_ok = True
        resp = await client.get_full_page("/app/alert")
        # status=200, html_len=5080
        # HAS 'data-dazzle-table'
        # HAS '/alert/create'

asyncio.run(run())

The same ModeRunner substrate the CLI uses, the same auth call, the same persona — returns 200 with both data-dazzle-table="Alert" and <a href="/app/alert/create"> present. _check_rbac should find both markers and mark passed.

But the CLI's dazzle ux verify --contracts --managed reports them as failed with an empty error.

Two distinct bugs

Bug A — wrong status

The contracts shouldn't fail. The underlying app is rendering correctly for ops_engineer. Possible causes:

  1. The CLI's RBAC loop in src/dazzle/cli/ux.py:308-365 creates a fresh HtmxClient per persona (persona_client = HtmxClient(base_url=site_url)), authenticates, then checks. Maybe the session cookie isn't persisted across the auth → fetch sequence in some managed-mode environment.
  2. Maybe the managed-mode subprocess does some cleanup that invalidates the session between auth and the GET (e.g., per-test DB reset, port re-allocation).
  3. Maybe _get_permitted_personas (line 308 region) is filtering out ops_engineer incorrectly, causing the contract to be checked with expected_present=False while the page actually renders the markers — but then errors would say "expected absent, but found" which is non-empty.

Bug B — empty error message

Per src/dazzle/testing/ux/contract_checker.py:707-712:

if errors:
    contract.status = "failed"
    contract.error = "; ".join(errors)
else:
    contract.status = "passed"
    contract.error = None

A "failed" status MUST come with a non-empty error message — "; ".join([]) is "" but the else-branch sets status="passed" first. So the empty error implies the contract was set to status="failed" outside check_contract with error="".

The most likely candidate is line 365: rc.error = str(e) inside except Exception as e. Some exception's __str__ returns "". Possible culprits: requests or httpx exceptions where the message is in .args[0] not str, or a raised Exception("") somewhere in the stack.

This is a logging-clarity bug regardless of bug A: a FAIL with no error message is unactionable.

Repro environment

  • Branch: main (commit c1a04a6, v0.67.146)
  • Python 3.12.11
  • macOS 25.5.0 (darwin)
  • Triggered by /improve cycle 118-119 ux-converge sweep — see dev_docs/improve-backlog.md Lane: ux-converge row for ops_dashboard.

Suggested fix path

  1. Add a print(f"DEBUG check_contract({contract.contract_id}) → status={status}, error={error!r}") near src/dazzle/cli/ux.py:382 and re-run managed mode to confirm which code path is setting status without a message. Promote to proper logging once located.
  2. Wrap the try/except at src/dazzle/cli/ux.py:362-365 with a guard that synthesises a fallback error when str(e) is empty (rc.error = str(e) or f"{type(e).__name__}: <no message>").
  3. Trace bug A from the debug trace's source.

Discovered by

/improve cycle 119 ux-converge lane. ops_dashboard regressed 0 → 2 contract failures between cycle 107 and 118; cycle 119's CONVERGE step tried to root-cause and found that the runtime behaviour is correct, the contract plumbing is what's broken.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions