Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1120cd9
feat(web): add plainweave[web] extra and lazy 'plainweave web' CLI
tachyon-beep Jun 25, 2026
7f80c70
chore(web): add Task-3 removal hint to type: ignore on create_app import
tachyon-beep Jun 25, 2026
eaf5410
feat(web): request context with operator-actor resolution and CSRF he…
tachyon-beep Jun 25, 2026
519b7a3
test(conformance): producer freeze for the legis preflight-facts enve…
tachyon-beep Jun 25, 2026
f7ea83c
feat(web): app factory, PlainweaveError->HTTP mapping, base layout, C…
tachyon-beep Jun 25, 2026
6f4c41d
fix(web): CSRF middleware preserves request body for downstream handlers
tachyon-beep Jun 25, 2026
3facd80
feat(conformance): plainweave becomes the 4th SEI conformer
tachyon-beep Jun 25, 2026
004ed20
feat(web): corpus browse with search/status/orphan filters
tachyon-beep Jun 25, 2026
1ae3733
refactor(web): fix approved-title coverage + eliminate double search_…
tachyon-beep Jun 25, 2026
9bfca28
feat(web): corpus inline expand with independent per-row targets
tachyon-beep Jun 25, 2026
df91c4d
feat(web): requirement detail showing current vs draft side by side
tachyon-beep Jun 25, 2026
9b476fb
test(web): cover approved-version render path on requirement detail
tachyon-beep Jun 25, 2026
f6c3625
feat(web): intent dashboard with coverage, orphans, and no-silent-cle…
tachyon-beep Jun 25, 2026
9065f86
feat(web): goals list page (with list_goals read if needed)
tachyon-beep Jun 25, 2026
73d7319
feat(web): create/edit requirement with conflict-preserves-text UX
tachyon-beep Jun 25, 2026
9415496
test(web): strengthen test_edit_success_redirects to verify update pe…
tachyon-beep Jun 25, 2026
4cad359
feat(web): create goals and ladder requirements to goals
tachyon-beep Jun 25, 2026
285a6f8
feat(web): unified review queue (drafts + proposed links) with empty …
tachyon-beep Jun 25, 2026
ee0214e
feat(web): two-step draft approval with OOB status/badge/empty-state …
tachyon-beep Jun 25, 2026
e6a404d
feat(web): accept/reject trace links with required-reason two-step an…
tachyon-beep Jun 25, 2026
8427777
test(web): add reject happy-path and link-card-restore tests for task…
tachyon-beep Jun 25, 2026
71ba234
feat(web): extra confirm step for accepting drifted trace links
tachyon-beep Jun 25, 2026
4cb22ac
test(web): assert all five drift-confirm elements (CSRF, targets, rea…
tachyon-beep Jun 25, 2026
150243c
test(web): CSRF + missing-extra guarantees; docs: web UI quickstart
tachyon-beep Jun 25, 2026
7967ac2
test(web): structural accessibility contracts; record manual AT gate
tachyon-beep Jun 25, 2026
ee21ed1
fix(web): CSRF cold-start 403 — mint token before call_next, embed vi…
tachyon-beep Jun 25, 2026
6727ac4
fix(web): input-validation 400s, authority-attribution tests, output …
tachyon-beep Jun 25, 2026
ce60bf6
merge: consolidate feat/weft-sei-conformance (SEI conformance + prefl…
tachyon-beep Jun 25, 2026
129d430
chore(format): ruff-format conformance test from merged feat/weft-sei…
tachyon-beep Jun 25, 2026
fc1297e
docs: README installation + coverage north-star note; CONTRIBUTING, C…
tachyon-beep Jun 25, 2026
4b1a399
merge: fold in working-set doc updates from feat/weft-sei-conformance
tachyon-beep Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ coverage *completeness* remains a documented roadmap item, not a 1.0 gate.)
bounded evidence), `intent orphans`, `intent trace`, `intent corpus`.
- **Authoring surface** — `goal`, `req` (draft/approve/supersede/deprecate),
`bind sei`, `catalog record`, `trace`, `criterion`, `verify`, `baseline`, `actor`.
- **Local store & verification reads** — `init` (create a `.plainweave/` store),
`status` (requirement verification status), `dossier` (per-requirement dossier).
- **Cross-member seams** — Loomweave catalog adapter (consumes SEIs opaquely,
never mints), Legis preflight advisory cell, peer-ready entity-intent-context API.
- **MCP server** (`plainweave-mcp`) — read-only mirror of the intent reads;
Expand Down
15 changes: 10 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# Contributing to Plainweave

