---
phase: 18-graph-schema-extension
plan: 01
type: implement
wave: 1
depends_on: []
files_modified: [internal/graph/query.go, internal/graph/rebuild.go, internal/graph/extract.go, web/src/api/client.ts, web/src/lib/graph/filter.ts]
autonomous: true
requirements: [GF-02]

must_haves:
  truths:
    - "GraphNode struct extended with Aliases, Tags, Exists fields"
    - "GraphNode.Type includes 'attachment' alongside 'page' and 'tag'"
    - "pageAliases extractor reads aliases from frontmatter yaml.Node"
    - "Rebuild populates Aliases and Tags from page frontmatter"
    - "Rebuild populates Exists: true for live pages, false for unresolved link targets"
    - "Attachment nodes created for non-.md files in repo"
    - "TypeScript GraphNode type updated — no more type assertions"
    - "matchesSearch uses real aliases and tags arrays"
    - "filterNodes uses real type and exists values"
    - "All 3 inert toggles become functional"
---

<objective>
Extend backend GraphNode with aliases, tags, exists, and attachment types — activating the 3 inert Phase 17 filter toggles. Pure backend data plumbing, no new UI.
</objective>

<execution_context>
@$HOME/.claude/anvil/workflows/execute-plan.md
</execution_context>

<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md §GF-02
@.planning/phases/18-graph-schema-extension/18-CONTEXT.md
</context>

<tasks>

<task type="implement" tdd="true">
  <name>Task 1: Extend GraphNode struct + add pageAliases extractor</name>
  <files>internal/graph/query.go, internal/graph/extract.go</files>
  <read_first>
    - internal/graph/query.go — GraphNode struct, node construction in GraphData/Neighborhood
    - internal/graph/extract.go — pageTags function (pattern to mirror for pageAliases)
  </read_first>
  <behavior>
    Extend `GraphNode` struct with new fields and add `pageAliases` extractor:
    - GraphNode: add `Aliases []string \`json:"aliases"\``, `Tags []string \`json:"tags"\``, `Exists bool \`json:"exists"\``
    - GraphNode.Type comment updated: `// "page" | "tag" | "attachment"`
    - `pageAliases(doc)` function in extract.go: reads `aliases` key from frontmatter — if it's a SequenceNode, collect all scalar items; if ScalarNode, wrap in single-element slice. Mirror `pageTags` pattern exactly.
    - `pageTags` result is also stored on the node now (not just used for edge building)
  </behavior>
  <action>
    1. In `internal/graph/query.go`, extend GraphNode:
    ```go
    type GraphNode struct {
        ID       string   `json:"id"`
        Label    string   `json:"label"`
        Type     string   `json:"type"`     // "page" | "tag" | "attachment"
        Aliases  []string `json:"aliases,omitempty"`
        Tags     []string `json:"tags,omitempty"`
        Exists   bool     `json:"exists"`
    }
    ```
    2. In `internal/graph/extract.go`, add `pageAliases`:
    ```go
    func pageAliases(doc *okf.Doc) []string {
        mapping := topMapping(&doc.Front)
        if mapping == nil { return nil }
        for i := 0; i+1 < len(mapping.Content); i += 2 {
            if mapping.Content[i].Value != "aliases" { continue }
            val := mapping.Content[i+1]
            switch val.Kind {
            case yaml.SequenceNode:
                out := make([]string, 0, len(val.Content))
                for _, item := range val.Content {
                    if item.Kind == yaml.ScalarNode {
                        if t := strings.TrimSpace(item.Value); t != "" {
                            out = append(out, t)
                        }
                    }
                }
                return out
            case yaml.ScalarNode:
                if t := strings.TrimSpace(val.Value); t != "" {
                    return []string{t}
                }
            }
            return nil
        }
        return nil
    }
    ```
  </action>
  <acceptance_criteria>
    - grep 'Aliases' internal/graph/query.go returns match
    - grep 'Tags' internal/graph/query.go returns match
    - grep 'Exists' internal/graph/query.go returns match
    - grep '"attachment"' internal/graph/query.go returns match
    - grep 'func pageAliases' internal/graph/extract.go returns match
    - grep 'aliases' internal/graph/extract.go returns match
    - go build ./internal/graph/... exits 0
  </acceptance_criteria>
  <done>GraphNode extended, pageAliases extractor added.</done>
</task>

