# kbx Project Source Linking — Component Design

**Date:** 2026-03-06
**Status:** Draft (decisions resolved)
**Author:** kbx-dev
**Parent:** `docs/plans/2026-03-06-cross-system-commitment-architecture.md`

## Overview

This document details kbx's role in the cross-system commitment architecture: how projects link to external systems, how task-matching logic moves from brief-deck into kbx as a reusable service, and how entity linking gains source-ID awareness.

Three deliverables:
1. **`sources:` schema** — structured external links on project entities
2. **Task-matching extraction** — reusable matching logic in `api.py`
3. **Entity linking via source IDs** — Tier 0 unambiguous matching pass

---

## 1. `sources:` Schema Design

### 1.1 YAML Frontmatter Schema

```yaml
sources:
- type: linear          # Required — free string discriminator
  id: PRJ-123           # Source-native identifier (optional)
  url: https://linear.app/gitguardian/project/prj-123  # Web URL (optional)
- type: notion
  id: abc123def456
  url: https://www.notion.so/gitguardian/AI-Adoption-abc123def456
- type: slack
  channel: C08HJC8MWQN  # Slack channel ID (type-specific)
  name: "#proj-agentic-engineering-leads"  # Human label (type-specific)
- type: jira
  key: GG-1234
  url: https://gitguardian.atlassian.net/browse/GG-1234
- type: github
  repo: tenfourty/kbx
  url: https://github.com/tenfourty/kbx
- type: custom
  label: Internal wiki
  url: https://wiki.example.com/project/foo
```

### 1.2 Schema Rules

| Rule | Detail |
|------|--------|
| **`type` is required** | Free string. No enum — users bring their own source types. |
| **All other keys are optional** | kbx does not validate type-specific keys. |
| **`url` is conventional** | Most sources have a web URL, but it's not enforced. |
| **`id` is conventional** | Source-native identifier for entity linking (see §3). |
| **No nesting** | Each source entry is a flat dict. No nested objects. |
| **Order preserved** | YAML list order is preserved through parse → metadata → writeback. |
| **Duplicates allowed** | Multiple entries with the same `type` are valid (e.g., two Slack channels). |

### 1.3 Known Source Types (Convention, Not Enforced)

| Type | Common keys | ID format |
|------|------------|-----------|
| `linear` | `id`, `url` | Project key (e.g., `PRJ-123`) or UUID |
| `notion` | `id`, `url` | 32-char hex page ID |
| `slack` | `channel`, `name`, `url` | Slack channel ID (`C[A-Z0-9]{10}`) |
| `jira` | `key`, `url` | Issue/project key (e.g., `GG-1234`) |
| `github` | `repo`, `url` | `owner/repo` |
| `gitlab` | `project`, `url` | `group/project` |

### 1.4 SQLite Storage

**No schema migration.** Sources live inside the existing `metadata` JSON blob in the `entities` table:

```sql
-- Existing schema (unchanged)
CREATE TABLE entities (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    entity_type TEXT NOT NULL,
    aliases TEXT,       -- JSON array
    metadata TEXT,      -- JSON object (sources lives here)
    source_path TEXT
);
```

After parsing, `metadata["sources"]` is a Python `list[dict[str, str]]`. The JSON blob already supports nested structures — `task_keywords` is already stored as a list.

**Why not a separate table?** Sources are tightly coupled to the entity lifecycle (created/deleted with the entity, written back to the same markdown file). A separate table adds join complexity for no benefit — the metadata blob is already loaded for every entity operation.

### 1.5 Parse Behaviour (`entities.py`)

**No code changes needed for basic support.** The current `_parse_project_file()` already handles this correctly:

```python
# entities.py lines 163-174 (existing code)
for key, value in fm.items():
    if key in _YAML_SPECIAL:        # aliases, pinned — skip
        continue
    if isinstance(value, list):
        metadata[key] = value        # ← sources: list of dicts passes through here
    else:
        str_val = str(value).strip()
        ...
```

YAML parses `sources:` as a `list[dict]`, which hits the `isinstance(value, list)` branch and is stored as-is. Verified: `task_keywords` already works this way.

