---
phase: 08-derived-link-tag-store-maintenance
plan: 02
type: execute
wave: 2
depends_on: ["08-01"]
files_modified:
  - cmd/okf-workspace/main.go
  - internal/pages/service.go
  - internal/pages/rename.go
  - internal/pages/trash.go
  - internal/pages/graphenqueue_test.go
autonomous: true
requirements: [LINK-01]
must_haves:
  truths:
    - "The KindGraph handler is registered on the single worker at startup and a startup-drift rebuild self-heals the store when it diverges from the working tree (mirroring the search drift rebuild)"
    - "Every pages mutation enqueues a fire-and-forget graph job beside its existing search enqueue: create -> upsert, save -> upsert, create-folder -> upsert, rename/move -> delete(old)+upsert(new)+upsert(each rewritten linker), folder rename/move -> per-moved delete(old)+upsert(new)+upsert(rewritten), delete-to-trash -> delete, folder delete -> per-page delete, restore -> upsert"
    - "After ANY mutation kind, the stored link/backlink/tag adjacency reflects the change without an app restart"
    - "On rename/move, inbound edges from linker pages are rewritten (no stale src/dst rows) because every rewritten linker page is re-scanned — the #1 research pitfall is closed"
    - "A dropped graph enqueue is logged at Warn and swallowed (the rebuild backstop reconciles) — a save is never blocked or failed by graph maintenance"
  artifacts:
    - path: "cmd/okf-workspace/main.go"
      provides: "worker.Register(graph.KindGraph, graph.GraphHandler(...)) + startup graph drift rebuild + Store wiring (SetRepo/SetDB/SetGit)"
      contains: "graph.KindGraph"
    - path: "internal/pages/service.go"
      provides: "enqueueGraphUpsert / enqueueGraphDelete helpers + calls in Create/Save/CreateFolder"
      contains: "enqueueGraphUpsert"
  key_links:
    - from: "internal/pages/service.go"
      to: "internal/graph/graphjob.go"
      via: "worker.Enqueue(ctx, graph.KindGraph, graph.UpsertPagePayload(path)) fire-and-forget"
      pattern: "graph\\.KindGraph"
    - from: "cmd/okf-workspace/main.go"
      to: "internal/graph/rebuild.go"
      via: "startup DriftCheck -> Enqueue(graph.KindGraph, graph.RebuildPayload()) mirroring search drift"
      pattern: "graph\\.RebuildPayload"
    - from: "internal/pages/rename.go"
      to: "internal/graph/graphjob.go"
      via: "delete(old)+upsert(new)+upsert(rewritten linkers) beside the existing index enqueues in relocate/relocateFolder"
      pattern: "enqueueGraph"
---

<objective>
Wire the `KindGraph` job (built in 08-01) into the running system: register the handler + a startup-drift rebuild in `main.go` (mirroring the existing search wiring), and add `enqueueGraphUpsert`/`enqueueGraphDelete` calls beside EVERY existing `enqueueIndexUpsert`/`enqueueIndexDelete` across all pages mutation methods. Then prove with integration tests that the store stays fresh after each mutation kind — with explicit coverage of the rename/move stale-edge pitfall.

Purpose: This completes LINK-01's freshness requirement: after any page mutation (create / save / rename / move / delete-to-trash / restore + the Phase-7 folder rename/move/delete) the stored adjacency reflects the change without a restart. Stale edges on rename/move are the #1 research pitfall (PITFALLS.md pitfall 2) — closed here by re-scanning every rewritten linker page.

Output: graph handler + startup drift registered in `main.go`; graph enqueue siblings in `service.go`, `rename.go`, `trash.go`; a per-mutation-kind freshness integration test. NO admin endpoint (that is 08-03).
</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
@.planning/phases/08-derived-link-tag-store-maintenance/08-01-SUMMARY.md

# The exact seams this plan modifies (read the enqueue sites you will mirror):
@internal/pages/service.go
@internal/pages/rename.go
@internal/pages/trash.go
@cmd/okf-workspace/main.go
</context>

<artifacts_this_phase_produces>
- `pages.Service` gains `enqueueGraphUpsert(ctx, pagePath)` and `enqueueGraphDelete(ctx, pagePath)` (private helpers, exact siblings of `enqueueIndexUpsert`/`enqueueIndexDelete`).
- Graph enqueue calls added at every existing index-enqueue site (see the table in Task 2).
- `main.go`: `worker.Register(graph.KindGraph, graph.GraphHandler(graphStore, contentRepo))`, graph Store constructed + `SetRepo`/`SetDB`/`SetGit`, and a startup `graphStore.DriftCheck` -> `worker.Enqueue(graph.KindGraph, graph.RebuildPayload())` block mirroring the search drift block.
</artifacts_this_phase_produces>

