---
phase: 08-derived-link-tag-store-maintenance
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - internal/store/migrations/0009_graph.sql
  - internal/graph/store.go
  - internal/graph/graphjob.go
  - internal/graph/rebuild.go
  - internal/graph/extract.go
  - internal/graph/store_test.go
  - internal/graph/rebuild_test.go
  - internal/graph/concurrency_test.go
autonomous: true
requirements: [LINK-01]
must_haves:
  truths:
    - "A page's outbound internal .md links are stored as forward edges in page_links (src_path -> dst_path), skipping external/absolute links and dangling links to nonexistent .md files"
    - "Backlinks for a page are the reverse query on page_links (SELECT src_path WHERE dst_path = ?) with no separate table"
    - "A page's frontmatter tags are stored as raw membership rows in page_tags (page_path -> tag), matching exactly what search.readTags reads"
    - "Deleting both tables and running RebuildGraph from the on-disk Markdown files reproduces byte-identical adjacency (page_links + page_tags rows) — SQLite is never the source of truth"
    - "The KindGraph upsert handler re-scans one page and atomically replaces only that page's src_path link rows and page_path tag rows; delete removes rows where src_path=path OR dst_path=path AND page_path=path"
    - "Concurrent KindGraph upsert/delete/rebuild on the single drain goroutine never deadlocks or data-races (-race clean)"
  artifacts:
    - path: "internal/store/migrations/0009_graph.sql"
      provides: "page_links + page_tags derived-cache tables + graph_meta key/value"
      contains: "CREATE TABLE IF NOT EXISTS page_links"
    - path: "internal/graph/graphjob.go"
      provides: "KindGraph const, graphPayload, UpsertPagePayload/DeletePagePayload/RebuildPayload, GraphHandler"
      exports: ["KindGraph", "GraphHandler", "UpsertPagePayload", "DeletePagePayload", "RebuildPayload"]
    - path: "internal/graph/rebuild.go"
      provides: "RebuildGraph from-files walk + StoreHead/DriftCheck for startup drift"
      contains: "func (s *Store) RebuildGraph"
    - path: "internal/graph/extract.go"
      provides: "outbound-edge extraction reusing okf.FindLinks + path resolver, and readTags reuse"
      contains: "okf.FindLinks"
  key_links:
    - from: "internal/graph/extract.go"
      to: "internal/okf/links.go"
      via: "okf.FindLinks(body) + path.Clean(path.Join(dir, dest)) resolution mirroring RewriteLinks"
      pattern: "okf\\.FindLinks"
    - from: "internal/graph/extract.go"
      to: "internal/search/service.go"
      via: "tag membership read sequence-aware (same logic as search.readTags)"
      pattern: "FieldTags"
    - from: "internal/graph/graphjob.go"
      to: "internal/jobs/worker.go"
      via: "GraphHandler returns a jobs.Handler registered for KindGraph on the single worker"
      pattern: "jobs\\.Handler"
---

<objective>
Build the derived link/tag adjacency store as a new `internal/graph` package: two SQLite cache tables (`page_links`, `page_tags`), a `KindGraph` job (upsert/delete/rebuild) that mirrors `search.KindIndex` exactly, a `RebuildGraph` from-files path that mirrors `search.RebuildIndex`, and the byte-stable extraction that REUSES `okf.FindLinks` (forward links) and the sequence-aware tag reader (`search.readTags` logic) — never a new scanner.

Purpose: This is the foundation layer (LINK-01) that all of v1.0's graph UI (Phases 9–10) and tag-vocabulary prompting (Phases 11–12) read from. The store is a DERIVED CACHE: deleting it and rebuilding from the `.md` files on disk must reproduce identical adjacency. SQLite is never the source of truth (locked invariant from CLAUDE.md + 08-CONTEXT.md).

Output: `internal/store/migrations/0009_graph.sql`, the `internal/graph` package (`store.go`, `graphjob.go`, `rebuild.go`, `extract.go`), and three test files proving derived-only rebuild equivalence and `-race` safety. NO wiring into mutations (that is 08-02) and NO admin endpoint (that is 08-03) — this plan is the data layer + job handler only.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-derived-link-tag-store-maintenance/08-CONTEXT.md
@.planning/research/ARCHITECTURE.md