**One concern:** The list items are dicts, not strings. `yaml.dump()` in writeback will round-trip them correctly (YAML natively supports list-of-mapping). But we should add a test to verify the full round-trip.

### 1.6 Writeback Behaviour (`writeback.py`)

**No code changes needed.** The `_rebuild_project_header()` function already handles custom fields:

```python
# writeback.py lines 142-145 (existing code)
# Custom fields (not in registry)
for key, value in entity.metadata.items():
    if key not in registered_keys and value:
        fm[key] = value   # ← sources passes through here as list[dict]
```

`yaml.dump()` handles `list[dict]` natively:

```yaml
sources:
- type: linear
  id: PRJ-123
  url: https://linear.app/gitguardian/project/prj-123
- type: slack
  channel: C08HJC8MWQN
  name: '#proj-agentic-engineering-leads'
```

**Ordering concern:** `yaml.dump(sort_keys=False)` is already set, so top-level key order follows insertion order. But the registered fields (`status`, `started`, `lead`) are written first, then custom fields in dict iteration order. `sources` will appear after registered fields, which is fine. Within each source entry, key order depends on dict insertion order (Python 3.7+ guarantees this).

### 1.7 Validation

**Minimal validation — kbx is permissive by design.** The only validation:

1. `sources` must be a list (if present). Non-list values are ignored.
2. Each item must be a dict. Non-dict items are skipped.
3. Each dict must have a `type` key. Entries without `type` are skipped with a warning.

This validation happens at **display time and entity-linking time**, not at parse time. Malformed entries are preserved in the metadata blob (no data loss) but excluded from structured operations.

```python
def _extract_sources(metadata: dict[str, Any]) -> list[dict[str, Any]]:
    """Extract and validate source entries from entity metadata.

    Returns only well-formed entries (dict with 'type' key).
    Malformed entries are silently skipped.
    """
    raw = metadata.get("sources")
    if not isinstance(raw, list):
        return []
    return [s for s in raw if isinstance(s, dict) and "type" in s]
```

### 1.8 CLI: `kbx project create --source` / `kbx project edit --source`

**Syntax:** `--source TYPE:URL` or `--source TYPE:KEY=VALUE,KEY=VALUE`

```bash
# Simple URL-based source
kbx project create "AI Adoption" --source "linear:https://linear.app/gitguardian/project/prj-123"

# Key-value source
kbx project create "AI Adoption" --source "slack:channel=C08HJC8MWQN,name=#proj-agentic"

# Multiple sources
kbx project edit "AI Adoption" \
  --source "linear:https://linear.app/..." \
  --source "slack:channel=C08HJC8MWQN"
```

**Parsing logic:**

```python
def _parse_source_arg(raw: str) -> dict[str, str]:
    """Parse --source TYPE:SPEC into a source dict.

    Formats:
      "linear:https://..." → {"type": "linear", "url": "https://..."}
      "slack:channel=C123,name=#foo" → {"type": "slack", "channel": "C123", "name": "#foo"}
    """
    type_str, _, spec = raw.partition(":")
    if not spec:
        raise click.BadParameter(f"Missing spec after type: {raw}")
    result = {"type": type_str.strip()}
    if spec.startswith("http://") or spec.startswith("https://"):
        result["url"] = spec.strip()
    else:
        for pair in spec.split(","):
            key, _, val = pair.partition("=")
            if key and val:
                result[key.strip()] = val.strip()
    return result
```

**Edit semantics:** `--source` appends to existing sources (does not replace). To remove a source, use `--remove-source TYPE` which removes all entries of that type, or `--remove-source TYPE:ID` for specific entries.

### 1.9 Display in `kbx project find`

**Table output** — new "Sources" section between metadata and facts:

```
AI Adoption (project)  aka TF Agentic AI, AI Tooling Review

  status: Active
  lead: Jeremy Brown
  started: November 2025

Sources:
  linear: PRJ-123 — https://linear.app/gitguardian/project/prj-123
  notion: abc123def456 — https://www.notion.so/gitguardian/AI-Adoption-abc123
  slack: #proj-agentic-engineering-leads (C08HJC8MWQN)
  github: tenfourty/kbx — https://github.com/tenfourty/kbx

Facts:
  - [2026-02-06] Task Force formally assembled

42 linked documents
  -> kbx project timeline 'AI Adoption' --limit 20
```