<tasks>

<task type="auto">
  <name>Task 1: Register KindGraph handler + startup-drift rebuild in main.go</name>
  <files>cmd/okf-workspace/main.go</files>
  <read_first>
    @cmd/okf-workspace/main.go (lines 209-336: how searchIdx is opened, SetRepo/SetDB/SetGit, worker.Register(search.KindIndex, ...), and the startup DriftCheck -> Enqueue(RebuildPayload) block — clone this exact shape for graph)
    @internal/search/meta.go (DriftCheck/StoreHead contract the graph Store mirrors)
    @.planning/phases/08-derived-link-tag-store-maintenance/08-01-SUMMARY.md (exact graph Store constructor + symbol names)
  </read_first>
  <action>
In `cmd/okf-workspace/main.go`, after the search index wiring block (around the `worker.Register(search.KindIndex, ...)` line) and BEFORE `worker.Start(ctx)`:
- Construct the graph store using the constructor recorded in 08-01-SUMMARY (e.g. `graphStore := graph.OpenStore(st.DB())`), then `graphStore.SetRepo(contentRepo)` and `graphStore.SetGit(gs)` (the same `gs` head provider search uses). The graph tables already exist via migration 0009 applied at store open, so no separate index dir is needed (unlike Bleve — graph adjacency lives in the operational SQLite db).
- `worker.Register(graph.KindGraph, graph.GraphHandler(graphStore, contentRepo))` beside the existing search registration.
- Add the import for `internal/graph`.

After the existing search drift block (the `searchIdx.DriftCheck(...)` block near the end), add a parallel graph-drift block: `if drifted, derr := graphStore.DriftCheck(context.Background(), gs); derr != nil { logger.Warn(...) } else if drifted { worker.Enqueue(context.Background(), graph.KindGraph, graph.RebuildPayload()) ... }`. Use the SAME fire-and-forget Enqueue (NEVER EnqueueAndWait — CR-01), the SAME best-effort posture (a drift-check error must never block startup), and log lines with the hidden-Git-safe vocabulary ("link/graph index", never "Bleve"/"git"). A fresh install (empty graph_meta, populated repo) reads as drift and triggers the initial build — confirm this self-heals an empty store on first boot.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go build ./... && go vet ./cmd/...</automated>
  </verify>
  <acceptance_criteria>
    - `main.go` registers graph.KindGraph on the worker before Start, with the graph Store wired to contentRepo + gs.
    - A startup graph-drift block enqueues a rebuild fire-and-forget when graph_meta head != current HEAD, mirroring the search drift block; errors are Warn-logged and never block startup.
    - `go build ./...` succeeds; the binary still builds CGO-free (no new dependency).
    - Startup log vocabulary contains no Git/Bleve/index-internal terms in user-reachable copy (handlers); internal logs may name the graph store.
  </acceptance_criteria>
  <done>The KindGraph handler is live on the single worker and the store self-heals from files at startup via drift detection.</done>
</task>

<task type="auto">
  <name>Task 2: Add enqueueGraphUpsert/Delete beside every existing index enqueue (all mutation kinds)</name>
  <files>internal/pages/service.go, internal/pages/rename.go, internal/pages/trash.go</files>
  <read_first>
    @internal/pages/service.go (lines 316-339 enqueueIndexUpsert/enqueueIndexDelete — clone these two helpers; lines 166, 251, 290 the Create/Save/CreateFolder call sites)
    @internal/pages/rename.go (lines 308-318 relocateFolder enqueue block; lines 473-486 relocate enqueue block — mirror delete(old)+upsert(new)+upsert(rewritten) for graph)
    @internal/pages/trash.go (lines 120-125 deleteWithGroup; lines 396-400 restoreInner; DeleteFolder loops deleteWithGroup so per-page graph delete is automatic)
    @.planning/phases/08-derived-link-tag-store-maintenance/08-01-SUMMARY.md (graph payload builder names)
  </read_first>
  <action>
In `internal/pages/service.go`, add two private helpers `enqueueGraphUpsert(ctx, pagePath)` and `enqueueGraphDelete(ctx, pagePath)` that are exact structural clones of `enqueueIndexUpsert`/`enqueueIndexDelete` but enqueue `graph.KindGraph` with `graph.UpsertPagePayload`/`graph.DeletePagePayload`. Same fire-and-forget contract: `worker.Enqueue` (never EnqueueAndWait), and a dropped enqueue is logged at Warn and swallowed (the rebuild backstop reconciles). Add the `internal/graph` import.