<task type="implement" tdd="true">
  <name>Task 2: Populate new fields in RebuildGraph + GraphData/Neighborhood</name>
  <files>internal/graph/rebuild.go, internal/graph/query.go</files>
  <read_first>
    - internal/graph/rebuild.go — RebuildGraph function
    - internal/graph/query.go — GraphData, Neighborhood, titleFor
    - internal/graph/extract.go — pageAliases, pageTags, outboundLinks
  </read_first>
  <behavior>
    Update RebuildGraph and query functions to populate the new fields:
    - In RebuildGraph: during the second pass over pages, extract `aliases` and `tags` from each doc and store them alongside link/tag rows (new columns in page_links/page_tags, or a separate metadata table). Simplest: add `aliases` and `tags` columns to page_links (or a new page_meta table). Actually, the cleanest approach: store them in the node construction in GraphData/Neighborhood.
    - BETTER APPROACH: Don't change the rebuild schema. Instead, in `GraphData`/`Neighborhood`, when constructing page nodes, read the page frontmatter via `titleFor`-style read and extract aliases + tags + title on the fly. This keeps the SQL schema unchanged and works incrementally.
    - ACTUALLY BEST: The rebuild already reads every page's body. Store aliases/tags per-page in a new `page_meta` table (page_path, aliases JSON, tags JSON). But that adds migration complexity.
    - SIMPLEST: In `GraphData`/`Neighborhood`, for each page node, call a new method `pageMeta(path)` that reads the file and returns (aliases, tags). Uses the existing repo.Read + okf.Parse + pageAliases + pageTags. Similar to `titleFor` which already reads one field per page.
    - For `Exists`: during `GraphData`, after building the page set, for each page check if it's in `livePages()`. If in live set → Exists: true. If only in cache-referenced set → Exists: false.
    - For attachment nodes: iterate repo Tree items that are NOT .md and NOT trashed and NOT directories, create GraphNode with type "attachment", label = filename, Exists: true.
  </behavior>
  <action>
    1. Add helper `func (s *Store) pageMeta(pagePath string) (aliases, tags []string)` that reads the file, parses, calls pageAliases + pageTags. Falls back to nil,nil on error.
    2. In `GraphData`, when building page nodes:
    ```go
    aliases, tags := s.pageMeta(p)
    g.Nodes = append(g.Nodes, GraphNode{
        ID: p, Label: s.titleFor(p), Type: "page",
        Aliases: aliases, Tags: tags,
        Exists: s.inLiveSet(p),
    })
    ```
    3. Add `inLiveSet` method that checks the livePages() set.
    4. Add attachment nodes after page nodes:
    ```go
    for _, p := range sortedKeys(s.attachmentFiles()) {
        g.Nodes = append(g.Nodes, GraphNode{
            ID: p, Label: path.Base(p), Type: "attachment", Exists: true,
        })
    }
    ```
    5. Add `attachmentFiles()` method: returns set of non-.md, non-dir, non-trashed repo Tree paths.
    6. Update `Neighborhood` similarly — aliases/tags/Exists on page nodes.
    7. Update tests in rebuild_test.go and query_test.go.
  </action>
  <acceptance_criteria>
    - grep 'pageMeta' internal/graph/query.go returns match
    - grep 'inLiveSet' internal/graph/query.go returns match
    - grep 'attachmentFiles' internal/graph/query.go returns match
    - grep 'Exists:' internal/graph/query.go returns match
    - grep 'Aliases:' internal/graph/query.go returns match
    - grep 'Tags:' internal/graph/query.go returns match
    - grep 'attachment' internal/graph/query.go returns match (type assignment)
    - go test ./internal/graph/... exits 0
    - go build ./... exits 0
  </acceptance_criteria>
  <done>GraphData and Neighborhood populate aliases, tags, exists, and attachment nodes.</done>
</task>

<task type="implement" tdd="true">
  <name>Task 3: Update TypeScript types + remove type assertions in filter.ts</name>
  <files>web/src/api/client.ts, web/src/lib/graph/filter.ts</files>
  <read_first>
    - web/src/api/client.ts — GraphNode type definition
    - web/src/lib/graph/filter.ts — matchesSearch, filterNodes (type assertions to remove)
  </read_first>
  <behavior>
    - Update `GraphNode` in client.ts to match the new Go struct:
    ```ts
    export interface GraphNode {
      id: string;
      label: string;
      type: "page" | "tag" | "attachment";
      aliases?: string[];
      tags?: string[];
      exists?: boolean;
    }
    ```
    - In `filter.ts`, remove type assertions and use the real fields:
      - `matchesSearch`: use `node.aliases?.some(...)` and `node.tags?.some(...)` instead of `(node as {aliases?...}).aliases?.some(...)`
      - `filterNodes`: use `node.type === "attachment"` instead of `n.type === "attachment"` (was already checking `.type`, just the value never existed). Use `node.exists === false` instead of `(n as {exists?...}).exists === false`.
  </behavior>
  <action>
    1. Edit `web/src/api/client.ts`: update GraphNode interface
    2. Edit `web/src/lib/graph/filter.ts`:
       - In `matchesSearch`: replace type assertions with optional chaining on the real fields
       - In `filterNodes`: remove `as` casts from `exists` check
    3. Verify `npx tsc --noEmit -p web/tsconfig.json` exits 0
  </action>
  <acceptance_criteria>
    - grep 'aliases?' web/src/api/client.ts returns match
    - grep 'tags?' web/src/api/client.ts returns match
    - grep 'exists?' web/src/api/client.ts returns match
    - grep '"attachment"' web/src/api/client.ts returns match
    - grep 'as {' web/src/lib/graph/filter.ts does NOT return match (no type assertions remain)
    - npx tsc --noEmit -p web/tsconfig.json exits 0
  </acceptance_criteria>
  <done>TypeScript types match backend, filter.ts uses real fields, no type assertions.</done>
</task>

</tasks>

<success_criteria>
- go build + go test all clean
- npx tsc clean
- All 3 inert toggles now functional (visual verification)
- GF-02 acceptance criteria met
</success_criteria>