# Patterns to mirror EXACTLY (read these — the new code is a structural clone):
@internal/search/indexjob.go
@internal/search/rebuild.go
@internal/search/service.go
@internal/search/meta.go
@internal/okf/links.go
@internal/jobs/worker.go
@internal/store/migrations/0007_search.sql
</context>

<artifacts_this_phase_produces>
New symbols introduced by THIS plan (08-01). 08-02 and 08-03 consume these names:
- Migration `0009_graph.sql` — tables `page_links(src_path, dst_path)`, `page_tags(page_path, tag)`, `graph_meta(key, value)`.
- Package `internal/graph` with exported type `Store` (constructor `OpenStore(db *sql.DB)` or equivalent; attaches repo via `SetRepo`, git head provider via `SetGit`, mirroring `search.Index`).
- `graph.KindGraph` (const `"graph"`).
- `graph.GraphHandler(store *Store, r *repo.Repo) jobs.Handler` — the handler registered on the single worker.
- `graph.UpsertPagePayload(pagePath string) string`, `graph.DeletePagePayload(pagePath string) string`, `graph.RebuildPayload() string`.
- `(*Store).RebuildGraph(ctx) error`, `(*Store).StoreHead(ctx, headProvider) error`, `(*Store).DriftCheck(ctx, headProvider) (bool, error)`.
</artifacts_this_phase_produces>

<tasks>

<task type="auto">
  <name>Task 1: Migration 0009 + graph Store with derived cache tables</name>
  <files>internal/store/migrations/0009_graph.sql, internal/graph/store.go, internal/graph/store_test.go</files>
  <read_first>
    @internal/store/migrations/0007_search.sql (migration shape, IF NOT EXISTS discipline, header comment style)
    @internal/search/meta.go (Store struct fields pattern: db, repo, gs/headProvider; readMeta/writeMeta/StoreHead/DriftCheck to clone for graph_meta)
    @internal/search/index.go (lines 23-31, 90-94: Index struct fields + SetRepo/SetDB/SetGit setters to mirror)
  </read_first>
  <action>
Create `internal/store/migrations/0009_graph.sql` (the next number after 0008 — confirmed latest). Header comment must state these are DERIVED, rebuildable-from-files caches (operational metadata only, never source of truth), mirroring the 0007_search.sql header tone. Define three tables with `CREATE TABLE IF NOT EXISTS`:
- `page_links (src_path TEXT NOT NULL, dst_path TEXT NOT NULL, PRIMARY KEY (src_path, dst_path))` — one row per resolved internal forward edge (src links to dst). Add `CREATE INDEX IF NOT EXISTS idx_page_links_dst ON page_links (dst_path)` for O(backlinks) reverse lookup.
- `page_tags (page_path TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (page_path, tag))` — raw per-page tag membership. Add `CREATE INDEX IF NOT EXISTS idx_page_tags_tag ON page_tags (tag)` for the future shared-tag join (Phase 9 consumes; build the index now).
- `graph_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)` — key/value bookkeeping (first key `last_graphed_head` for startup drift), cloning `search_meta`.

Create `internal/graph/store.go` defining the `Store` type that owns `db *sql.DB`, `repo *repo.Repo`, and a git head provider field. Mirror `search.Index`'s constructor + setter shape: `OpenStore(db *sql.DB) *Store`, `SetRepo(r *repo.Repo)`, `SetGit(gs headProvider)`. Define a local `headProvider interface { HeadSHA(ctx context.Context) (string, error) }` identical to search's (kept local to avoid importing search). Implement `readMeta`/`writeMeta`/`StoreHead`/`DriftCheck` against `graph_meta` by cloning `internal/search/meta.go` (key constant `last_graphed_head`). Do NOT import `internal/search` — this package must not depend on it (the tag-read logic is re-implemented in extract.go in Task 2, not imported).