Plainweave is a local-first requirements and verification authority for the Weft
suite. It is early-stage; keep changes small, tested, and aligned with the
authority boundary in `docs/concept.md`.
Plainweave is the Weft federation member that holds the team's code-grounded
intent — the local-first traceability graph binding code entities (Loomweave
SEIs) to requirements and strategic goals. As of 1.0.0 it is stable
(Production/Stable on PyPI, versioned JSON envelopes, a green CI gate). Keep
changes small, tested, and aligned with the authority boundary in the canonical
design,
[`docs/design/2026-06-18-plainweave-permission-to-exist.md`](docs/design/2026-06-18-plainweave-permission-to-exist.md).

## Development setup

Expand All @@ -22,8 +26,9 @@ This installs Plainweave in a uv-managed virtual environment with `ruff`, `mypy`
- Linter and formatter: `ruff` with line length 120.
- Type checker: `mypy` in strict mode.
- Tests: `pytest`.
- Runtime base: keep required dependencies empty unless the approved design says
otherwise.
- Runtime base: the only approved runtime dependency is `mcp>=1.2.0` (needed by
`plainweave-mcp`). Do not add further runtime dependencies without an explicit
design decision.

Before committing:

Expand Down
71 changes: 63 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ agent use, not canned reports):
the artifact a curator reads to spot *"these three are the same."* Consolidation
is **agent-driven**; Plainweave serves the substrate, not an automated verdict.

Over these three sits **`coverage()`** — the self-computed *intent-coverage*
north-star: the fraction of public surfaces that answer *"why does this exist?"*
It is honestly qualified in-band (namespace scoping, `denominator_complete`,
`present_plugins`, bounded evidence) and never reports a silent clean when the
denominator is partial.

**Boundary** — coverage facts ride out at the git/CI boundary through Legis
(*"this change adds N public entities bound to no requirement"*). **Advisory by
default;** any repo wanting teeth dials it up through Legis's policy cells.
Expand Down Expand Up @@ -110,12 +116,41 @@ blast-radius map + dated counterpart tickets.

## Documentation