**Formatting logic for each source entry:**

```python
def _format_source(source: dict[str, str]) -> str:
    """Format a single source entry for table display."""
    stype = source["type"]
    # Prefer name/label for display, fall back to id, then url
    label = source.get("name") or source.get("label") or source.get("key") or source.get("id") or ""
    url = source.get("url", "")
    if label and url:
        return f"  {stype}: {label} — {url}"
    if label:
        return f"  {stype}: {label}"
    if url:
        return f"  {stype}: {url}"
    return f"  {stype}: (no details)"
```

**JSON output** — no changes needed. Sources already appear in `metadata.sources` as a list of dicts.

**Context output** (`kbx context`) — optionally append source type summary:

```
AI Adoption(Jeremy Brown,Active,linear+slack+notion)
```

Implementation: ~5 lines in `_format_project()` in `context.py`.

---

## 2. Task-Matching Logic Extraction

### 2.1 Current State (brief-deck)

The matching logic lives in `brief_deck/routes/projects.py` (~80 lines, 4 functions):

- `_parse_keywords(raw)` — parse `task_keywords` from metadata
- `_build_patterns(name, aliases, metadata)` — build lowercase match patterns
- `_extract_project_links(description)` — parse `project: <name>` from description
- `_match_tasks_to_projects(projects, tasks)` — two-tier matching

**Critical bug:** Tier 2 (title matching) uses naive `p in title_lower` substring matching. This means:
- `"ai"` matches "R**ai**lway", "w**ai**ting", "em**ai**l", "K**ai**zen"
- `"MFA"` matches "loopback**MFA**ilure" (unlikely but possible)
- Any short keyword is a false-positive magnet

### 2.2 Proposed Location

New module: **`src/kb/matching.py`** — pure matching logic, no DB or IO dependencies.

**Why a new module, not `api.py`?**
- The matching functions are pure (input → output, no state)
- They don't need DB access, embedder, or project_root
- Keeping them separate makes them easy to test and import independently
- `api.py` gets a thin wrapper method that calls `matching.py`

### 2.3 API Signatures

```python
# src/kb/matching.py

"""Task-to-project matching logic.

Pure functions for linking external tasks to kbx projects. No DB or IO
dependencies — operates on TypedDicts and strings only.
"""

from __future__ import annotations

import re
from typing import Any, TypedDict


class TaskInput(TypedDict, total=False):
    """Minimal task shape for matching. kbx's own type — no gm dependency.

    Required: title.
    Optional: description (used for Tier 1 explicit project links).
    All other keys are passed through in results but ignored by matching.
    """
    title: str             # Required
    description: str | None


class ProjectInput(TypedDict, total=False):
    """Project shape for matching. Typically from EntitySummary fields."""
    name: str              # Required
    aliases: list[str]
    metadata: dict[str, Any]  # may contain task_keywords, sources


# Tier 1: explicit project link in task descriptions
# Note: `project` is a STRING (1:1 task-to-project), not a list.
# Convention: one `project: <Name>` line per task description.
_PROJECT_LINE_RE = re.compile(r"^project:\s*(.+)", re.IGNORECASE | re.MULTILINE)


def extract_project_link(description: str | None) -> str | None:
    """Extract the explicit project name from a ``project: <Name>`` line.

    Returns lowercased project name, or None if no match.
    Convention: 1:1 task-to-project. If multiple lines exist, first wins.
    """
    ...


def match_tasks_to_projects(
    projects: list[ProjectInput],
    tasks: list[TaskInput],
    *,
    min_keyword_len: int = 4,
) -> dict[str, list[TaskInput]]:
    """Match tasks to projects via two-tier linking.

    Parameters
    ----------
    projects:
        List of ProjectInput dicts with ``name``, ``aliases``, ``metadata``.
    tasks:
        List of TaskInput dicts with ``title`` and optional ``description``.
        Any extra keys are preserved in results but ignored by matching.
    min_keyword_len:
        Keywords shorter than this use word-boundary matching (``\\b``).
        Keywords >= this length use substring matching (current behaviour).
        Default 4 eliminates false positives for "AI", "NHI", "ODS", etc.

    Returns
    -------
    dict mapping project name to list of matched TaskInput dicts.
    Projects with zero matches have empty lists.

    Matching Algorithm
    ------------------
    **Tier 1 — Explicit link (highest priority):**
    Parse ``project: <Name>`` line from task description. If found, link to
    that project only — skip Tier 2. 1:1 enforced (first match wins).
    Case-insensitive match against project names.

    **Tier 2 — Title matching (fallback):**
    For tasks without explicit links, match task title against project
    name + aliases + task_keywords. Match mode depends on pattern length:
    - len >= min_keyword_len: substring match (``pattern in title``)
    - len < min_keyword_len: word-boundary match (``\\bpattern\\b``)

    A task can match multiple projects (via Tier 2).
    A project can match multiple tasks.
    """
    ...
```

