web: render a review block as a paragraph, not as its source lines prosediff's package doc says prose reflows and that whitespace alone must never produce a diff, and the web layer then emitted one row per SOURCE line: a hard-wrapped paragraph arrived as six ragged rows with six numbers, which is the line-differ artefact the engine exists to avoid. A prose block is now one row holding the block's markdown rendered, with the word marks carried through as <ins>/<del>, and the gutter states the line range the block occupies on each side once. Code fences, frontmatter and raw HTML keep their lines, because there the line is the unit and its whitespace is content. That retires the recovery of per-line numbers for reflowed prose — the WordsByLine spreading, its interleaving and the per-row pairing — and with it the off-by-one they were defending against. A modification is one row of its own kind rather than a delete row plus an insert row, so the edit reads in place. The property test now checks the invariant per side rather than per row: one row can carry both revisions' words, and the projection asserted is the one the renderer uses. diff.js restores a hash by extent overlap instead of membership, because a row that states a range puts only its two ends in the cell.
doc: say what the anchors pass costs and what it buys, and pin the alert
doc: report a wikilink that names a section the document does not have
chrome: the resolved favicon and the queue as a shared table
pages: read a form's body, bounded, and never its URL
bearer: refuse through the shared table and challenge
chimw: the request line, the HEAD twins and the routing refusals
login: decode the unified-login cookie through ecore
instconf: one reading of this instance's origins
logging: log through slog and scribe rather than stdlib log sr-ht-ecore's panic middleware now reports through log/slog, and it reports through the *default* logger — nothing can hand a middleware in another module this service's *slog.Logger. So the daemon's scribe handler becomes the one install point, and the packages that were still calling log.Printf go through the default logger too: the read plane's render and encode failures, its 5xx mapping, and the credential resolver's fail-closed line. Each carries the fields that used to be interpolated into the sentence — method, path, status, page, doc — and the error itself through scribe.Err. The handler grows what it was missing: file:line, because most of what reaches it is a failure and 'which of the six render sites' is the first question; colour dropped when stderr is not a terminal; and the masks. This daemon handles the unified-login cookie and tokens.sr.ht working tokens, and a struct logged whole is how a live credential outlives its own request in a log file — masking in the handler covers the log line nobody reviewed as well as these. cmd/specsrht-migrate keeps stdlib log on purpose: it is a one-shot CLI whose 'specsrht-migrate: ...' progress an operator reads at the terminal during an upgrade, and log.Fatalf is its error exit.
web: draw the whole web tier from sr-ht-ecore The chrome moved last time; this moves the four packages around it. pages discovers the page templates and carries the shared error body, so the hand-maintained page list, the render helper and error.html go; assets finds the hashed stylesheet and serves the static tree, so the local regexp, the href glob and handleStatic go; middleware brings the private-cache policy and a panic guard that aborts a connection rather than appending an error page to a truncated one; and ecoretest replaces the hand-built config and the TestMain that minted its own keys. The one that matters is csrf. The guard was a predicate three handlers remembered to call, which made unprotected the default for any form added later. It is now csrf.Require on the router, so it covers the routes that are not written yet, it runs before routing — an unrouted POST is refused rather than 404'd — and the refusal is the shared sentence. Host comparison becomes case-insensitive, which is strictly more permissive and only for an operator typo in the config. threads.html becomes _threads.html, the partial spelling pages.Load discovers, and the review page takes container-fluid: two prose columns do not fit the centred container, which is what cover and dolt already concluded for their wide views. What stays here: the sentinel-to-status mapping in fail, and a renderError that wraps ecore's error body in this service's view struct.
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: remove the local agent-token plane The agent_token table and everything that minted, verified, listed or revoked it. Agent credentials are tokens.sr.ht working tokens now: signed, expiring, owned by a meta.sr.ht account and carrying grants, verified locally by sr-ht-ecore's bearer package. One door, and nothing behind it — a credential the instance plane refuses is refused, where it used to be offered to a second store that might say yes. DEPLOY GATE: do not deploy this until every agent configured with the shared secret holds a tokens.sr.ht token with spec:propose (spec:read to read). Migration 0005 drops the table, migrate-on-upgrade runs it on deploy, and deploying early locks out every agent at once — including the SSH push path. hooks/ no longer reads agent_token directly. The push path goes through the same authn.Resolver the HTTP surfaces use and demands spec:propose, because a push by an agent is a proposal by another transport. The refs rule and the provenance requirement are untouched on both counts: a universal grant is still only an agent to the receive path, and X-Agent / X-Agent-Session are still mandatory on every agent write. bearer.ErrNotOurs is now a permanent refusal. A meta.sr.ht PAT used to fall through to the local store and miss there; with no store to fall through to it earns a 401 rather than the 503 an unclassified error would. [tokens.sr.ht] origin becomes a required config key: with no issuer there is no credential to check, so the daemon fails startup instead of serving reads and refusing every agent write one request at a time. /tokens redirects to the daemon that issues (tokens SPEC ch. 7).
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.
web: git.sr.ht-style dashboard and unified nav brand Adopt the family look the dolt service already has. The nav brand becomes circle icon + site name + red service label (dropping the hub-origin variant), matching every other service on the instance. The logged-in index turns into the two-column dashboard: a sidebar with the service blurb, review-queue/agent-tokens block buttons and the search form, and the spaces as shared-theme event-list cards. The big in-page h2 lives on only for anonymous visitors, where the nav has no user context yet.
feat(web,service): the owner mints and revokes agent tokens in a browser Issuing a credential required SSH to the host, which made the remote agent write plane unusable from anywhere else: to hand an agent a token the owner had to be at the machine. /tokens is that page — list, mint, revoke — behind the same owner-only gate and same-origin guard as approve/reject. The mint is owner-only, and that rule is what revocation depends on: an agent allowed to mint would survive having its own credential revoked by issuing itself another, and "revoke the token" is the entire incident response this design has. An agent asking for the page gets 403 rather than the read plane's login redirect — it is authenticated already, so bouncing it to meta would answer a question it did not ask. The plaintext is rendered in the response to the POST rather than after a redirect. A redirect would either drop the secret or carry it in a URL, where it lands in history and in every proxy log on the way; the cost is that a reload re-submits and mints a second token, which is one click to revoke on that same page, whereas a lost token is not recoverable. service.IssueAgentToken/ListAgentTokens/RevokeAgentToken hold the ACL and the mint, and `specsrht token` now goes through them too, so the CLI and the page cannot drift into two ideas of what issuing a token is. spec-ejq.3
fix(web): a code fence whose language changed says so (spec-by6.4) prosediff hashes a block's Info, so ```go becoming ```python pairs the two fences as a modification — but Block.Lines holds a fence's contents without its delimiters, so the line script came out entirely equal and every row rendered as context. The page said the document changed and then showed nothing that had, which is worse than either saying nothing or showing the change: the reviewer looks for an edit that appears not to exist. The fence's opening delimiter is not a row of this table and inventing a line number for it would be a guess, so the change is stated as a marker row above the fence's lines, in the same shape a move already uses. Only a code fence is covered — Info also carries a list item's marker and a table's column count, and neither is a language a reviewer would want announced.
feat(web): line-numbered unified prose diff replaces the block cards (spec-by6.3.5) The block-card renderer was reviewed against a live proposal and rejected: "ADDED PARAGRAPH" outweighed the content on every row, every block carried identical chrome, and on a new file the whole page is one change, so the cards added noise and no signal. Two columns of digits say the same thing and then get out of the way, which is what a gutter is for. Selection is by line, anchoring is by block. Lines are what the cursor lands on; block hashes are what survive a reflow. The web layer maps a selected line range onto its enclosing prosediff block and stores the existing core.CommentAnchor unchanged — service/, db/ and core/ do not move and the POST wire format is untouched. The composer states which block it will anchor to before anything is typed, so the indirection is visible rather than magic. A line number is never guessed. A modified prose block goes through prosediff.WordsByLine, whose ok=false contract is honoured with a paired old/new region stating a line RANGE; a block rewritten past the similarity threshold takes that path too. An equal block that was rewrapped states an old number only for the lines the old revision really holds — equal line counts were the first rule and were not proof, which a property test over 2800 generated document/edit pairs found within seventeen cases. The markup is a table because prose wraps and a number has to stay on the first visual line of the line it names. One rail ground behind both number tracks with a single hairline against the content; the change tint starts at the sign column so the gutter never reads as part of the change; heading rows pin themselves as the section readout, replacing the per-hunk breadcrumb that only restated a heading three rows above. Folding and commenting both work with JavaScript off — the fold is a checkbox, and every block keeps a visible composer. With the script in, that per-block composer is hidden and reached by selecting lines instead, because sixteen identical "comment on this block" rows are the chrome this port removes. A composer holding typed text is never hidden by anything. Two pre-existing prosediff faults are fixed here because line numbers are what made them visible: a thematic break reported line 1 for every rule in the document, and a document whose entire content is "---" panicked in splitFrontmatter.
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
refactor(authn): one Principal.CanRead() for the read-plane ACL (spec-ejq.1) graph's gate, web's mayRead and mcpsrv's Gate each hand-spelled 'IsOwner() || IsAgent()' — three copies of the read ACL, which graph's own comment warned is how a corpus leaks when they drift. Define it once as authn.Principal.CanRead and route all three through it. coreauth.Derive keeps its own owner||agent test on purpose: it answers a different question (is this an owner-backed identity to bridge to AUTH_INTERNAL), and coupling it to the read ACL would misroute a future read-only viewer kind to the owner's UserID. Closes spec-ejq.1