feat(web): proposal review page — prose diff + approve/reject (Phase 4) The browser review plane at /~owner/space/p/<id>, the stable URL every write already returns. The owner opens the link an agent handed them, reads a prose diff of each changed document, and approves (merges now) or rejects. - web/diff.go: the prose-diff HTML renderer, consuming prosediff's block model (the package renders text only; HTML is the web layer's job). It implements the Phase 0 verdict's hard requirement — inline word diffs above 0.75 block similarity, a two-column old/new view below it, because 13% of real edits shred and are unreadable inline. All document content is HTML-escaped; only the diff structure is markup. - service/review.go: ProposalDiff reads each changed document's base and proposed content for the page to diff (branch tip resolved to a sha, the legitimate pinned-rev read, not the ReadDocumentAtRef bypass), and MergeHuman fixes the approval kind so a browser approve is always human. - web/proposal.go: the GET page and the approve/reject POSTs. Only the owner may act (an agent is authenticated but has no more approval authority than anyone); a cross-site guard on Origin/Referer is the CSRF defense a form post needs when the session cookie is meta's. Post- redirect-get back to the page. Stale/already-merged approve → 409. - web.Reader gains the proposal reads and the two actions; the diff-view styles go in scss/main.scss (inline marks, two-column, code line diffs). Inbox and the policy-merged digest are the remaining Phase 4 surfaces.
feat: service.Archive — one accessor, one tree walk, one link graph web/ and mcpsrv/ both needed a *doc.Archive and each invented its own way to get one. web/ handed sp.Repo to doc.Scan — reaching past service/ into gitx, which the layering rule forbids — and then listed the documents a second time for their bodies, two tree walks per page view. mcpsrv/ converted []service.Document back into []gitx.Document, rebuilding hashes service/ had already stringified. Two workarounds around one missing accessor is how three agent-facing surfaces stop being identical. Service.Archive resolves the revision, walks the tree once, and returns the archive with the bodies. ArchiveFrom is the same construction over a Document set a caller already holds — the one place a hex object name is converted back to a hash, malformed ids refused rather than zeroed. doc.Page.Links and Page.WordCount were documented as "filled in by a render pass" and nothing filled them, so Archive.Backlinks always returned empty; web/ worked around that by re-rendering every document of the space on each page view. doc.Archive.LinkPass now owns the pass and the accessor runs it, so the link graph exists wherever the archive does. web/ and mcpsrv/ no longer import gitx or go-git, in tests either. doc.DirOf replaces the three copies of the same fromDir helper.
feat: web — the Phase 2 read plane UI and its SCSS entry SourceHut chrome (nav service-switcher, login block, environment banner, error page, embedded hashed static assets) ported from compare.sr.ht, plus the read-plane pages: landing, space document tree, rendered document with frontmatter/backlinks, and search. The URL grammar is the design's pinned one: a document's address carries no extension, ".md" is raw source, ".json" is metadata plus body, and ?rev= pins any of the three to an immutable revision. Links rendered inside a pinned page keep the pin, so following one does not silently land on the approved head. One human and no visibility levels, so the read ACL is one line: the owner and its agents read, everyone else is redirected to meta's login (a browser) or refused with 401 (a client asking for .md/.json).