- [`docs/design/`](docs/design/) — the canonical design ("permission for code to
exist"). **Start here.**
- [`docs/design/2026-06-18-plainweave-permission-to-exist.md`](docs/design/2026-06-18-plainweave-permission-to-exist.md)
— the canonical design ("permission for code to exist"). **Start here.**
- [`docs/MODULE-MAP.md`](docs/MODULE-MAP.md) — what the precursor core carries
forward vs. what the reframe reshapes.
- [`docs/README.md`](docs/README.md) — index of canon vs. precursor-era docs.

## Installation

```bash
pip install plainweave
```

Or with [uv](https://docs.astral.sh/uv/):

```bash
uv pip install plainweave # add to an environment
uvx plainweave --help # or run it without installing
```

Plainweave requires Python ≥ 3.12. Installing exposes two console commands:

- `plainweave` — the CLI: `init`, `intent` (`coverage` / `orphans` / `trace` /
`corpus`), `req`, `goal`, `bind`, `catalog`, `criterion`, `verify`, `status`,
`dossier`, `baseline`, `actor`, and `doctor`.
- `plainweave-mcp` — the read-only MCP server that mirrors the intent reads for
agents (`mutates:false`, `local_only:true`).

Quick start:

```bash
plainweave init # create a local store under .plainweave/
plainweave intent coverage # the north-star: how much public surface is justified
plainweave doctor # check store, Loomweave binding, and MCP surface
```

## Development

Plainweave uses [uv](https://docs.astral.sh/uv/), `hatchling`, `ruff`, `mypy`,
Expand All @@ -128,10 +163,30 @@ make ci # lint + typecheck + test (coverage-gated)

The runtime package depends on the official Python MCP SDK for `plainweave-mcp`.

### What works today (carried forward from the precursor)
### Web UI (optional)

Install the extra and launch the operator console:

pip install 'plainweave[web]'
plainweave web --actor human:<you>

Browse the corpus, author requirements, and ratify agent-proposed drafts and
trace links. Local-first, single-operator; advisory only (no release verdicts).

#### Accessibility (AT gate — manual)

Before shipping the review surface, run an NVDA (Windows) or VoiceOver (macOS)
pass over the `/review` queue. Each approve / accept / reject action must:

1. **Announce the outcome** via the `#sr-status` live region
(`role="status" aria-live="polite"`), e.g. "Approved: Requirement title".
2. **Move focus** to the next action button in the queue (or to the "All caught
up" heading when the queue empties).
3. **On the last item**, announce "Queue is now empty" and place focus on the
"All caught up" heading.

The precursor's local requirements/verification core is intact and green — a
foundation to reshape, not the reframed feature set. See the MODULE MAP for the
current → target audit. The code-up read primitives (`orphans`/`trace`/`corpus`),
the SEI-keyed ADR-029 bindings, and the authoring-time write path are **stubbed
with backlog markers**, not yet implemented.
Structural contracts (live region presence, skip-link, labelled search input,
per-item `aria-label` on Approve buttons) are locked by
`tests/web/test_a11y_contracts.py`. The focus-management and announcement
behaviour above require a live AT session and cannot be automated in the
current test harness.
28 changes: 28 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies = [
[dependency-groups]
dev = [
"coverage[toml]>=7.0",
"jinja2>=3.1.6",
"mypy>=1.13.0",
"pytest>=8.0",
"pytest-cov>=5.0",
Expand All @@ -42,13 +43,25 @@ plainweave-mcp = "plainweave.mcp_server:main"
Homepage = "https://github.com/foundryside-dev/plainweave"
Repository = "https://github.com/foundryside-dev/plainweave"
Issues = "https://github.com/foundryside-dev/plainweave/issues"
Changelog = "https://github.com/foundryside-dev/plainweave/blob/main/CHANGELOG.md"

[project.optional-dependencies]
web = [
"starlette>=0.37",
"uvicorn>=0.30",
"jinja2>=3.1",
]

[tool.hatch.version]
path = "src/plainweave/_version.py"

[tool.hatch.build.targets.wheel]
packages = ["src/plainweave"]

[tool.hatch.build.targets.wheel.force-include]
"src/plainweave/web/templates" = "plainweave/web/templates"
"src/plainweave/web/static" = "plainweave/web/static"

[tool.ruff]
line-length = 120
target-version = "py312"
Expand All @@ -67,6 +80,21 @@ explicit_package_bases = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q"
filterwarnings = [
# Starlette TestClient emits a StarletteDeprecationWarning (a subclass of
# DeprecationWarning) at import time when the legacy httpx package is installed
# instead of httpx2. This is a third-party warning outside our control; suppress
# it until we can upgrade to httpx2.
"ignore:Using `httpx` with `starlette.testclient` is deprecated.*",
# pytest-cov's coverage tracer triggers Python's ResourceWarning for SQLite
# connections that are closed lazily by the GC rather than explicitly. These
# are pre-existing store-layer connections surfaced only under --cov; they do
# not indicate a real data-loss risk. Track the underlying leak separately.
"ignore:unclosed database.*:ResourceWarning",
]
markers = [
"sei_drift: opt-in release-gate recheck that the vendored SEI conformance oracle has not drifted from the upstream Loomweave checkout (run with -m sei_drift)",
]

[tool.coverage.run]
source = ["plainweave"]
Expand Down
3 changes: 3 additions & 0 deletions src/plainweave/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--version", action="store_true", help="Print the Plainweave version and exit.")
subparsers = parser.add_subparsers(dest="command")
register_commands(subparsers)
from plainweave.web.server import add_web_subcommand # local import keeps web optional

add_web_subcommand(subparsers)
return parser


Expand Down
32 changes: 32 additions & 0 deletions src/plainweave/loomweave_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,39 @@ def snapshot_error(self, error: LoomweaveIdentityError) -> JsonObject:
code = code_by_reason.get(error.reason, "identity_degraded")
return {"code": code, "message": error.message}

def _probe_sei_capability(self) -> None:
"""Probe the remote Loomweave's ``GET /api/v1/_capabilities`` and gate the
HTTP identity resolve on ``sei.supported``.

Loomweave is the SEI authority; a consumer learns whether an instance serves
SEI from the wire capability, NOT from the local SQLite schema. Without this
probe the adapter would POST ``/api/v1/identity/resolve`` against a pre-SEI
Loomweave and surface the response (or its connection error) as
``not_found`` / ``unreachable`` — conflating "this instance has no SEI
capability" with "this instance is down". That fails §8 ``capability_absent``,
which requires an HONEST degrade.

Two outcomes are kept ORTHOGONAL, mirroring the existing reason vocabulary:
* ``_http_json`` raises (connection refused / timeout / non-object body) →
the error propagates with ``reason="unreachable"`` — the remote is down,
unchanged from before.
* ``_http_json`` returns a 2xx body whose ``sei.supported`` is not exactly
``True`` (absent / false / malformed) → raise ``reason="unsupported"`` —
the remote is reachable but serves no SEI. This is the ONLY new behaviour;
it is the honest "identity unavailable" the conformance oracle demands.
"""
body = self._http_json("GET", "/api/v1/_capabilities")
sei = body.get("sei")
supported = isinstance(sei, dict) and sei.get("supported") is True
if not supported:
raise LoomweaveIdentityError(
"unsupported",
"Loomweave instance does not advertise SEI support (/api/v1/_capabilities).",
[self._degraded("sei_support_missing", "Loomweave SEI capability is absent on the remote.")],
)

def _resolve_identity_http(self, value: str) -> LoomweaveCatalogEntity:
self._probe_sei_capability()
if value.startswith(LOOMWEAVE_SEI_PREFIX):
quoted = urllib.parse.quote(value, safe="")
body = self._http_json("GET", f"/api/v1/identity/sei/{quoted}")
Expand Down
5 changes: 5 additions & 0 deletions src/plainweave/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,11 @@ def create_goal(self, title: str, statement: str, *, actor: str) -> IntentGoal:
connection.commit()
return IntentGoal(goal_id, display_id, stable_id, title, statement, "active", actor, now)

def list_goals(self) -> list[IntentGoal]:
with connect(self.db_path) as connection:
rows = connection.execute("select * from intent_goals order by display_id").fetchall()
return [self._goal_from_row(row) for row in rows]

def link_goal_to_requirement(self, goal_id: str, requirement_id: str, *, actor: str) -> IntentEdge:
self._require_actor(actor)
now = self._now()
Expand Down
1 change: 1 addition & 0 deletions src/plainweave/web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Optional operator-facing web tier (the plainweave[web] extra)."""
80 changes: 80 additions & 0 deletions src/plainweave/web/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from __future__ import annotations

from pathlib import Path
from urllib.parse import parse_qsl

from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import PlainTextResponse, Response
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from starlette.templating import Jinja2Templates

from plainweave.errors import PlainweaveError
from plainweave.web.context import RequestContext, csrf_ok, new_csrf_token
from plainweave.web.errors import error_to_status

_HERE = Path(__file__).parent
_CSRF_COOKIE = "pw_csrf"


def create_app(*, actor: str | None, root: Path | None) -> Starlette:
templates = Jinja2Templates(directory=str(_HERE / "templates"))

def ctx_factory() -> RequestContext:
return RequestContext.from_root(root, actor=actor)

async def healthz(request: Request) -> Response:
return PlainTextResponse("ok")

async def on_error(request: Request, exc: Exception) -> Response:
if isinstance(exc, PlainweaveError):
status = error_to_status(exc.code)
return templates.TemplateResponse(
request,
"_partials/error.html",
{"code": exc.code.value, "message": exc.message, "hint": exc.hint},
status_code=status,
)
raise exc

async def csrf_mw(request: Request, call_next: RequestResponseEndpoint) -> Response:
cookie_token = request.cookies.get(_CSRF_COOKIE)
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
# Read via .body() so Starlette's _CachedRequest can replay the raw
# bytes to downstream handlers — calling .form() here would consume
# the body stream, leaving downstream request.form() empty.
body = await request.body()
fields = dict(parse_qsl(body.decode("utf-8")))
if not csrf_ok(cookie_token, fields.get("_csrf")):
return PlainTextResponse("CSRF check failed", status_code=403)
# Mint the token for THIS render before calling the handler so the template
# can embed a real token even on the very first (cold) request, when there
# is no cookie yet. scope["state"] is shared into the handler via
# BaseHTTPMiddleware, making request.state.csrf_token visible there.
token = cookie_token or new_csrf_token()
request.state.csrf_token = token
response = await call_next(request)
if cookie_token is None:
response.set_cookie(_CSRF_COOKIE, token, httponly=True, samesite="strict")
return response

routes = [
Route("/healthz", healthz),
Mount("/static", app=StaticFiles(directory=str(_HERE / "static")), name="static"),
]
app = Starlette(
routes=routes,
middleware=[Middleware(BaseHTTPMiddleware, dispatch=csrf_mw)],
exception_handlers={PlainweaveError: on_error},
)
app.state.templates = templates
app.state.ctx_factory = ctx_factory
app.state.csrf_cookie = _CSRF_COOKIE

from plainweave.web.routes import register_all

register_all(app)
return app
61 changes: 61 additions & 0 deletions src/plainweave/web/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from __future__ import annotations

import secrets
from dataclasses import dataclass
from pathlib import Path

from plainweave.errors import ErrorCode, PlainweaveError
from plainweave.paths import plainweave_db_path
from plainweave.service import PlainweaveService

DEFAULT_OPERATOR_ID = "human:operator"


@dataclass(frozen=True)
class OperatorIdentity:
actor_id: str
display_name: str
kind: str


class RequestContext:
def __init__(self, service: PlainweaveService, operator: OperatorIdentity) -> None:
self.service = service
self.operator = operator

@classmethod
def from_root(cls, root: Path | None, *, actor: str | None) -> RequestContext:
service = PlainweaveService(plainweave_db_path(root), root=root)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

actor_id = actor or DEFAULT_OPERATOR_ID
display = actor_id.split(":", 1)[-1] or actor_id
operator = cls._ensure_operator(service, actor_id, display)
return cls(service, operator)

@staticmethod
def _ensure_operator(service: PlainweaveService, actor_id: str, display: str) -> OperatorIdentity:
# Register the operator as a human actor. At genesis (no attester yet) this
# 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

except PlainweaveError as exc:
if exc.code is ErrorCode.POLICY_REQUIRED:
raise PlainweaveError(
ErrorCode.POLICY_REQUIRED,
f"operator actor {actor_id!r} is not a registered human and cannot self-register "
"(an attester already exists). Register it via the CLI before launching the web UI.",
recoverable=False,
hint="plainweave actor register --id <id> --kind human --actor <existing-attester>",
) from exc
raise
return OperatorIdentity(actor_id=actor_id, display_name=display, kind="human")


def new_csrf_token() -> str:
return secrets.token_urlsafe(32)


def csrf_ok(cookie_token: str | None, form_token: str | None) -> bool:
if not cookie_token or not form_token:
return False
return secrets.compare_digest(cookie_token, form_token)
Loading
Loading