web: draw the chrome from sr-ht-ecore The nav/service-switcher, the login block, the environment banner and the brand were this service's own copy of code compare.sr.ht had already copied from somewhere else. They come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome now: one chrome.Service built at startup from the shared config.ini, one chrome.Page per request, embedded in viewData so the shared partials find their fields on the dot. web/chrome.go is gone — buildNav, navItem, canonIndex, the login/logout/ profile URL building and the chrome half of viewData with it. The layout renders srht-env-banner and srht-nav instead of the local markup, the landing page's space list renders through srht-repo-list, and the template FuncMap starts from chrome.Funcs() (the local shortsha was a duplicate of the shared one). sameOrigin and the login redirect ask the chrome for our origin rather than keeping a second copy that could disagree with the links on the page. Three of ecore's policies differ from what this service did, and win, per that package's own doc: [sr.ht]site-name defaults to "sr.ht" rather than "sourcehut" and [sr.ht]environment to "development" rather than "production" when the key is absent, and the brand carries a fixed 15rem min-width so the switcher starts at the same x on every service. The instance's config.ini sets both keys, so on it only the brand width is visible. The nav tests that only restated ecore's rules — switcher order, the paste/pages/hub exclusion, the shape of a login URL — are dropped; ecore tests those. What is left covers this service's seam: that the identity authn resolved is the one the chrome is handed.
authn: accept tokens.sr.ht working tokens beside the agent token A second agent credential plane, next to the existing one rather than in place of it. The agent_token table, every agent configured with it, and the refs rule and provenance requirement around it are untouched; the local plane is removed in a later phase, not this one. The resolver tries the instance plane first and falls back to the local store on exactly two refusals, bearer.ErrInvalid and bearer.ErrNotOurs. spec's local token has no prefix to discriminate on — it is 32 random bytes in base64, which is precisely what "did not decode as one of ours" looks like — so the fallback replaces the shape test bench and cover can afford. ErrRevoked, ErrForbidden and ErrUnavailable are terminal: a withdrawn credential must not get a second chance at the old door, and an unreachable daemon must not silently degrade into the legacy plane. Grants ride on the principal and are checked where the action is known, never in the middleware, which runs upstream of the router: spec:propose in service.Propose, below both write surfaces, and spec:read in each read surface's gate. /mcp checks per tool rather than at its Gate, because one endpoint carries both kinds and a surface-wide read grant would refuse a propose-only token at initialize. Principal.Authorize is a no-op off the instance plane, which is what keeps the local token working. The instance plane brings an owner where the local token had none, so a working token belonging to anybody but [sr.ht] owner-name is refused rather than admitted as a second identity: Principal.Owner is read by the provenance committer, the refs rule's principal kind and the coreauth AuthContext, all written for one human. StatusFor is the one status table. ErrUnavailable is 503 and never 401 — reading "I could not ask tokens.sr.ht" as "revoked" would refuse every live instance token while a daemon that is deliberately off the hot path restarts. An instance with no [tokens.sr.ht] section builds no instance plane and starts anyway, serving its own agent token as before.
feat(web): commentable prose diff with honest anchor state (spec-by6.3.3) Reverses the diff view's founding rule. renderDocDiff skipped ChangeEqual outright — "the review shows only what changed" — but any block of a proposed document must be commentable, so unchanged blocks now render as collapsed, dimmed context. Changed blocks keep their border, tint and full body, so the page still reads as a diff at a glance rather than as a document dump. A context block carrying a comment renders open. ChangeMoveIn now shows its text too, since a comment control on invisible text is a control on nothing; ChangeMoveOut stays a bare marker and is deliberately not commentable, because the same paragraph is anchorable at its move-in position and two anchors for one paragraph is the bug that avoids. Every rendered block carries id="b-<16 hex>", hashed from the whole anchor tuple. Not the page ordinal: an ordinal renumbers on any insertion above it, so a saved link would silently scroll to a neighbouring paragraph, whereas including the block hash makes a stale link resolve to nothing instead. Threads are placed by anchor and by nothing else. Anything no rendered block claims — an outdated anchor, an old-side anchor whose block the diff no longer draws, a document the proposal no longer changes — is collected into a page-level "comments that lost their anchor" area. Never dropped, never moved onto a neighbour: a comment reads as authoritative about the block it sits beside, so attaching it to the wrong one is worse than admitting it lost its place. An edited anchor is drawn on its block and badged. The comment form's anchor is built at submit time from the branch as it now reads, through service.AnchorOf — hand-rolling the ordinal conversion here would put the browser's comments on different blocks than the MCP tool's, which is the one way two surfaces of one conversation disagree without either looking broken. The form's block hash guards it: a block that moved while the page sat open is a 409, not a comment attached to whatever took its place. That hash is required rather than checked-when-present. Skipping the guard for a form that omits it would let a later template refactor drop the hidden field and disable the staleness check silently, with every test still green. Authority is surfaced, not re-implemented: compose and resolve are the owner's because service says so and ErrForbidden becomes a 403. spec-by6.3.3
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.