Add `store_test.go` with a harness that opens an in-memory or temp SQLite DB, applies migrations (via the store package's Migrate, same as search tests do), and asserts page_links/page_tags/graph_meta exist and accept inserts. Use a no-db-nil-safe path consistent with search (db==nil => no-op meta).
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go build ./... && go test ./internal/graph/ -run TestStore -count=1</automated>
  </verify>
  <acceptance_criteria>
    - `0009_graph.sql` exists with page_links, page_tags, graph_meta tables + the two indexes, all `IF NOT EXISTS`.
    - Migration applies idempotently (re-running Migrate is a no-op) and the schema_migrations row 0009 is recorded.
    - `graph.Store` compiles with db/repo/headProvider fields and SetRepo/SetGit/StoreHead/DriftCheck against graph_meta.
    - `internal/graph` does NOT import `internal/search` (verify: `go list -deps ./internal/graph` excludes the search package — confirmed by the build + an explicit grep that no `internal/search` import appears in the package files).
    - store_test.go passes: tables queryable, drift check returns true when stored head != current.
  </acceptance_criteria>
  <done>Migration 0009 and the graph Store skeleton (tables + meta/drift bookkeeping) exist and pass tests; package is search-independent.</done>
</task>

<task type="auto">
  <name>Task 2: Edge + tag extraction reusing okf.FindLinks and sequence-aware tag read</name>
  <files>internal/graph/extract.go</files>
  <read_first>
    @internal/okf/links.go (FindLinks, splitFragment, isAbsoluteOrExternal, RewriteLinks lines 349-386 for the exact resolution recipe: path.Clean(path.Join(fromDir, dest)))
    @internal/search/service.go (readTags: the sequence-aware yaml.Node walk over doc.Front for FieldTags — re-implement this logic here, do NOT import search)
    @internal/search/indexjob.go (lines 125-145 indexPage: how a page is Read->Parse->Field/readTags, the shape to mirror per-page)
    @internal/okf/okf.go (lines 24-41: FieldTags / FieldTitle constants; okf.Parse, okf.Field, doc.Front, doc.Body)
  </read_first>
  <action>
Create `internal/graph/extract.go` with two pure functions over a parsed page, both REUSING existing parsers (no new scanner — locked decision):

1. `outboundLinks(srcPath string, body []byte, exists func(repoRelPath string) (bool, error)) ([]string, error)` — returns the resolved, deduped repo-relative `.md` destinations this page links to. Implementation: call `okf.FindLinks(body)`; for each link, split off the `#fragment` (reuse the same split logic okf uses — strip at the first `#`), skip empty dests and `isAbsoluteOrExternal` dests (re-implement the same predicate okf uses: leading `/`, or a scheme `:` before any `/`), resolve the rest with `path.Clean(path.Join(path.Dir(srcPath), dest))`. Keep an edge ONLY if the resolved target ends in `.md` AND `exists(resolved)` is true — a dangling link to a nonexistent `.md` is silently dropped (locked: do not surface dangling links this phase). Dedupe and exclude a self-edge (src == dst). Note in a comment that this mirrors `okf.RewriteLinks`'s resolution exactly so forward edges agree with the rename link-rewrite.

2. `pageTags(doc *okf.Doc) []string` — re-implement `search.readTags`'s sequence-aware walk: find the top-level `tags` key in `doc.Front` (handle DocumentNode->MappingNode), collect `yaml.SequenceNode` scalar items (trimmed, non-empty) or a single `yaml.ScalarNode`, returning nil otherwise. This must produce IDENTICAL output to `search.readTags` for the same doc (so page_tags matches what search understands). Add a comment citing that parity requirement.

Do NOT place the `isAbsoluteOrExternal` literal predicate text as a negative-grep target anywhere. These are normal helper functions.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go test ./internal/graph/ -run TestExtract -count=1</automated>
  </verify>
  <acceptance_criteria>
    - `outboundLinks` returns resolved repo-relative `.md` targets for relative links; skips http/https/mailto/absolute links; drops dangling links whose target does not exist; drops self-edges; dedupes.
    - A link inside a fenced/inline code block is NOT returned (inherited from okf.FindLinks — add a test case asserting it).
    - `pageTags` returns the same slice `search.readTags` would for: `tags: [a, b]`, a block list, a single scalar `tags: ops`, and no tags (nil). A cross-check test parses the same doc bytes and asserts equality against the documented expected set.
    - extract.go does not import internal/search.
  </acceptance_criteria>
  <done>Forward-link and tag extraction reuse okf.FindLinks + the sequence-aware tag logic and produce adjacency that agrees with the existing parsers.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: KindGraph job (upsert/delete/rebuild) + RebuildGraph + derived-only and -race tests</name>
  <files>internal/graph/graphjob.go, internal/graph/rebuild.go, internal/graph/rebuild_test.go, internal/graph/concurrency_test.go</files>
  <read_first>
    @internal/search/indexjob.go (KindIndex const, indexPayload JSON shape, *Payload builders, IndexHandler with defer-recover + Op dispatch — clone this structure verbatim for graph)
    @internal/search/rebuild.go (RebuildIndex: repo.Tree() walk, skip dirs/non-.md/trashed, repo.Read, okf.Parse, per-file skip-on-error WR-06, StoreHead at end — clone the walk for RebuildGraph rewriting both tables in one tx)
    @internal/search/concurrency_test.go (TestIndex_ConcurrentReadWrite CR-01 pattern: concurrent upsert/delete/rebuild goroutines under -race — clone for KindGraph)
    @internal/repo/files.go (lines 22-27, 96-123: TreeItem{Path,IsDir}, Tree(), Read(), Exists() signatures)
  </read_first>
  <behavior>
    - Test (derived-only invariant — the hard success criterion): seed N pages on a temp repo with known links + tags; run RebuildGraph; snapshot page_links + page_tags rows. Then DELETE FROM both tables and run RebuildGraph again; assert the row sets are byte-identical to the snapshot. This proves SQLite is never the source of truth.
    - Test (incremental == rebuild): apply a series of per-page upsert/delete jobs (mirroring what mutations will enqueue), then run a full RebuildGraph into a fresh comparison; assert the incremental table state equals the from-scratch state for the same files.
    - Test (upsert replaces only the page's rows): upsert page A (links to B, C; tags x,y). Re-scan A after its body changed to link only B and tag only x; assert A's old C-edge and y-tag rows are gone, B's and other pages' rows untouched.
    - Test (delete): delete(A) removes rows where src_path=A OR dst_path=A (page_links) and page_path=A (page_tags); edges from other pages pointing at A are removed too.
    - Test (-race, CR-01): concurrent goroutines running upsert, delete, and RebuildGraph against the shared Store on the single-writer discipline; assert no panic / no race / no deadlock.
  </behavior>
  <action>
Create `internal/graph/graphjob.go` cloning `internal/search/indexjob.go`:
- `const KindGraph = "graph"`.
- `graphPayload struct { Op string; Path string }` with Op in `upsert|delete|rebuild` (JSON-tagged).
- `UpsertPagePayload(pagePath) string`, `DeletePagePayload(pagePath) string`, `RebuildPayload() string` — JSON marshal helpers (payload shape owned here, mirroring search).
- `GraphHandler(store *Store, r *repo.Repo) jobs.Handler` — attaches repo if nil (like IndexHandler), returns a handler with `defer recover()` converting a panic to a returned error (so the single drain goroutine survives), unmarshals graphPayload, dispatches: `rebuild` -> `store.RebuildGraph(ctx)`; `upsert` -> `store.upsertPage(ctx, p.Path)`; `delete` -> `store.deletePage(ctx, p.Path)`; unknown op -> error; never re-enqueues (and if it ever did, Enqueue not EnqueueAndWait — CR-01).

In `store.go` (or graphjob.go) implement:
- `upsertPage(ctx, path)`: Read via repo.Read; if the file does not exist treat as delete (a stale upsert for a removed page is a no-op delete); okf.Parse; compute outboundLinks (passing repo.Exists as the existence check) + pageTags; in ONE transaction delete this page's existing rows (page_links WHERE src_path=path; page_tags WHERE page_path=path) then insert the fresh edge/tag sets. A parse error on one page is logged and swallowed (best-effort over files, like search).
- `deletePage(ctx, path)`: one tx deleting page_links WHERE src_path=path OR dst_path=path, and page_tags WHERE page_path=path.

Create `internal/graph/rebuild.go` cloning `internal/search/rebuild.go`'s walk:
- `RebuildGraph(ctx)`: require repo (SetRepo); walk `repo.Tree()`, skip dirs / non-`.md` / trashed (re-use the same trash-prefix constant `.okf-workspace/trash` as search's `trashPrefix`, kept local); for each live page repo.Read + okf.Parse (skip-on-error WR-06, log Warn, continue), accumulate its outbound edges + tags into in-memory maps. Then in ONE transaction `DELETE FROM page_links; DELETE FROM page_tags;` and bulk-insert the accumulated rows, so a from-scratch rebuild fully rewrites both tables (the correctness backstop). Build edges only for targets that exist in the walked set (resolve dangling against the same live-page set so rebuild and incremental agree). After a successful rebuild, `StoreHead(ctx, gs)` to record last_graphed_head (only if gs set), done LAST so a partial rebuild never claims an in-sync HEAD.

Add `rebuild_test.go` and `concurrency_test.go` implementing the <behavior> tests above. Reuse a temp-repo harness in the same style as search's harness_test.go (a real repo.Repo over a tmp dir + a real migrated SQLite db). The -race test MUST be runnable via `go test -race`.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go test ./internal/graph/ -count=1 && CGO_ENABLED=1 go test ./internal/graph/ -race -run 'TestGraph_Concurrent|TestRebuildGraph|TestUpsert|TestDelete|TestDerivedOnly' -count=1</automated>
  </verify>
  <acceptance_criteria>
    - The derived-only test passes: delete both tables + RebuildGraph reproduces byte-identical page_links + page_tags row sets.
    - The incremental-equals-rebuild test passes for a sequence of upsert/delete ops vs a from-scratch rebuild over the same files.
    - upsert replaces ONLY the target page's link + tag rows (other pages untouched); delete removes inbound + outbound edges of the page and its tag rows.
    - The CR-01-style concurrency test passes under `-race` with no panic, race, or deadlock on the single-writer Store.
    - GraphHandler recovers a handler panic into a returned error (a malformed/corrupt scenario does not wedge the worker).
  </acceptance_criteria>
  <done>The KindGraph job + RebuildGraph reproduce identical derived adjacency from files, maintain per-page rows incrementally, and are -race clean — the LINK-01 data layer is proven.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| page Markdown body/frontmatter -> graph extractor | Untrusted page content (link destinations, tag strings) is parsed into adjacency; a crafted link/path must not escape the repo or wedge the worker |
| SQLite graph tables -> downstream graph reads (Phase 9) | Derived cache; if it diverges from files it must self-heal via rebuild, never be trusted as truth |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-01 | Tampering | link destination resolution in extract.go | mitigate | Resolution uses path.Clean(path.Join(...)) and stores only repo-relative results; existence is checked via repo.Exists (the SEC-01 resolver) so a `../` traversal cannot create an edge to a path outside the repo. Reads go through repo.Read, never os.* |
| T-08-02 | Denial of Service | GraphHandler on the single drain goroutine | mitigate | defer-recover() converts a parser/SQLite panic into a returned error (mirrors IndexHandler) so one corrupt page never kills the worker; per-page parse errors are skipped (WR-06), never fatal |
| T-08-03 | Denial of Service | RebuildGraph full walk | accept | At ~5 users / a few hundred pages a full rebuild is cheap and runs on the worker, off the request path; node-count scaling is out of v1.0 scope (research) |
| T-08-04 | Information disclosure | dangling/unresolved links | accept | Dangling links are silently dropped (not stored, not surfaced) per locked decision — no new disclosure surface |
| T-08-SC | Tampering | npm/pip/cargo installs | mitigate | This plan adds NO new dependency (graph rides existing stdlib + okf + jobs); no package install task exists, so no legitimacy checkpoint is required |
</threat_model>

<verification>
- `go build ./...` succeeds (single CGO-free binary constraint preserved; no new dependency added).
- `go test ./internal/graph/ -count=1` passes; the derived-only rebuild-equivalence test passes.
- `CGO_ENABLED=1 go test ./internal/graph/ -race` passes (CR-01 single-writer discipline proven for KindGraph).
- `go vet ./internal/graph/` is clean.
- Grep confirms `internal/graph` does not import `internal/search`.
</verification>

<success_criteria>
- Migration 0009 creates page_links + page_tags (+ graph_meta) derived-cache tables, applied idempotently.
- The `internal/graph` package exposes KindGraph, GraphHandler, the three payload builders, RebuildGraph, StoreHead, DriftCheck.
- Forward-link extraction reuses okf.FindLinks + the okf resolution recipe; tag extraction matches search.readTags output.
- Deleting both tables and rebuilding from files reproduces byte-identical adjacency (the hard LINK-01 derived-only invariant), proven by an automated test.
- The job is -race clean on the single drain goroutine.
</success_criteria>

<output>
Create `.planning/phases/08-derived-link-tag-store-maintenance/08-01-SUMMARY.md` when done. Record the exact exported symbol names (Store constructor, KindGraph, GraphHandler, payload builders, RebuildGraph/StoreHead/DriftCheck) so 08-02 and 08-03 wire against the real names.
</output>
