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:
- 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.
- 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).
- 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
- 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.
- 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>").
- 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.
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--contractsrun against a livedazzle serve --local --test-modeserver (no--managed) reports18 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
The same
ModeRunnersubstrate the CLI uses, the same auth call, the same persona — returns 200 with bothdata-dazzle-table="Alert"and<a href="/app/alert/create">present._check_rbacshould find both markers and markpassed.But the CLI's
dazzle ux verify --contracts --managedreports them asfailedwith 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:src/dazzle/cli/ux.py:308-365creates a freshHtmxClientper 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._get_permitted_personas(line 308 region) is filtering outops_engineerincorrectly, causing the contract to be checked withexpected_present=Falsewhile 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:A "failed" status MUST come with a non-empty error message —
"; ".join([])is""but the else-branch setsstatus="passed"first. So the empty error implies the contract was set tostatus="failed"outsidecheck_contractwitherror="".The most likely candidate is line 365:
rc.error = str(e)insideexcept Exception as e. Some exception's__str__returns"". Possible culprits:requestsorhttpxexceptions where the message is in.args[0]not str, or a raisedException("")somewhere in the stack.This is a logging-clarity bug regardless of bug A: a FAIL with no error message is unactionable.
Repro environment
/improvecycle 118-119 ux-converge sweep — seedev_docs/improve-backlog.mdLane: ux-converge row for ops_dashboard.Suggested fix path
print(f"DEBUG check_contract({contract.contract_id}) → status={status}, error={error!r}")nearsrc/dazzle/cli/ux.py:382and re-run managed mode to confirm which code path is setting status without a message. Promote to proper logging once located.src/dazzle/cli/ux.py:362-365with a guard that synthesises a fallback error whenstr(e)is empty (rc.error = str(e) or f"{type(e).__name__}: <no message>").Discovered by
/improvecycle 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.