### 2.4 Word-Boundary Algorithm Detail

The critical improvement over brief-deck's current naive substring matching:

```python
def _build_match_patterns(
    name: str,
    aliases: list[str] | None = None,
    metadata: dict[str, Any] | None = None,
    min_keyword_len: int = 4,
) -> list[tuple[str, bool]]:
    """Build (lowered_pattern, needs_word_boundary) tuples.

    Short patterns (< min_keyword_len chars) get word-boundary enforcement
    to prevent false positives like "AI" matching "Railway".

    Returns deduplicated patterns.
    """
    raw_patterns: list[str] = []
    if name:
        raw_patterns.append(name.lower())
    if aliases:
        raw_patterns.extend(a.lower() for a in aliases if a)
    if metadata:
        kw_raw = metadata.get("task_keywords")
        if kw_raw:
            raw_patterns.extend(_parse_keywords(kw_raw))

    # Deduplicate while preserving order
    seen: set[str] = set()
    result: list[tuple[str, bool]] = []
    for p in raw_patterns:
        if p not in seen:
            seen.add(p)
            result.append((p, len(p) < min_keyword_len))
    return result


def _title_matches(title_lower: str, patterns: list[tuple[str, bool]]) -> bool:
    """Check if a task title matches any project pattern.

    Short patterns use word-boundary regex; long patterns use substring.
    """
    for pattern, needs_boundary in patterns:
        if needs_boundary:
            if re.search(rf"\b{re.escape(pattern)}\b", title_lower):
                return True
        else:
            if pattern in title_lower:
                return True
    return False
```

**Threshold analysis for `min_keyword_len=4`:**

| Keyword | Length | Mode | False positive risk |
|---------|--------|------|-------------------|
| `AI` | 2 | word-boundary `\bai\b` | Eliminates: Railway, waiting, email, Kaizen |
| `NHI` | 3 | word-boundary `\bnhi\b` | Safe — very few words contain "nhi" |
| `ODS` | 3 | word-boundary `\bods\b` | Eliminates: methods, periods |
| `MFA` | 3 | word-boundary `\bmfa\b` | Safe |
| `AWS` | 3 | word-boundary `\baws\b` | Eliminates: laws, claws, draws |
| `SOC2` | 4 | substring | Safe — "SOC2" is distinctive enough |
| `claude` | 6 | substring | Safe |
| `Oxide` | 5 | substring | Safe |
| `reorg` | 5 | substring | Safe |
| `Coralogix` | 9 | substring | Safe |

**Decision: `min_keyword_len=4` is the sweet spot.** Keywords of 1-3 chars get word-boundary enforcement. Everything 4+ uses fast substring matching.

### 2.5 `KnowledgeBase` Wrapper Method (`api.py`)

```python
from kb.matching import TaskInput, ProjectInput, match_tasks_to_projects

class KnowledgeBase:
    def match_tasks_to_projects(
        self,
        tasks: list[TaskInput],
        *,
        min_keyword_len: int = 4,
    ) -> dict[str, list[TaskInput]]:
        """Match tasks to projects using the two-tier algorithm.

        Loads project entities from the DB, converts to ProjectInput dicts,
        delegates to matching.match_tasks_to_projects().

        Parameters
        ----------
        tasks:
            TaskInput dicts with ``title`` and optional ``description``.
        min_keyword_len:
            Word-boundary threshold (see matching module docs).

        Returns
        -------
        {project_name: [matched_tasks]}
        """
        projects = self.list_entities(entity_type="project")
        project_dicts: list[ProjectInput] = [
            {
                "name": p.name,
                "aliases": p.aliases,  # available via EntitySummary (Decision #2)
                "metadata": p.metadata,
            }
            for p in projects
        ]
        return match_tasks_to_projects(
            project_dicts, tasks, min_keyword_len=min_keyword_len
        )
```