Then add a graph enqueue beside EVERY existing index enqueue, matching the index op exactly:

| Method (file) | Existing index call | Add graph call |
|---|---|---|
| Create (service.go ~166) | enqueueIndexUpsert(path) | enqueueGraphUpsert(path) |
| Save (service.go ~251) | enqueueIndexUpsert(path) | enqueueGraphUpsert(path) |
| CreateFolder (service.go ~290) | enqueueIndexUpsert(indexPath) | enqueueGraphUpsert(indexPath) |
| relocate / rename+move (rename.go ~476-484) | Delete(old); Upsert(new); Upsert(each rewritten linker) | enqueueGraphDelete(old); enqueueGraphUpsert(new); enqueueGraphUpsert(each rewritten linker — the same `for _, w := range writes` loop) |
| relocateFolder (rename.go ~308-317) | per-move Delete(old)+Upsert(new); Upsert(each rewritten byPath) | mirror: per-move enqueueGraphDelete(old)+enqueueGraphUpsert(new); enqueueGraphUpsert(each rewritten page) |
| deleteWithGroup (trash.go ~125) | enqueueIndexDelete(pagePath) | enqueueGraphDelete(pagePath) |
| restoreInner (trash.go ~400) | enqueueIndexUpsert(target) | enqueueGraphUpsert(target) |

DeleteFolder and RestoreGroup already loop deleteWithGroup / restoreInner per descendant, so adding the graph call inside those two methods automatically covers folder delete + group restore — do NOT add a second enqueue in the folder-loop callers (would double-enqueue). The rename/move linker re-scan (upserting every rewritten linker page) is what rewrites inbound edges and closes the stale-edge pitfall; ensure the graph upsert covers the SAME `writes`/`byPath` set the index upsert covers.
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go build ./... && go test ./internal/pages/ -count=1</automated>
  </verify>
  <acceptance_criteria>
    - `enqueueGraphUpsert`/`enqueueGraphDelete` exist as exact siblings of the index helpers (fire-and-forget, Warn-and-swallow).
    - A graph enqueue sits beside every existing index enqueue, with the matching op (upsert vs delete) for all eight sites in the table.
    - rename/move enqueues a graph upsert for every rewritten linker page (same set as the index upsert loop), not just the moved page.
    - No double-enqueue: folder delete / group restore go through the per-page helper exactly once each.
    - Existing pages tests still pass (the additive enqueues do not change mutation behavior).
  </acceptance_criteria>
  <done>Every pages mutation enqueues the matching graph job beside its search job; rename/move re-scans rewritten linkers so inbound edges never go stale.</done>
</task>

<task type="auto">
  <name>Task 3: Per-mutation-kind freshness integration test (all kinds + rename stale-edge)</name>
  <files>internal/pages/graphenqueue_test.go</files>
  <read_first>
    @internal/pages/service_test.go (the pages test harness: how a Service is stood up with a real worker/repo/db, how mutations are driven and awaited)
    @internal/pages/rename_test.go (how rename/move is tested end-to-end, how the worker is drained/awaited)
    @internal/search/indexjob_test.go (how a fire-and-forget enqueue is observed in tests — drain the worker then assert state)
    @.planning/phases/08-derived-link-tag-store-maintenance/08-01-SUMMARY.md (graph Store query surface to assert rows; KindGraph registration for the test worker)
  </read_first>
  <behavior>
    - For EACH mutation kind, drive the real pages.Service mutation, drain the worker (so the fire-and-forget KindGraph job runs), then query page_links / page_tags and assert the adjacency matches the on-disk files:
      - create: a new page that already contains a link to an existing page produces that forward edge + its tags.
      - save: editing a page's body to add/remove a link updates its src_path edge set; editing tags updates page_tags.
      - rename/move (THE pitfall): page B links to A; rename A -> A'. After draining, assert the edge B->A is gone and B->A' exists (inbound edges rewritten, no stale src/dst rows), AND the old src=A rows are gone, new src=A' rows present.
      - delete-to-trash: deleting A removes A's outbound rows AND inbound edges pointing at A; A's tags gone.
      - restore: restoring A re-adds A's outbound edges + tags.
      - folder rename/move + folder delete: a descendant page's edges follow the folder relocation / are removed on folder delete.
    - Backlinks-as-reverse-query: assert backlinks(A) == SELECT src_path FROM page_links WHERE dst_path=A returns exactly the linker pages (no separate backlink table).
    - Cross-check against rebuild: after the full mutation sequence, run RebuildGraph and assert the incrementally-maintained tables equal the from-scratch rebuild (the same files => the same adjacency).
  </behavior>
  <action>
