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: 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.
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
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.
refactor: make the filter-polarity trap inexpressible service.SpaceFilter meant "empty membership selects nothing" — a newly created project has no members — while search.Query.Spaces was a bare []core.SpaceRef whose empty case meant every space. Passing a project's members into a query therefore turned an empty project into the whole corpus: a silent scope inversion, invisible when it happens, and passing every test written with a non-empty project. The filter moves to core.SpaceFilter with unexported fields, and search.Query takes it whole. There is no slice to hand over any more, so the inversion cannot be written. Its three states are distinct: every space (EverythingFilter), exactly these — possibly none — (SpacesFilter), and the zero value, which is neither. Search refuses the zero value rather than defaulting it, because both plausible defaults are wrong for one of the two callers that can produce one, and returns no hits for a filter that selects no space without asking the index. service.SpaceFilter is now an alias for the core type, so ResolveProject and its callers keep their names. Tests that built a Query without a scope now say core.EverythingFilter(), which is what they always meant.
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).