**Resolved (Decision #2):** `aliases: list[str]` added to `EntitySummary`. Non-breaking additive change. `list_entities()` query updated to include aliases from DB.

### 2.6 Interaction with Existing Entity Linking

Task-matching is **completely separate** from entity linking in `entities.py`. They solve different problems:

| Aspect | Entity linking (`entities.py`) | Task matching (`matching.py`) |
|--------|-------------------------------|-------------------------------|
| **Matches what?** | Documents (meetings, notes) to entities | External tasks to project entities |
| **Input** | Document title + tags + content | Task title + description |
| **Stored?** | Yes — `entity_mentions` table | No — computed on demand |
| **Algorithm** | 4-tier (tag/participant/title/content) | 2-tier (explicit description/title) |
| **Consumers** | Indexer, search, timeline | brief-deck, CoS, future CLI |

No overlap, no interaction. The only shared concept is project names/aliases.

---

## 3. Entity Linking via Source IDs (Tier 0)

### 3.1 Concept

When a document mentions a source ID (e.g., Slack channel `C08HJC8MWQN` or Linear project `PRJ-123`), link it to the corresponding kbx project entity. This is **unambiguous** — IDs don't collide with natural language.

### 3.2 Source ID Extraction During `seed_entities()`

New function called during entity seeding:

```python
def _extract_source_ids(metadata: dict[str, Any]) -> list[str]:
    """Extract matchable identifiers from sources metadata.

    Extracts `id`, `key`, and `channel` values from source entries.
    These are used as additional content-matching patterns during
    entity linking.

    Returns deduplicated list of identifiers.
    """
    sources = _extract_sources(metadata)
    ids: list[str] = []
    for source in sources:
        for key in ("id", "key", "channel"):
            val = source.get(key)
            if val and isinstance(val, str) and len(val) >= 3:
                ids.append(val)
    return list(dict.fromkeys(ids))  # deduplicate, preserve order
```

### 3.3 Storage — Prefixed Aliases

**Approach: Add source IDs as prefixed aliases during seeding.**

```python
# In seed_entities(), after parsing the project file:
source_ids = _extract_source_ids(entity_data.metadata)
all_aliases = list(entity_data.aliases) + [f"src:{sid}" for sid in source_ids]
```

**Why prefixed aliases, not a separate table?**
- No schema migration
- Aliases already participate in entity matching
- The `src:` prefix prevents collisions with human names (no person is named "src:C08HJC8MWQN")
- Minimal code change — just augment the alias list

**Why not un-prefixed aliases?**
- `C08HJC8MWQN` is not a useful human-readable alias
- It would appear in `kbx project find` output, polluting the display
- The prefix makes the intent clear and enables filtering in display code

### 3.4 Matching in `find_entity_mentions()`

Add a new pass between the existing Tier 3 (title substring) and Tier 4 (content name matching):

```python
# New Tier 3.5: Source ID matching
# Only match src:-prefixed aliases — exact case-sensitive match in content
for entity in entities:
    for alias in entity.aliases:
        if not alias.startswith("src:"):
            continue
        source_id = alias[4:]  # strip "src:" prefix
        if source_id in content:  # exact substring (IDs are unique enough)
            _add(entity.id, "source_ref")
```

**New `mention_type` value:** `"source_ref"` — distinguishes source-ID matches from name-based matches. This is useful for:
- Filtering/weighting in search results
- Displaying provenance ("linked via Slack channel ID" vs "mentioned by name")
- Analytics (how many links come from structured sources vs name matching?)

### 3.5 Pattern Compilation

Source IDs are short, unique strings. They don't need the full `_build_name_patterns()` / `_boundary_pattern()` machinery:

- **No word-boundary needed** — IDs like `C08HJC8MWQN` or `PRJ-123` contain non-word characters that make word boundaries unreliable
- **No case normalisation** — IDs are case-sensitive by convention
- **Simple `in` check** — `"C08HJC8MWQN" in content` is fast and sufficient
- **Length filter** — skip IDs < 3 chars to avoid matching common letter sequences

For the initial implementation, **don't compile regex patterns** for source IDs. A simple `str.__contains__` check per entity is fast enough (15 projects × 2-3 source IDs = ~40 checks per document).

### 3.6 Performance Implications

**Current entity linking cost:** ~1000 regex pattern compilations cached once, then `pattern.search(content)` per document. For 3000 documents, this is the bottleneck.

**Added cost from source IDs:** ~40 `str.__contains__` checks per document. This is negligible — `in` on strings is O(n) but with C-level implementation, much faster than regex.

**No meaningful performance impact** for corpora up to 10K+ documents.

---

## 4. Backfill Plan

### 4.1 Projects Needing `task_keywords`

8 of 15 projects currently lack `task_keywords`:

| # | Project | Suggested Keywords | Rationale |
|---|---------|-------------------|-----------|
| 1 | CoreLogic Migration | `corelogic`, `customer migration`, `onboarding` | Customer name + process terms |
| 2 | Customer Accounts | `ING`, `Pearson`, `customer account` | Key customer names |
| 3 | GG Shield v2 | `ggshield`, `scout`, `CLI tool`, `shield` | Tool names, must match "ggshield" |
| 4 | Hackathon Jan 2026 | `hackathon` | Simple — completed project anyway |
| 5 | Local Dev Protection | `local scanning`, `endpoint`, `gglocal` | Product feature terms |
| 6 | MFA Implementation | `MFA`, `multi-factor`, `authentication` | Acronym + full terms |
| 7 | Rust Migration | `rust`, `detection engine`, `rewrite` | Language + component |
| 8 | Series C | `fundraising`, `series C`, `valuation` | Finance terms. Completed project. |

**Note:** "MFA" is 3 chars → will use word-boundary matching with `min_keyword_len=4`. This correctly prevents false positives while matching "MFA rollout plan".

### 4.2 Projects With Known External Links

Based on current file content analysis:

| Project | Known Sources | Where Found |
|---------|--------------|-------------|
| AI Adoption | 4 Slack channels (C08HJC8MWQN, C08DDLSK08G, C0A07HQ349G, C09SRDQFPGT) | Body `## Key Channels` |
| Security Policies | 1 Slack channel (C0AEDG6QYKD), 1 Notion doc (Incident Response Plan) | Body `## Key Channel`, prose |
| Other 13 projects | Unknown — may have Linear/Notion/Jira links not documented | Need manual audit |

### 4.3 Automation Opportunities

**Partially automatable:**

1. **Slack channel extraction from body text** — regex `(C[A-Z0-9]{10})` in `## Key Channels` sections → generate `sources:` entries. Could be a one-off script or a `kbx correct`-style command.

2. **Linear project discovery** — if Linear MCP is available, query for projects and match by name to kbx projects. CoS could do this during `/setup`.

3. **Notion page discovery** — similar to Linear, but Notion page IDs are less predictable.

**Not automatable:**
- Jira links (need project keys from the user)
- GitHub repos (need explicit mapping)
- Which external links matter for each project (human judgement)

**Recommended approach:** Script the Slack channel extraction (safe, deterministic). Everything else is manual or CoS-assisted during `/setup`.

---

## 5. Decisions (All Resolved)

### Decision 1: Separate `matching.py` module or inline in `api.py`?

**Resolved: (a) Separate module.** `src/kb/matching.py` with pure functions. `api.py` wraps them. Follows existing pattern (`entities.py`, `search.py`, `chunker.py`).

### Decision 2: Add `aliases` to `EntitySummary`?

**Resolved: (a) Yes.** Add `aliases: list[str]` to `EntitySummary`. Non-breaking additive change. `list_entities()` query updated to include aliases. brief-deck and other consumers get aliases for free.

### Decision 3: `min_keyword_len` default — 3 or 4?

**Resolved: (b) `min_keyword_len=4`.** Keywords 1-3 chars get word-boundary enforcement. Eliminates "AWS"→"laws", "ODS"→"methods" false positives. Short acronyms should use descriptive `task_keywords` on projects (e.g., "AI tools" instead of just "AI").

### Decision 4: Source ID storage — prefixed aliases or new column?

**Resolved: (a) Prefixed aliases.** `src:C08HJC8MWQN` — no schema migration. The `src:` prefix prevents display pollution. Filtered from `_derive_aliases()` and `_format_entity_profile()`.

### Decision 5: Should `_format_entity_profile()` show `sources:` separately or as part of metadata?

**Resolved: (a) Separate "Sources:" section.** Formatted display between metadata and facts. Raw metadata representation is unreadable for humans.

### Decision 6: Should `kbx project edit --source` append or replace?

**Resolved: (a) Append with `--remove-source` for removal.** Add update if easy, otherwise CRUD without U (create/delete sources, no in-place update of individual entries). Matches existing `--meta key=value` merge pattern.

### Decision 7: Should matching.py accept typed models or plain dicts?

**Resolved: (c) TypedDict.** Use `TaskInput(TypedDict)` with `title: str` and `description: str | None`. Strongly typed, no gm dependency. kbx's own type — consumers construct it from their native task models. `ProjectInput(TypedDict)` for project dicts. See updated signatures in §2.3.

**Cross-cutting decisions (from top-level doc):**
- **`project` field is a string, not a list** — 1:1 task-to-project enforced. `null` if no project. `extract_project_link()` returns `str | None`, not `list[str]`.
- **`--help` output uses layered LLM context** — top-level = overview, subcommand = detailed contracts. The `_AGENT_PLAYBOOK` in `cli.py` should document the matching API contract for LLM consumers.

---

## 6. Test Plan

### 6.1 New Test File: `tests/test_matching.py`

| Test | What it verifies |
|------|-----------------|
| `test_extract_project_link_basic` | `project: AI Adoption` → `"ai adoption"` |
| `test_extract_project_link_first_wins` | Multiple `project:` lines → first one returned (1:1 enforced) |
| `test_extract_project_link_case_insensitive` | `Project:` and `PROJECT:` work |
| `test_extract_project_link_empty` | None / empty string → `None` |
| `test_match_tier1_explicit_link` | Task with `project: X` matches only project X |
| `test_match_tier1_skips_tier2` | Explicitly linked tasks don't fall through to title matching |
| `test_match_tier2_name_substring` | Task title containing project name → match |
| `test_match_tier2_alias_match` | Task title containing alias → match |
| `test_match_tier2_keyword_match` | Task title containing task_keyword → match |
| `test_match_tier2_word_boundary_short` | "AI" doesn't match "Railway" with `min_keyword_len=4` |
| `test_match_tier2_word_boundary_exact` | "AI" matches "Review AI tools" with word boundary |
| `test_match_tier2_long_keyword_substring` | "claude" matches "Review claude setup" (no boundary needed) |
| `test_match_no_false_positive_aws` | "AWS" doesn't match "outlaws" with word boundary |
| `test_match_no_false_positive_ods` | "ODS" doesn't match "methods" with word boundary |
| `test_match_multiple_projects` | One task can match multiple projects |
| `test_match_empty_tasks` | Empty task list → all projects have `[]` |
| `test_match_empty_projects` | Empty project list → `{}` |
| `test_parse_keywords_list` | `['AI', 'claude']` → `['ai', 'claude']` |
| `test_parse_keywords_stringified_list` | `"['AI', 'claude']"` → `['ai', 'claude']` |
| `test_parse_keywords_csv` | `"AI, claude"` → `['ai', 'claude']` |
| `test_build_patterns_deduplication` | Same pattern from name + alias counted once |
| `test_min_keyword_len_configurable` | Custom threshold changes boundary behaviour |

### 6.2 New Tests in `tests/test_entities.py`

| Test | What it verifies |
|------|-----------------|
| `test_parse_project_sources_roundtrip` | YAML sources list survives parse → seed → writeback |
| `test_source_ids_added_as_aliases` | `src:C08HJC8MWQN` appears in aliases after seeding |
| `test_source_id_entity_linking` | Document mentioning `C08HJC8MWQN` links to project |
| `test_source_ref_mention_type` | Source ID match produces `mention_type="source_ref"` |
| `test_source_ids_skipped_in_display` | `src:`-prefixed aliases filtered from `_derive_aliases()` |
| `test_short_source_ids_ignored` | IDs < 3 chars not added as aliases |

### 6.3 Updates to `tests/test_api.py`

| Test | What it verifies |
|------|-----------------|
| `test_match_tasks_to_projects_api` | `KnowledgeBase.match_tasks_to_projects()` delegates correctly |
| `test_entity_summary_includes_aliases` | `list_entities()` returns aliases (if Decision #2 = yes) |

### 6.4 New Tests in `tests/test_cli.py`

| Test | What it verifies |
|------|-----------------|
| `test_project_create_with_source` | `--source "linear:https://..."` adds to sources |
| `test_project_edit_add_source` | `--source` appends to existing |
| `test_project_edit_remove_source` | `--remove-source linear` removes entries |
| `test_project_find_shows_sources` | Table output includes "Sources:" section |
| `test_project_find_json_has_sources` | JSON output has `metadata.sources` |

**Estimated test count:** ~30 new tests across 4 files.

---

## 7. Migration and Compatibility

### 7.1 Breaking Changes

**None.** Every change is additive:

| Change | Breaking? | Why |
|--------|-----------|-----|
| `sources` in metadata | No | New optional key, ignored if absent |
| `src:` prefixed aliases | No | New aliases, invisible to consumers that don't filter |
| `matching.py` module | No | New module, nothing depends on it yet |
| `EntitySummary.aliases` | No | New field added to existing model |
| `--source` CLI option | No | New option, existing commands unchanged |
| `mention_type="source_ref"` | No | New value, consumers use `IN (...)` queries |
| Sources section in display | No | Additive to table output |

### 7.2 brief-deck Migration Path

1. **Phase 1 (immediate):** brief-deck continues using its own matching code. No change needed.
2. **Phase 2:** brief-deck imports `from kb.matching import match_tasks_to_projects, TaskInput` and deletes its local copy (~80 lines). Constructs `TaskInput` dicts from gm `Task.model_dump()`. Note: `project` is now a **string** (1:1), not a list — adjust any template logic that expects multiple project matches per task via Tier 1.
3. **Phase 3:** brief-deck reads `sources` from project metadata and renders clickable link badges on project detail pages.

**No breaking change at any phase.** brief-deck can migrate incrementally.

### 7.3 CoS Migration Path

CoS currently does not call kbx for task matching. Once `matching.py` exists:
1. CoS debrief can call `kbx project list --json` and use the matching logic to auto-link extracted action items to projects
2. CoS briefing can call the matching API to show project associations on task summaries

### 7.4 MCP Server

The MCP server (`mcp_server.py`) already exposes `project find` and `project list`. Sources will automatically appear in the JSON metadata. No MCP protocol changes needed.

If we add a `match_tasks` tool to the MCP server, external AI tools could send task lists and get project matches back. This is a future enhancement, not part of this design.

---

## Appendix A: File Changes Summary

| File | Change | Lines (est.) |
|------|--------|-------------|
| `src/kb/matching.py` | **New** — task matching logic + `TaskInput`/`ProjectInput` TypedDicts | ~130 |
| `src/kb/entities.py` | Add `_extract_source_ids()`, augment aliases in `seed_entities()`, add source_ref matching pass in `find_entity_mentions()` | ~40 |
| `src/kb/writeback.py` | Filter `src:` aliases from `_derive_aliases()` | ~3 |
| `src/kb/api.py` | Add `match_tasks_to_projects()` wrapper | ~20 |
| `src/kb/cli.py` | `--source`/`--remove-source` options, sources display in `_format_entity_profile()` | ~50 |
| `src/kb/context.py` | Source type summary in `_format_project()` | ~5 |
| `src/kb/types.py` | Add `aliases: list[str]` to `EntitySummary` | ~1 |
| `tests/test_matching.py` | **New** — matching logic tests | ~200 |
| `tests/test_entities.py` | Source ID linking tests | ~60 |
| `tests/test_api.py` | API wrapper tests | ~20 |
| `tests/test_cli.py` | CLI source option tests | ~40 |
| **Total** | | **~570** |
