~bigbes/sr-ht-spec

dd56e38c60d154ed0db6d60101a2fb92667438ea — Eugene Blikh 24 days ago 2dc6b71
refactor(doc): one route from a revision to an Archive (spec-wcr #2, #4)

doc.Scan/DocumentSource were production-dead after service.Archive landed —
their only consumer was doc's own test fixture — and they were the seam that
made web's layering violation writable: a surface could reach past service/
into gitx and build its own archive. Deleted, so doc/ now owns no way to read
a revision and service.Archive is the single route from rev to Archive. The
fixture reads through ListDocuments + FromDocuments instead.

TestScanReportsGitErrors covered an unknown revision failing rather than
walking empty, which gitx did not test itself. Relocated there as
TestWalkOfAnUnknownRevisionFails rather than dropped.

linkHierarchy passed path.Dir(p.Path) where every other call site passes DirOf.
Unobservable today: the two differ only at the space root, and there the
section-proximity step is subsumed by the same-directory step above it, so "."
only ever skipped a lookup that had already answered. Verified by reverting and
re-running. Changed anyway — it stays unobservable only by coincidence of two
ranking rules — with a test pinning the invariant that `parent:` resolves to
whatever the same wikilink in the body resolves to.

spec-wcr
M .beads/interactions.jsonl => .beads/interactions.jsonl +1 -0
@@ 7,3 7,4 @@
{"id":"int-635c713a62ccbeb74f0f6030f75eb20d","kind":"field_change","created_at":"2026-07-24T16:45:31.84927Z","actor":"Eugene Blikh","issue_id":"spec-jjo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"MCP surface gated to owner+agents (mcpsrv.Gate), mounted inside resolver middleware; tests cover anon 401 / owner+agent pass / fail-closed without middleware. Commit 7cc652d."}}
{"id":"int-efe1054b315e03fdc31f2535d4ccd1d1","kind":"field_change","created_at":"2026-07-24T16:54:06.613186Z","actor":"Eugene Blikh","issue_id":"spec-mfm","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Inbox GET reads digest_mark to flag/count new-since-last-looked auto-merges + divider (pure read); explicit POST /inbox/seen advances the mark behind owner-only + same-origin guard. service.DigestMark/MarkDigestSeen added. Commit 865a21f."}}
{"id":"int-f4f6563f5207770848db3bf66247c0db","kind":"field_change","created_at":"2026-07-24T16:59:33.671408Z","actor":"Eugene Blikh","issue_id":"spec-ejq.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"authn.Principal.CanRead() defined once; graph.gate, web.mayRead, mcpsrv.Gate routed through it. coreauth left independent (different semantic). Unit test covers owner/agent true, anon/zero/unknown false. Commit pending push."}}
{"id":"int-d8d8556e8412e54680f206d5867f2370","kind":"field_change","created_at":"2026-07-24T18:12:41.580051Z","actor":"Eugene Blikh","issue_id":"spec-wcr","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"All 7 items dispositioned: 2 fixed (#1,#2), 1 deleted (#4), 2 wontfix (#3,#5), 2 moved to Phase 5 (spec-by6.1, spec-by6.2)."}}

M .beads/issues.jsonl => .beads/issues.jsonl +3 -1
@@ 3,11 3,13 @@
{"_type":"issue","id":"spec-43w","title":"Stale apk pins: paste.sr.ht 0.19.3-r0 and hub.sr.ht 0.29.4-r0 aged off the mirror","description":"Pre-existing, not caused by spec.sr.ht — but a FULL stack rebuild (post_push) would fail on these two apk layers and leave the stack un-deployable (the 2026-07-11 incident pattern). The self-hosted append-only repo.bigb.es mirror only preserves what it has seen, and it started after these aged off; old versions are unrecoverable.","design":"spec.sr.ht was deployed AROUND this with labng push --no-restart + a single-service 'docker compose build spec', so nothing else rebuilt. To fix properly: bump the two pins in versions.env to what the mirror currently ships (paste 0.19.4-r0, hub 0.31.1-r0 as of 2026-07-22). hub 0.29.4-\u003e0.31.1 crosses two minors with forward-only migrations — pg_dumpall first per the stack CLAUDE.md. Kept separate from the spec deploy deliberately.","status":"closed","priority":2,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-07-23T04:09:54Z","created_by":"Eugene Blikh","updated_at":"2026-07-23T04:52:46Z","closed_at":"2026-07-23T04:52:46Z","close_reason":"Stale apk pins already bumped in the stack versions.env (paste 0.19.4-r0, hub 0.31.1-r0); fixed out of band.","dependencies":[{"issue_id":"spec-43w","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-23T07:09:54Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-ar4","title":"Restart other SourceHut services so spec.sr.ht appears in their nav","description":"The switcher entry is config-driven: each service builds its own nav from config sections ending in .sr.ht. spec.sr.ht is in the shared config.ini now, but the OTHER services (git, meta, todo, ...) only pick up the new entry on restart. Not done unprompted because it briefly bounces every service. On phoebe: docker compose restart \u003cthe sourcehut services\u003e.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-07-23T04:09:53Z","created_by":"Eugene Blikh","updated_at":"2026-07-23T04:09:53Z","dependencies":[{"issue_id":"spec-ar4","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-23T07:09:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-3vz","title":"Phase 4: review plane — approve/reject in a browser","description":"Proposal pages at stable URLs (returned by every write), the inbox, prose diff, approve (merges immediately) / reject, status lifecycle, and the digest of policy-merged firehose content.","design":"Productionise prosediff (the Phase 0 spike) into the review UI. CRITICAL requirement from the Phase 0 verdict: 13% of real prose modifications shred into interleaved fragments (similarity \u003c= 0.73), so the UI MUST switch to a two-column old/new view below ~0.75 and inline word diffs above it — building only the inline renderer makes one review in eight unreadable. The prosediff package already emits the (Hash, HeadingPath, Ordinal) comment-anchor tuple. Reads a proposal branch via service.ReadDocumentAtRef (the deliberately-awkward bypass), never the normal read path.","status":"closed","priority":2,"issue_type":"feature","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-07-23T04:09:25Z","created_by":"Eugene Blikh","updated_at":"2026-07-23T06:52:55Z","started_at":"2026-07-23T06:31:53Z","closed_at":"2026-07-23T06:52:55Z","close_reason":"Review plane shipped: proposal page at /~owner/space/p/\u003cid\u003e with the prose diff (inline word-diff above 0.75 similarity, two-column old/new below — the Phase 0 verdict's hard requirement, in web/diff.go), approve(merge)/reject as owner-only CSRF-guarded POSTs, status badges, and the /inbox review queue + policy-merged digest. service.ProposalDiff/InboxProposals/DigestProposals/MergeHuman back it; web.Reader extended. Diff reads the proposal branch by resolving its tip to a sha (pinned read) rather than the ReadDocumentAtRef bypass — cleaner and immutable. Validated: diff renderer unit tests (inline/two-column/escaping), proposal-page + approve/reject handler tests (CSRF, owner-only, stale-\u003e409, wrong-space-\u003e404), inbox tests, and PG-backed service tests. Follow-up: digest_mark 'since last seen' tracking.","dependencies":[{"issue_id":"spec-3vz","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-23T07:09:25Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"spec-3vz","depends_on_id":"spec-zqb","type":"blocks","created_at":"2026-07-23T07:09:26Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"spec-by6.2","title":"Attachments: image embeds render as visibly missing","description":"gitx walks .md only, so an archive built from a git tree has no attachment index and ![[diagram.png]] resolves to a visibly-missing link. doc.FromPages already accepts an assets map — the gap is producing one. Two options: (a) gitx.WalkBlobs + an asset index threaded through service.Archive, plus a read-plane route serving the blob; (b) adopt Mermaid-by-convention and document that binary attachments are out of scope. Carried over from spec-wcr item 7 (triaged 2026-07-24: a feature, not a latent bug).","status":"open","priority":3,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-07-24T18:12:24Z","created_by":"Eugene Blikh","updated_at":"2026-07-24T18:12:24Z","dependencies":[{"issue_id":"spec-by6.2","depends_on_id":"spec-by6","type":"parent-child","created_at":"2026-07-24T21:12:24Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-45j","title":"Phase 5a-2: webhook tables, SDL, resolvers, delivery on proposal open/merge/reject","description":"On top of 5a-1: gql_proposal_wh_sub/_delivery tables + migration, WebhookSubscription/Delivery/Payload SDL, create/delete mutations + webhooks queries, and fire via webhooks.Schedule on proposal lifecycle events (service event hook -\u003e queue). Reference pages.sr.ht webhooks/webhooks.go + graph/model/webhooks.go.","status":"closed","priority":3,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-07-24T02:05:29Z","created_by":"Eugene Blikh","updated_at":"2026-07-24T09:50:16Z","closed_at":"2026-07-24T09:50:16Z","close_reason":"Webhook tables/SDL/resolvers/delivery all shipped and verified end-to-end.","dependencies":[{"issue_id":"spec-45j","depends_on_id":"spec-3m9","type":"parent-child","created_at":"2026-07-24T05:05:30Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-8y4","title":"Phase 5a-1: adopt core-go graph conventions (directives, database.Model, cursors, auth+user model)","description":"Retrofit spec's minimal hand-written graph onto core-go's stack, the prerequisite for GraphQL-native webhooks. Per pages.sr.ht: @access/@private directives + impls, core-go database.Model for space/document/proposal/project/search types, cursor pagination, core-go auth.Middleware + a single-owner user table. Blueprint from ~/data/home/tmp/pages.sr.ht.","status":"closed","priority":3,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-07-24T02:05:29Z","created_by":"Eugene Blikh","updated_at":"2026-07-24T09:50:17Z","closed_at":"2026-07-24T09:50:17Z","close_reason":"Adopted core-go server stack for /query (faithful path); the convention pieces webhooks needed (auth/database/server context, database.Model for webhook models, gqlgen) are in.","dependencies":[{"issue_id":"spec-8y4","depends_on_id":"spec-3m9","type":"parent-child","created_at":"2026-07-24T05:05:29Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-3m9","title":"Phase 5a: webhooks + notifications on proposal lifecycle","description":"Phase 5a: webhooks via FULL core-go convention adoption (user chose to match pages.sr.ht/lists.sr.ht byte-for-byte). Prerequisite: retrofit graph/ onto core-go conventions (database.Model, @access/@private directives, cursor pagination, auth.Middleware + a user model with OAuth2 scopes). Then add gql_proposal_wh_sub/_delivery + webhook SDL/resolvers/delivery on top. Reference: ~/data/home/tmp/pages.sr.ht (same fork). Large graph rewrite, not additive.","status":"closed","priority":3,"issue_type":"feature","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-07-23T07:02:58Z","created_by":"Eugene Blikh","updated_at":"2026-07-24T09:50:18Z","started_at":"2026-07-23T07:03:00Z","closed_at":"2026-07-24T09:50:18Z","close_reason":"Phase 5a webhooks complete: DB + authn→AuthContext bridge + GraphQL surface + core-go server wiring + firing. Verified live: agent REST propose delivers an Ed25519-signed POST whose body is the subscription query executed against the ProposalEvent payload; delivery recorded.","dependencies":[{"issue_id":"spec-3m9","depends_on_id":"spec-by6","type":"parent-child","created_at":"2026-07-23T10:02:59Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-mfm","title":"Digest should track 'since you last looked' via digest_mark, not just show recent policy-merges","description":"Phase 4 ships the policy-merged digest as 'recent policy-merged proposals' (service.DigestProposals). The design intends it as 'what auto-merged since you last saw the digest', backed by the digest_mark table (db.GetDigestMark/SetDigestMark, already present). Advancing the mark is a write, and the inbox GET is deliberately kept pure, so this needs either a POST 'mark as seen' action or an accepted side-effecting GET. Low priority: the digest already surfaces auto-merged content, which is its core purpose.","status":"closed","priority":3,"issue_type":"feature","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-07-23T06:52:53Z","created_by":"Eugene Blikh","updated_at":"2026-07-24T16:54:07Z","started_at":"2026-07-24T16:49:21Z","closed_at":"2026-07-24T16:54:07Z","close_reason":"Inbox GET reads digest_mark to flag/count new-since-last-looked auto-merges + divider (pure read); explicit POST /inbox/seen advances the mark behind owner-only + same-origin guard. service.DigestMark/MarkDigestSeen added. Commit 865a21f.","dependencies":[{"issue_id":"spec-mfm","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-23T09:52:54Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-jjo","title":"MCP read tools (/mcp) have no auth gate — reads are open behind the Host check","description":"Phase 3 added the resolver middleware to /mcp so spec_propose can resolve the agent, but the read tools (spec_search/spec_read/spec_list) still have no ACL: anyone passing the Host allowlist can read approved content. graph's /query gates to owner+agents (graph.gate); /mcp does not. The design's read plane is fail-closed (owner+agents only). Add the same gate to the MCP surface. Pre-existing since Phase 2; not caused by Phase 3, but now that /mcp resolves a principal the gate is a one-liner. Deliberately left out of Phase 3 to avoid changing Phase 2 read behavior mid-feature.","status":"closed","priority":3,"issue_type":"bug","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-07-23T05:29:15Z","created_by":"Eugene Blikh","updated_at":"2026-07-24T16:45:32Z","started_at":"2026-07-24T16:42:15Z","closed_at":"2026-07-24T16:45:32Z","close_reason":"MCP surface gated to owner+agents (mcpsrv.Gate), mounted inside resolver middleware; tests cover anon 401 / owner+agent pass / fail-closed without middleware. Commit 7cc652d.","dependencies":[{"issue_id":"spec-jjo","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-23T08:29:28Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-wcr","title":"Loose ends from Phases 1-2 (latent bugs, no user impact yet)","description":"Real issues agents flagged and deliberately left alone during the parallel build. None blocks anything today; each is a trap for a later phase.","design":"1. ProposalBranch derived THREE ways: core.ProposalBranch (canonical), and a third copy in SQL — db.OpenProposal concatenates prefix||serial in its INSERT to allocate id+branch atomically. Shares the prefix constant but not the function; reconcile if they can ever diverge. 2. doc.linkHierarchy passes path.Dir(p.Path) i.e. '.' as fromDir where every other call site uses DirOf ('') — section-proximity lookup misses for root-level parent: targets. Latent; fixing changes resolution behaviour. 3. doc.SplitLog collides entry IDs within a space (two type:log docs -\u003e identical IDs -\u003e second silently overwrites first in one index). Defended in search/ but should be fixed at source in doc/. 4. doc.Scan/DocumentSource are production-dead after service.Archive landed — they're the documented seam but also the API that made web's layering violation writable; delete is a judgement call. 5. mcpsrv ships a beforeCAS test hook in production code (gitx too). 6. No GraphQL complexity limit — service authenticates on its own router, not core-go's WithSchema, so nothing bounds query cost; low risk on a single-user authed surface. 7. Attachments: gitx walks .md only, so image embeds render as visibly-missing; needs WalkBlobs + asset index, or prefer Mermaid by convention.","notes":"Triage 2026-07-24 (post Phase 5a). Investigated all 7 items:\n\n#1 ProposalBranch 3rd copy in OpenProposal SQL — DONE (commit e97532c): pure-Go guard test TestOpenProposalBranchMatchesCanonical asserts BranchPrefix||id == core.ProposalBranch(id). No behavior change.\n\n#2 linkHierarchy path.Dir vs DirOf — JUDGEMENT CALL, deferred. Fixing changes link-resolution behavior for root-level parent: targets; needs a design decision + fixture confirming intended resolution before touching.\n\n#3 SplitLog id collision — NOT A BUG as feared. Already correctly defended in search/extract.go (logEntries substitutes p.ID for the log# prefix → space-unique). \"Fix at source in doc/\" would push search-index-keyspace uniqueness into doc/, which shouldn't know it's indexed into a shared space index. Current layering (doc/ = within-doc identity, search/ = index-key uniqueness) is arguably correct. Recommend WONTFIX unless a second SplitLog consumer appears.\n\n#4 delete doc.Scan/DocumentSource — JUDGEMENT CALL (deletion), deferred. Production-dead after service.Archive; also the seam that made web's layering violation writable. Lean delete, but confirm no external/test reliance first.\n\n#5 beforeCAS \"test hook in production\" — NOT A BUG. gitx.beforeCAS is an unexported field set only by same-package tests (write_test/merge_test); zero prod cost, cannot be set externally. Standard test-seam pattern. mcpsrv has no such hook (item wording conflated). Recommend WONTFIX.\n\n#6 no GraphQL complexity limit — FEATURE, deferred. Low risk on single-user authed surface; wire only if /query is ever widened.\n\n#7 attachments (gitx walks .md only) — FEATURE, deferred. Real work: WalkBlobs + asset index, or adopt Mermaid-by-convention. Phase 5+ scope.\n\nActionable-now clear win (#1) done. Remainder are judgement calls (#2,#4), non-issues (#3,#5), or features (#6,#7) — each needs a decision, not mechanical fixing.","status":"open","priority":3,"issue_type":"chore","owner":"bigbes@gmail.com","created_at":"2026-07-23T04:09:52Z","created_by":"Eugene Blikh","updated_at":"2026-07-24T17:03:43Z","dependencies":[{"issue_id":"spec-wcr","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-23T07:09:52Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-wcr","title":"Loose ends from Phases 1-2 (latent bugs, no user impact yet)","description":"Real issues agents flagged and deliberately left alone during the parallel build. None blocks anything today; each is a trap for a later phase.","design":"1. ProposalBranch derived THREE ways: core.ProposalBranch (canonical), and a third copy in SQL — db.OpenProposal concatenates prefix||serial in its INSERT to allocate id+branch atomically. Shares the prefix constant but not the function; reconcile if they can ever diverge. 2. doc.linkHierarchy passes path.Dir(p.Path) i.e. '.' as fromDir where every other call site uses DirOf ('') — section-proximity lookup misses for root-level parent: targets. Latent; fixing changes resolution behaviour. 3. doc.SplitLog collides entry IDs within a space (two type:log docs -\u003e identical IDs -\u003e second silently overwrites first in one index). Defended in search/ but should be fixed at source in doc/. 4. doc.Scan/DocumentSource are production-dead after service.Archive landed — they're the documented seam but also the API that made web's layering violation writable; delete is a judgement call. 5. mcpsrv ships a beforeCAS test hook in production code (gitx too). 6. No GraphQL complexity limit — service authenticates on its own router, not core-go's WithSchema, so nothing bounds query cost; low risk on a single-user authed surface. 7. Attachments: gitx walks .md only, so image embeds render as visibly-missing; needs WalkBlobs + asset index, or prefer Mermaid by convention.","notes":"RESOLVED 2026-07-24. All 7 items dispositioned.\n\n#1 ProposalBranch 3rd copy in OpenProposal SQL — FIXED (e97532c), guard test.\n\n#2 linkHierarchy path.Dir vs DirOf — FIXED. Prior triage called this a\njudgement call that 'changes resolution behaviour'; that was wrong. Traced it:\npath.Dir and DirOf differ only for root-level documents, and there the\nsection-proximity step (stemsIn[topSection(fromDir)+'/'+bare]) is subsumed by\nthe same-directory step above it — stemsIn['/x'] is populated only by a root\nx.md, which byPath['x.md'] already returned. So '.' only ever skipped a lookup\nthat had already answered. VERIFIED by reverting to path.Dir and re-running:\nidentical results. Changed to DirOf as trap-removal, with\nTestParentResolvesLikeAWikilinkFromTheSameDocument pinning the invariant\n(parent: resolves to what the same wikilink in the body resolves to).\n\n#3 SplitLog id collision — WONTFIX. Already correctly defended in\nsearch/extract.go; 'fixing at source' would push index-keyspace uniqueness into\ndoc/, which shouldn't know it is indexed into a shared space index.\n\n#4 delete doc.Scan/DocumentSource — DONE. Confirmed production-dead (only\nconsumer was doc's own test fixture). Deleted both; doc/ now owns no way to read\na revision, leaving service.Archive as the single route from rev to Archive —\nclosing the seam that made web's layering violation writable. doc test fixture\nrewritten onto ListDocuments+FromDocuments; TestScanReportsGitErrors relocated\nto gitx as TestWalkOfAnUnknownRevisionFails (gitx had no unknown-rev coverage).\n\n#5 beforeCAS test hook — WONTFIX. Unexported, set only by same-package tests,\ncannot be set externally. Standard test-seam pattern; mcpsrv has no such hook.\n\n#6 GraphQL complexity limit — moved to spec-by6.1 (feature, Phase 5).\n#7 attachments — moved to spec-by6.2 (feature, Phase 5).","status":"closed","priority":3,"issue_type":"chore","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-07-23T04:09:52Z","created_by":"Eugene Blikh","updated_at":"2026-07-24T18:12:42Z","started_at":"2026-07-24T18:07:15Z","closed_at":"2026-07-24T18:12:42Z","close_reason":"All 7 items dispositioned: 2 fixed (#1,#2), 1 deleted (#4), 2 wontfix (#3,#5), 2 moved to Phase 5 (spec-by6.1, spec-by6.2).","dependencies":[{"issue_id":"spec-wcr","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-23T07:09:52Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-by6","title":"Phase 5: comments, webhooks, mutations, vector search","description":"The deferred pile, each unblocked once its prerequisite lands.","design":"Inline comments (anchor tuple already emitted by prosediff; settle resolution against the built review UI before committing a schema). Webhooks + notifications (core-go/webhooks is GraphQL-native; the Phase 2 read schema is the foundation). GraphQL mutations once the proposal state machine stops moving. Vector search over the project index (warren's embed/ was deliberately not ported). Per-space token scoping (currently one token; scoping is a column + filter clause). Read-only mounts of external corpora (second-brain, Confluence RFCs) if the boundary ever moves — global IDs keep the door open.","status":"open","priority":3,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-07-23T04:09:27Z","created_by":"Eugene Blikh","updated_at":"2026-07-23T04:09:27Z","dependencies":[{"issue_id":"spec-by6","depends_on_id":"spec-3vz","type":"blocks","created_at":"2026-07-23T07:09:27Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"spec-by6","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-23T07:09:26Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-by6.1","title":"GraphQL complexity limit on the /query surface","description":"spec.sr.ht authenticates on its own router rather than core-go's WithSchema, so nothing bounds query cost — no depth or complexity limit on /query. Low risk today: the surface is single-user and authenticated. Wire a limit if /query is ever widened beyond owner+agents. Carried over from spec-wcr item 6 (triaged 2026-07-24: a feature, not a latent bug).","status":"open","priority":4,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-07-24T18:12:20Z","created_by":"Eugene Blikh","updated_at":"2026-07-24T18:12:20Z","dependencies":[{"issue_id":"spec-by6.1","depends_on_id":"spec-by6","type":"parent-child","created_at":"2026-07-24T21:12:20Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-ejq.1","title":"Consolidate owner+agents read predicate into one Principal.CanRead()","description":"graph/server.go:161, web/handlers.go:61 and now mcpsrv.Gate each hand-spell 'IsOwner() || IsAgent()'. graph.gate's own comment warns two read surfaces with two policies is how a corpus leaks — there are now three copies. Add authn.Principal.CanRead() and route all three through it so the policy is defined once. Low-risk mechanical refactor; deferred out of spec-jjo to keep that bug fix surgical.","status":"closed","priority":4,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-07-24T16:45:16Z","created_by":"Eugene Blikh","updated_at":"2026-07-24T16:59:34Z","started_at":"2026-07-24T16:57:49Z","closed_at":"2026-07-24T16:59:34Z","close_reason":"authn.Principal.CanRead() defined once; graph.gate, web.mayRead, mcpsrv.Gate routed through it. coreauth left independent (different semantic). Unit test covers owner/agent true, anon/zero/unknown false. Commit pending push.","dependencies":[{"issue_id":"spec-ejq.1","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-24T19:45:15Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}

M doc/archive.go => doc/archive.go +5 -4
@@ 64,10 64,11 @@ func newArchive(sp core.SpaceRef, rev string) *Archive {
// FromPages rebuilds an Archive's lookup structures from a page set that was
// produced earlier, without reading anything.
//
// This is the seam the git-tree walk feeds: Scan produces pages and calls into
// here, and nothing downstream of Archive knows the difference. aliases and the
// attachment index are supplied separately because they are not derivable from
// a Page; pass nil for either when they are not needed.
// This is the seam the git-tree walk feeds: a caller reads a revision's
// documents, [FromDocuments] turns them into pages, and nothing downstream of
// Archive knows the difference. aliases and the attachment index are supplied
// separately because they are not derivable from a Page; pass nil for either
// when they are not needed.
func FromPages(sp core.SpaceRef, rev string, pages []*Page, aliases, assets map[string]string) *Archive {
	a := newArchive(sp, rev)
	a.Pages = pages

M doc/doc.go => doc/doc.go +9 -7
@@ 8,13 8,15 @@
// # No filesystem
//
// warren scanned a directory: filepath.WalkDir plus os.ReadFile. There is no
// checkout here, so Scan walks a git tree instead — every read resolves a
// checkout here, so a caller walks a git tree instead — every read resolves a
// revision and reads blobs, which is what makes the approved head, a pinned
// ?rev=<sha> and a proposal branch the same code path with a different rev.
// The seam warren already had, FromPages, is untouched in spirit: it builds an
// Archive out of a page set with no I/O at all, and everything downstream of
// Archive — resolution, backlinks, hierarchy, rendering — is unaware of where
// the pages came from.
// This package does no I/O of its own: FromDocuments and the seam warren
// already had, FromPages, build an Archive out of a document set that is handed
// to them, and everything downstream of Archive — resolution, backlinks,
// hierarchy, rendering — is unaware of where the pages came from. Resolving a
// revision and reading it belongs to service/, which is the layer that owns the
// read contract.
//
// # One frontmatter parser
//


@@ 98,8 100,8 @@ type Page struct {
	// parent (not including the document itself).
	Crumbs []string `json:"crumbs,omitempty"`
	// Links holds the IDs of documents this one links to, deduplicated, in
	// order of first appearance. Filled in by [Archive.LinkPass], not by Scan:
	// links come out of a render, not out of a frontmatter parse.
	// order of first appearance. Filled in by [Archive.LinkPass], not when the
	// archive is built: links come out of a render, not a frontmatter parse.
	Links []string `json:"links,omitempty"`
	// WordCount is an approximate word count of the rendered text. Filled in by
	// [Archive.LinkPass], from the same render.

M doc/fixture_test.go => doc/fixture_test.go +16 -5
@@ 74,17 74,28 @@ func commit(t *testing.T, repo *gitx.Repo, n int, base string, files map[string]
	return branch
}

// archiveOf commits files into a fresh space and scans the result — the whole
// path from git objects to Archive.
// archiveOf commits files into a fresh space and builds the archive of the
// result — the whole path from git objects to Archive.
//
// The read half is spelled out here rather than hidden behind a helper in this
// package, because this package no longer has one: service/ owns the one route
// from a revision to an Archive, and a convenience wrapper here would be a
// second one growing back.
func archiveOf(t *testing.T, files map[string]string) *Archive {
	t.Helper()
	repo := space(t)
	rev := commit(t, repo, 1, repo.ApprovedBranch(), files)
	arc, err := Scan(context.Background(), repo, fxSpace, rev)
	return archiveAt(t, repo, rev)
}

// archiveAt reads one revision and builds its archive.
func archiveAt(t *testing.T, repo *gitx.Repo, rev string) *Archive {
	t.Helper()
	docs, err := repo.ListDocuments(context.Background(), rev)
	if err != nil {
		t.Fatalf("Scan(%q): %v", rev, err)
		t.Fatalf("ListDocuments(%q): %v", rev, err)
	}
	return arc
	return FromDocuments(fxSpace, rev, docs)
}

// mustPage looks a document up by path, failing the test when it is absent.

M doc/scan.go => doc/scan.go +18 -35
@@ 2,8 2,6 @@ package doc

import (
	"bytes"
	"context"
	"path"
	"sort"
	"strings"



@@ 11,43 9,22 @@ import (
	"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)

// DocumentSource is the read side of a space: everything this package needs
// from git. *gitx.Repo satisfies it.
//
// It is an interface rather than a *gitx.Repo so that the archive can be built
// over any document set — a test fixture, a cached tree — without pretending to
// be a repository. It is deliberately one method: an Archive is a whole
// revision, so there is nothing to read lazily.
type DocumentSource interface {
	WalkDocuments(ctx context.Context, rev string, fn func(gitx.Document) error) error
}

// Scan builds the Archive of a space at a revision. It is what warren's
// Scan(root string) became: the same result, walking a git tree instead of a
// directory.
// FromDocuments builds an Archive out of already-read documents. It performs no
// I/O at all, which is the property that keeps this package off git: a caller
// resolves a revision, reads its blobs, and hands the result over.
//
// rev is anything gitx resolves — the approved branch, a proposal branch, or a
// pinned commit sha — which is what makes the read plane, the review UI and a
// `?rev=` request one code path. Pass a resolved sha when the archive must stay
// pinned to what the caller already saw.
// This package deliberately owns no way to read a revision itself. It used to
// export one — Scan(ctx, DocumentSource, ...), a walk-and-build wrapper over
// *gitx.Repo — and that was the seam a surface used to reach past service/ into
// gitx and build its own archive, which is the layering violation
// [service.Service.Archive] exists to close. One route from a revision to an
// Archive means every surface resolves, links and addresses documents the same
// way; two routes means they agree until one of them is changed.
//
// Attachments are not enumerated: gitx's walk yields documents only, so an
// Attachments are not enumerated: a git walk yields documents only, so an
// archive built this way resolves `![[diagram.png]]` to a visibly missing link
// rather than to an attachment it cannot see. Use FromPages when a caller has
// an attachment index to supply.
func Scan(ctx context.Context, src DocumentSource, sp core.SpaceRef, rev string) (*Archive, error) {
	var docs []gitx.Document
	if err := src.WalkDocuments(ctx, rev, func(d gitx.Document) error {
		docs = append(docs, d)
		return nil
	}); err != nil {
		return nil, err
	}
	return FromDocuments(sp, rev, docs), nil
}

// FromDocuments builds an Archive out of already-read documents. It performs no
// I/O, so it is also the seam a caller with its own source of blobs uses.
func FromDocuments(sp core.SpaceRef, rev string, docs []gitx.Document) *Archive {
	sorted := make([]gitx.Document, len(docs))
	copy(sorted, docs)


@@ 175,12 152,18 @@ const maxCrumbDepth = 32
// parent that resolves to nothing leaves the document at the top level rather
// than failing the scan: a broken `parent:` is a defect in one document, not a
// reason to serve none.
//
// `parent:` is resolved through the same lookup as any other wikilink, from the
// linking document's own directory, so a bare `parent: [[storage]]` prefers the
// storage beside it. DirOf, not path.Dir: lookupPage keys sections off "" for
// the space root, and path.Dir's "." would silently skip the section-proximity
// step for every root-level document.
func (a *Archive) linkHierarchy() {
	for _, p := range a.Pages {
		if p.ParentID == "" {
			continue
		}
		parent := a.lookupPage(path.Dir(p.Path), p.ParentID)
		parent := a.lookupPage(DirOf(p.Path), p.ParentID)
		if parent == nil || parent.ID == p.ID {
			p.ParentID = ""
			continue

M doc/scan_test.go => doc/scan_test.go +42 -15
@@ 96,7 96,7 @@ func TestScanRefusesToResolveADuplicatedID(t *testing.T) {

// The approved head, a pinned sha and a proposal branch are the same code path
// with a different revision. That is the whole reason the checkout was dropped.
func TestScanReadsAnyRevisionThroughOnePath(t *testing.T) {
func TestArchiveIsBuiltTheSameWayAtEveryRevision(t *testing.T) {
	ctx := context.Background()
	repo := space(t)



@@ 112,14 112,7 @@ func TestScanReadsAnyRevisionThroughOnePath(t *testing.T) {
		"notes/aside.md":        "# Aside\n",
	})

	at := func(rev string) *Archive {
		t.Helper()
		arc, err := Scan(ctx, repo, fxSpace, rev)
		if err != nil {
			t.Fatalf("Scan(%q): %v", rev, err)
		}
		return arc
	}
	at := func(rev string) *Archive { return archiveAt(t, repo, rev) }

	head := at(pinned.String())
	if len(head.Pages) != 1 {


@@ 141,15 134,49 @@ func TestScanReadsAnyRevisionThroughOnePath(t *testing.T) {
	}
}

func TestScanReportsGitErrors(t *testing.T) {
	repo := space(t)
	if _, err := Scan(context.Background(), repo, fxSpace, "no-such-branch"); err == nil {
		t.Fatal("Scan of an unknown revision must fail rather than return an empty archive")
// `parent:` is a wikilink, so it must resolve to the same document the same
// link written in the body would. The two are separate code paths that happen
// to call the same lookup, and they agree only as long as the hierarchy pass
// hands it the directory shape Resolve expects — "" at the space root, which is
// what DirOf produces and what path.Dir spells "." instead.
//
// The hierarchy pass used to spell it path.Dir. That was unobservable: for a
// root-level document the section-proximity step is subsumed by the
// same-directory step above it, so "." only ever skipped a lookup that had
// already answered. It stayed unobservable by coincidence of two ranking rules,
// which is the kind of thing that stops being true quietly. The colliding stem
// below is what makes the two directories name different documents, so this
// test fails if a future change gives that skipped step something to say.
func TestParentResolvesLikeAWikilinkFromTheSameDocument(t *testing.T) {
	arc := archiveOf(t, map[string]string{
		"storage.md":       spec("SPEC-0100", "Root storage", "the root one"),
		"specs/storage.md": spec("SPEC-0200", "Section storage", "the specs one"),
		"overview.md":      "---\nid: SPEC-0300\ntitle: Overview\nstatus: draft\nparent: \"[[storage]]\"\n---\n\nbody\n",
		"specs/detail.md":  "---\nid: SPEC-0400\ntitle: Detail\nstatus: draft\nparent: \"[[storage]]\"\n---\n\nbody\n",
	})

	for path, want := range map[string]string{
		"overview.md":     "SPEC-0100", // at the root: the root storage
		"specs/detail.md": "SPEC-0200", // under specs/: the one beside it
	} {
		p := mustPage(t, arc, path)
		if p.ParentID != want {
			t.Errorf("%s parent = %q, want %q", path, p.ParentID, want)
		}
		if len(p.Crumbs) != 1 || p.Crumbs[0] != want {
			t.Errorf("%s crumbs = %v, want [%s]", path, p.Crumbs, want)
		}
		// The invariant behind the fix, stated directly: whatever the body's
		// own [[storage]] resolves to is what `parent:` must have resolved to.
		if got := arc.Resolve(DirOf(p.Path), "storage"); got.PageID != p.ParentID {
			t.Errorf("%s: parent resolved to %q but the same wikilink in its body resolves to %q",
				path, p.ParentID, got.PageID)
		}
	}
}

// Scan sorts by path, so an archive is identical whatever order the tree walk
// yields — which is what makes the index reproducible.
// FromDocuments sorts by path, so an archive is identical whatever order the
// tree walk yields — which is what makes the index reproducible.
func TestFromDocumentsIsPathOrdered(t *testing.T) {
	docs := []gitx.Document{
		{Path: "specs/z.md", Data: []byte(spec("SPEC-0002", "Z", "z"))},

M gitx/read_test.go => gitx/read_test.go +21 -0
@@ 54,6 54,27 @@ func TestWalkDocumentsYieldsOnlyMarkdown(t *testing.T) {
	}
}

// A revision that does not exist is an error, never an empty walk. Callers
// above this layer turn a document set straight into an archive, so a silent
// empty result would render as a space that exists and holds nothing.
func TestWalkOfAnUnknownRevisionFails(t *testing.T) {
	repo, _ := newSpace(t)
	ctx := context.Background()

	pushApproved(t, repo, ownerMeta("seed", 1),
		Write{Path: "specs/0007-storage.md", Content: doc("SPEC-0007", "Storage", "alpha")})

	if _, err := repo.ListDocuments(ctx, "no-such-branch"); err == nil {
		t.Fatal("ListDocuments of an unknown revision returned no error")
	}
	if err := repo.WalkDocuments(ctx, "no-such-branch", func(Document) error {
		t.Fatal("WalkDocuments yielded a document at an unknown revision")
		return nil
	}); err == nil {
		t.Fatal("WalkDocuments of an unknown revision returned no error")
	}
}

func TestReadIsTheSamePathForEveryRevision(t *testing.T) {
	repo, _ := newSpace(t)
	ctx := context.Background()