feat(web): operator-facing webUX MVP (Starlette+HTMX over PlainweaveService)#1
Conversation
…lpers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lope Producer-side conformance for plainweave's weft.plainweave.preflight_facts.v1 envelope (ADR-006). Re-captures the golden from the REAL producer PlainweaveMcpSurface.plainweave_preflight_facts_get over a fixed seeded project (replacing the stale hand-authored fixture — only producer.version 0.0.1->1.0.0 differed) + a Layer-1 byte-pin + a NON-CIRCULAR producer-source recheck that re-invokes the live producer and asserts == golden. The 2 non-deterministic fields (generated_at, producer.version) are validated against the live producer (aware ISO-8601 UTC; == plainweave.__version__) BEFORE normalizing, so drift reds non-circularly. Consumer side is legis (ringfenced/absent) -> producer-only. 2 tests pass; byte-pin + recheck both red on tamper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…SRF middleware Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace ``await request.form()`` in the CSRF BaseHTTPMiddleware with ``await request.body()`` + ``parse_qsl`` so Starlette's _CachedRequest can replay the raw bytes to downstream POST handlers. The old form() call consumed the body stream, leaving every downstream request.form() empty. Add regression test (RED->GREEN verified) that splices a /echo route and asserts the submitted field value arrives intact after CSRF validation. Also type the test fixtures properly (drop type:ignore suppressions) and extend test_error_status_mapping to cover all 10 ErrorCode members. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
plainweave (ex-charter, 1.0.0) consumes loomweave SEI via loomweave_adapter but was the SEI laggard (no §8 oracle). Add tests/conformance/test_sei_oracle.py: vendor loomweave's sei-conformance-oracle.json BYTE-IDENTICAL (same upstream pin 0ea5770...) + Layer-1 UPSTREAM_BLOB_SHA byte-pin + Layer-2 sei_drift recheck (PLAINWEAVE_SEI_DRIFT_REQUIRED arming, skip-clean absent) + the 6 §8 scenarios driving the REAL LoomweaveAdapter resolve path via injected fetch (non-circular). Load-bearing production fix (loomweave_adapter.py, +32 lines, additive): add _probe_sei_capability() gating the HTTP resolve on the /api/v1/_capabilities sei.supported wire — so capability_absent degrades HONESTLY (reason='unsupported') instead of conflating 'no SEI capability' with 'down' (reason='unreachable'). This closes the §8 capability_absent gap genuinely, not by faking it. All 6 scenarios pass; byte-identical to loomweave authority; byte-pin reds on tamper. Full suite 248 passed (+12 conformance), only the pre-existing stale uv-tool version test fails (unrelated). ruff+mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…requirements call - _resolve_titles now takes (svc, records) instead of ctx, removing the duplicate search_requirements() call in corpus() - Add test_corpus_approved_req_shows_version_title to cover the approved version title path in _resolve_titles (requirements.py now 100% covered) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds test_requirement_detail_approved_version_block which creates a
requirement, approves it (clears active_draft), then GETs /req/{id} and
asserts the "Current approved" heading and statement are present and the
draft section is absent. Includes a comment explaining why the
both-blocks case is unreachable via the public API (approve_requirement
and supersede_requirement both null out active_draft_id; no verb opens a
draft on an already-approved requirement).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rsisted Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…state and focus script Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…result Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d OOB result Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-13 coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…l warning text)
The previous assertion `"drifted" in confirm.text.lower()` matched only the
CSS class `queue-item--drifted`. Strengthen to verify the real badge text
("CODE DRIFTED"), the role=alert paragraph phrase, the CSRF hidden input,
the hx-post accept target, and the hx-get card-restore Cancel target.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add tests/web/test_a11y_contracts.py locking the §4.1/§12 a11y contracts: SR status live region + skip-link in base.html, visible <label> for the corpus search input, and per-item interpolated aria-label on every Approve button in the review queue. Append an NVDA/VoiceOver manual AT gate checklist to the README web section. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…a request.state
On the very first browser request (no pw_csrf cookie yet), csrf.html was reading
request.cookies.get('pw_csrf', ''), which returns '' because the cookie is set on
the *response*, not the incoming *request*. The form then embedded an empty _csrf
value; the next POST compared '' against the newly-set cookie → 403.
Fix: mint the token before call_next and store it as request.state.csrf_token
(scope-backed, shared into the handler). The template now reads
request.state.csrf_token. The cookie is still set on the response when absent.
Adds regression tests:
- test_csrf_cold_start_first_render_embeds_real_token (cold flow, RED before fix)
- test_wrong_csrf_token_still_403 (gate still fires on wrong token)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hygiene
FIX 2 — Authority attribution tests (tests/web/test_review.py):
- test_approve_attributes_operator_as_approver: asserts approved_by == "human:alice"
- test_accept_link_attributes_operator: asserts accepted_by == "human:alice"
These lock the product's core human-authority-in-the-audit-trail invariant.
FIX 3 — Malformed form input → 400, not 500:
- Added _require_int/_require_str/_optional_int helpers in review.py/requirements.py
that raise PlainweaveError(VALIDATION) → 400 instead of crashing with KeyError/ValueError
- test_approve_non_integer_expected_version_returns_400 asserts the gate
FIX 4 — Remove dead "Deprecated" status filter option from corpus_filter.html;
intent_corpus() only returns draft+approved so the toggle was always empty/misleading.
FIX 5 — Warning hygiene (315 → 0 under make ci):
- Fixed 2 tests that passed cookies= per-request (now use client.cookies.set())
- Added filterwarnings in pyproject.toml for httpx/httpx2 StarletteDeprecationWarning
(third-party, unfixable) and pre-existing SQLite ResourceWarning under --cov
(store-layer connection leak, tracked separately)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6727ac4fe4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| <p>Approve <strong>{{ title }}</strong> as <strong>version {{ next_version }}</strong>? This cannot be undone — there is no un-approve.</p> | ||
| <form hx-post="/req/{{ req_id }}/approve" hx-target="#queue-item-{{ req_id }}" hx-swap="outerHTML"> | ||
| {% include "_partials/csrf.html" %} | ||
| <input type="hidden" name="expected_version" value="{{ current_version }}"> |
There was a problem hiding this comment.
Check the draft revision before approval
When an operator opens this confirmation and another tab edits the active draft before the submit, current_version remains unchanged, so the POST still passes approve_requirement(... expected_version=...) and approves the newer, unseen draft while the dialog described the older content. Include and validate the draft revision (or recompare the draft being approved) before ratifying it.
Useful? React with 👍 / 👎.
| return templates.TemplateResponse( | ||
| request, | ||
| "_partials/edit_conflict.html", |
There was a problem hiding this comment.
Render a full conflict page for non-HTMX submits
This conflict branch is reachable from the full-page edit form, which submits with normal method="post" rather than an HTMX request. On a stale revision, the browser navigates to this partial without base.html or the htmx script, so the follow-up “Save my edits” form in edit_conflict.html depends on hx-post but cannot run after the navigation; render a full page when HX-Request is absent or make the original edit form an HTMX target.
Useful? React with 👍 / 👎.
| item = _link_item(ctx.service, link_id) | ||
| ctx.service.accept_trace_link(link_id, actor=ctx.operator.actor_id) |
There was a problem hiding this comment.
Require drift acknowledgement on accept
If a proposed Loomweave link is rendered as current and then drifts before the operator clicks Accept (or a valid-CSRF POST is sent directly), _link_item now reports item.drifted, but this path ignores it and calls accept_trace_link anyway. That bypasses the extra confirmation and hidden acknowledgement in link_accept_drifted_confirm.html, ratifying changed code without the operator acknowledging the drift.
Useful? React with 👍 / 👎.
| # self-registration is permitted; once an attester exists, only an existing | ||
| # attester may (re)register a human — surface that clearly rather than crashing. | ||
| try: | ||
| service.register_actor(actor_id, kind="human", display_name=display, actor=actor_id) |
There was a problem hiding this comment.
Avoid registering the actor on every request
Because ctx_factory() calls this for each routed page load, even a read-only GET for /, /review, or /intent invokes register_actor, which always writes an actor_registered event even when the same actor is already present. This makes ordinary browsing mutate the append-only audit log and fills it with duplicate privileged registration events; only the initial missing-actor path should perform the registration.
Useful? React with 👍 / 👎.
|
|
||
| @classmethod | ||
| def from_root(cls, root: Path | None, *, actor: str | None) -> RequestContext: | ||
| service = PlainweaveService(plainweave_db_path(root), root=root) |
There was a problem hiding this comment.
Handle an uninitialized store before serving
When the web UI is launched in a fresh checkout before plainweave init, this constructs a service for a database under a missing .plainweave/ directory; the first page request then reaches actor registration and raises sqlite3.OperationalError instead of a PlainweaveError with recovery guidance. The new README web launch path does not mention a required init step, so the operator console fails as a 500 unless this path initializes the store or mirrors the CLI's NOT_FOUND hint.
Useful? React with 👍 / 👎.
| remaining = _pending_count(ctx.service) | ||
| return templates.TemplateResponse( | ||
| request, | ||
| "_partials/queue_action_result.html", |
There was a problem hiding this comment.
Refresh the dossier after approving there
The approve endpoint is also invoked from the requirement detail page, but after a successful approval it always returns the review-queue action partial. From /req/{id} that only removes the temporary confirmation card, leaving the surrounding draft section and old statement visible even though the requirement has just been approved; return a refreshed dossier/redirect for non-queue callers instead of the queue-only partial.
Useful? React with 👍 / 👎.
| (function () { | ||
| document.body.addEventListener('htmx:afterSettle', function (evt) { | ||
| var elt = evt.detail.requestConfig && evt.detail.requestConfig.elt; | ||
| if (!elt || !elt.closest('.qi-actions')) return; |
There was a problem hiding this comment.
Move focus after confirmed actions too
The focus-management code only runs when the element that triggered the settled HTMX request is inside .qi-actions. The actual approve-confirm, drift-confirm, and reject-confirm forms are rendered outside that container, so completing those actions removes the card but skips the documented focus move to the next queue action or empty heading for keyboard/screen-reader users.
Useful? React with 👍 / 👎.
…ight-facts golden) into webux branch # Conflicts: # pyproject.toml
|
Consolidated the other outstanding work into this branch so nothing is stranded: merged |
…HANGELOG, changelog URL Working set on feat/weft-sei-conformance, committed so it isn't stranded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts: # README.md
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4b1a3990c4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| @pytest.mark.sei_drift | ||
| def test_vendored_oracle_matches_loomweave_source() -> None: |
There was a problem hiding this comment.
Gate the drift oracle outside default pytest runs
This marker does not make the test opt-in: checked pytest --help, and -m is the option that filters marked tests, while the repo's make ci runs plain pytest/pytest --cov with no marker exclusion. On any developer or CI machine where /home/john/loomweave (or LOOMWEAVE_REPO) points at a non-identical oracle, the ordinary gate will fail here even though the docstring says this is an opt-in release-gate recheck; skip unless explicitly armed or configure the default test command to exclude sei_drift.
Useful? React with 👍 / 👎.
| except ModuleNotFoundError: | ||
| print(WEB_EXTRA_HINT) |
There was a problem hiding this comment.
Catch missing Jinja2 when the web extra is absent
In a normal install without plainweave[web], the locked base dependency mcp already brings in starlette and uvicorn but not jinja2, so _serve() can get past its imports and then fail when create_app() constructs Jinja2Templates; Starlette reports that missing template engine as a non-ModuleNotFoundError, so this handler does not print the promised web-extra hint. Preflight/import jinja2 explicitly here or catch that missing-Jinja failure too.
Useful? React with 👍 / 👎.
| @@ -0,0 +1,11 @@ | |||
| {% for row in rows %} | |||
| <tr class="corpus-row" hx-get="/req/{{ row.req_id }}/inline" hx-target="#req-detail-{{ row.req_id }}" hx-swap="innerHTML" style="cursor:pointer"> | |||
There was a problem hiding this comment.
Provide a keyboard-operable corpus row action
The requirement title/status row is the only visible control that expands a requirement and reveals the “Full dossier” link, but a <tr> with hx-get is not focusable or keyboard-activatable. In the corpus table, keyboard and screen-reader users cannot open a row or reach the dossier unless they already know the URL; make the title a real link/button or add equivalent keyboard semantics.
Useful? React with 👍 / 👎.
Bump version 1.0.0 → 1.1.0 and add the [1.1.0] CHANGELOG section. The 1.1.0 code already landed on main via PR #1 (operator-facing webUX MVP + SEI conformance consolidation); this commit carries the release ceremony so release/1.1.0 is a reviewable release gate. Additive and backward-compatible: the 1.0 CLI, MCP surface, store schema, and JSON envelopes are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds Plainweave's operator-facing webUX — the human-facing seam of Weft — as a working local MVP. A thin Starlette + Jinja2 + HTMX web tier under
src/plainweave/web/sits on the existingPlainweaveService(no business logic in the web layer; the service stays the single source of truth). Shipped as an optionalplainweave[web]extra, so a barepip install plainweavestays one-dep.What's in it
aria-labels, focusable empty state).intent_coveragenorth-star + orphans, with an honest no-silent-clean degraded-peer banner) and goals + laddering.kind="human"operator actor — the authority linchpin), CSRF, centralizedPlainweaveError→HTTP mapping. One additive service read (list_goals()).plainweave web [--actor … --host --port --no-open](friendly hint + non-zero exit if the extra isn't installed).Process & quality
make cigreen: 298 passed, 90.72% coverage, mypy --strict + ruff clean, output pristine. Wardline clean on the form-input boundary.Docs
docs/superpowers/specs/2026-06-25-plainweave-webux-design.mddocs/superpowers/plans/2026-06-25-plainweave-webux-mvp.mdDeferred (documented, non-blocking)
SEI-binding display on detail, similarity hint, verification/baseline surfaces, multi-user/auth; full-page (non-HTMX) error styling; tightening redirect-status assertions; wiring real drift detection (§10.2). A manual NVDA/VoiceOver AT pass is recorded as a pre-ship gate for the review surface.
🤖 Generated with Claude Code