Create `internal/pages/graphenqueue_test.go`. Stand up a real `pages.Service` over a temp git repo + migrated SQLite db + a real `jobs.Worker` with BOTH the KindCommit handler (so mutations actually commit) and the `graph.GraphHandler` registered (so enqueued graph jobs run). Use the existing pages test harness helpers; register the graph handler the same way main.go does. For each mutation kind in <behavior>, perform the mutation, drain/await the worker until idle, then open the graph Store and assert the page_links/page_tags rows. Implement the rename stale-edge case explicitly (it is the #1 pitfall and a hard success criterion). Finish with the rebuild cross-check: call RebuildGraph and assert table equality with the incremental state.

If the pages package cannot import internal/graph without a cycle, place this test in `package pages_test` (external test package) so it can import both pages and graph — confirm there is no import cycle (pages already imports graph for the enqueue helpers in Task 2, so graph must NOT import pages; the external test package avoids any cycle for the assertions).
  </action>
  <verify>
    <automated>cd /home/john/go/src/github.com/postfix/okworkspace && go test ./internal/pages/ -run 'TestGraph' -count=1</automated>
  </verify>
  <acceptance_criteria>
    - A passing test exists for each mutation kind: create, save, rename, move, delete-to-trash, restore, folder rename, folder move, folder delete.
    - The rename/move test proves inbound edges are rewritten and no stale src=old or dst=old rows remain (pitfall 2 closed).
    - Backlinks are verified as the reverse query on page_links (no separate table).
    - The post-sequence RebuildGraph cross-check confirms incremental state == from-scratch state.
    - No import cycle (graph does not import pages; assertions live in an external test package if needed).
  </acceptance_criteria>
  <done>Mutation-freshness is proven for every mutation kind without a restart, the rename/move stale-edge pitfall is closed, and incremental maintenance agrees with a full rebuild.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| HTTP mutation handler -> pages.Service -> graph enqueue | A mutation triggers a derived-index update; the enqueue must never block or fail the user-facing write |
| startup -> graph drift rebuild | The store self-heals from files; a divergent/empty store must reconcile, not serve stale data permanently |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-05 | Denial of Service | graph enqueue on the mutation path | mitigate | Enqueue is fire-and-forget (worker.Enqueue, never EnqueueAndWait); a dropped enqueue is logged at Warn and swallowed — a save never blocks or fails on graph maintenance (mirrors the search enqueue contract) |
| T-08-06 | Tampering | stale inbound edges after rename/move | mitigate | Every rewritten linker page is re-scanned (graph upsert over the same writes set as the index upsert), so inbound edges are rewritten and no stale src/dst rows remain — closing research pitfall 2, gated by an explicit test |
| T-08-07 | Repudiation | startup drift rebuild | accept | Rebuild is an idempotent reconciliation; the search pattern already accepts best-effort drift recovery without per-rebuild audit. No new audit surface here (admin-triggered rebuild auditing is 08-03) |
| T-08-SC | Tampering | npm/pip/cargo installs | mitigate | No new dependency added by this plan; no install task, so no legitimacy checkpoint required |
</threat_model>

<verification>
- `go build ./...` succeeds; binary stays CGO-free.
- `go test ./internal/pages/ -count=1` passes including the new per-mutation-kind freshness tests.
- `go vet ./cmd/... ./internal/pages/...` clean.
- Manual reasoning check: every existing `enqueueIndex*` call site has a matching `enqueueGraph*` sibling (Task 2 table fully applied).
</verification>

<success_criteria>
- KindGraph handler registered on the single worker; startup-drift rebuild self-heals the store from files.
- Graph enqueue siblings present at every mutation site with the matching op.
- Every mutation kind keeps the store fresh without a restart (proven by integration tests).
- rename/move rewrites inbound edges with no stale rows (pitfall 2 closed).
</success_criteria>

<output>
Create `.planning/phases/08-derived-link-tag-store-maintenance/08-02-SUMMARY.md` when done. Note where the graph Store is constructed in main.go (so 08-03's admin endpoint can reference the same wiring if it needs the store/worker) and confirm the enqueue-site table was fully applied.
</output>
