lundie.io Get In Touch

Phase 9: Privacy, Polish & Final Push

December 2025 – January 2026

Almost Ready

Authentication was live. Security was hardened. By December the application was functionally complete – but two things stood between the current state and a launch I'd be comfortable with. The first was a feature that felt inseparable from the product's philosophy: giving users explicit control over what the AI could see. The second was a quieter problem – a persistent unease about security and metering that kept nudging me away from the proverbial publish button.

AI Context Model

Commits: API 3831302 – "AI context privacy controls with three-tier visibility model" / Web c0ccc36 – "Three-state AI context privacy toggle with animated visual feedback (Phase 1 – UI only)"

Every node gained an ai_context field with three possible states: private (the default – never sent to AI), visible (can be included) and pinned (always present in the AI's context). On the API side this is a simple Literal type with a hardcoded default that can be overridden by the user for each journal:

app/model/ai_context.py
1
2
AiContextPrivacy = Literal["private", "visible", "pinned"]
AI_CONTEXT_DEFAULT = "private"

The model is about curating what an AI knows about your work at any given moment – giving users explicit control over that boundary rather than leaving it to the system. The pinned state in particular reflects how context actually works in practice: a root journal node would almost always be present in the AI's understanding of the project because it's structural. Of course, should a user choose to hide all context from an AI, none is ever shared. Without at least three context nodes available, the API will not make the call at all.

Filtering at the boundary

sequenceDiagram box rgb(50, 85, 115) Frontend actor User participant NST as NestedSortableTree end box rgb(45, 100, 78) Backend participant NR as nodes_router end box rgb(110, 50, 58) External participant OAI as OpenAI end User->>NST: Request suggestion NST->>NST: Check context node states alt Fewer than 3 non-private nodes NST-->>User: Warning – no request sent else 3 or more nodes available NST->>NR: POST /api/v1/nodes/suggest NR->>NR: Validate ownership, filter private,\nauto-fetch pinned nodes alt Total context < 3 NR-->>NST: 400 – insufficient context NST-->>User: Show error else Total context ≥ 3 NR->>OAI: AI node completion with context OAI-->>NR: Suggestions NR-->>NST: Suggestions NST-->>User: Display suggestions end end

Abbreviated – ownership validation, pinned node fetching and API client dispatch omitted for clarity.

The model enforces at the AI boundary only. When a user views their own graph, every node is visible regardless of its ai_context value. Filtering only applies when building context for the suggestion endpoint: private nodes are excluded from explicit context. Pinned nodes are auto-fetched across all collections and prepended automatically:

app/services/ai_context_service.py – fetch_pinned_nodes_as_context()
1
2
3
4
5
6
7
8
9
10
11
for collection_name in COLLECTIONS:
    query = (
        db.collection(collection_name)
        .where(filter=FieldFilter("user_id", "==", user_id))
        .where(filter=FieldFilter("ai_context", "==", "pinned"))
        .limit(20)
    )
    for doc in query.stream():
        node_data = doc.to_dict()
        if node_data and node_data.get('id') not in exclude_ids:
            pinned_nodes.append(_convert_to_context_node(node_data, collection_name))

The (user_id, ai_context) composite index added here is what makes this query viable at small to medium scale – without it, Firestore would require a full collection scan for each node type. A graph database would be the natural migration target beyond that; I kept this in mind from the outset. The service and repository layers were structured so the persistence swap would be as contained as possible.

The toggle

Commit: Web c0ccc36 / 3d247fb

The frontend toggle was built in two stages: UI and animation first, backend persistence second. The cycling logic lives in a type file shared across all node components:

src/features/nodes/types/aiContext.ts
1
2
3
4
5
6
7
8
9
10
export type AiContextState = 'private' | 'visible' | 'pinned';

export function getNextAiContextState(current: AiContextState): AiContextState {
  const cycle: Record<AiContextState, AiContextState> = {
    private: 'visible',
    visible: 'pinned',
    pinned: 'private',
  };
  return cycle[current];
}

Interface before implementation

Building the UI before wiring the backend was deliberate. A toggle on a node editor sits right in the user's working flow – if it interrupted that flow, it would go unused regardless of how useful the feature was. Validating the interaction first meant the backend contract could be shaped by what the UX actually required. The initial assumption was that CRUD endpoints would handle persistence; the UI made it clear that the toggle should be part of the mutation process instead. The UX informed the architecture, not the other way round.

Graph Traversal

Commit: API 898559c – "Full batching overhaul for graph traversal + repo/service refactor"

The N+1 query problem in graph traversal, flagged as a known issue in Phase 3, was finally addressed here. The original implementation fetched edges, registry entries and node documents one node at a time. That meant 60+ firestore queries for a graph of 20 nodes – a round-trip approaching 19 seconds at worst.

The rewrite replaced per-node lookups with a BFS traversal that batches all outgoing edge queries at each depth level, then hydrates all node data in a single pass at the end:

app/services/edge_service.py – get_graph_from_node()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
current_level_nodes: List[str] = [root_node]
visited_nodes: Set[str] = {root_node}

while current_level_nodes and depth_cursor < max_depth:
    batched_edges, chunk_truncated = get_outgoing_edges_batch(
        current_level_nodes, user_id, edge_type_filter, hierarchy_filter
    )
    next_level_nodes: Set[str] = set()

    for edge in batched_edges:
        if edge.to_node not in visited_nodes:
            visited_nodes.add(edge.to_node)
            next_level_nodes.add(edge.to_node)

    current_level_nodes = list(next_level_nodes)
    depth_cursor += 1

# All node data fetched in one batched pass after traversal
nodes_data_dict = _fetch_nodes_data_batch(list(visited_nodes), user_id)

Firestore's in operator caps at 10 IDs per query, so get_outgoing_edges_batch() chunks the frontier automatically. The total query count for the same 20-node graph drops to 7–9 and the traversal time falls below one second.

Schema Hardening

Commits: API b6e358f, fa2abe0, ba9e140 and others – January 2026

Most API traffic had been flowing through the mutation queue since Phase 6. The traditional CRUD endpoints – one per node type, one per operation – had been playing catch-up. January was the moment to close the gap. The choice to keep CRUD endpoints alongside the mutation queue was deliberate: the tree view wasn't the only planned access pattern. Leaving conventional routes open gave flexibility for future graph views.

The most visible change was typed update schemas replacing raw dict parameters. The before/after from fa2abe0:

app/api/v1/results_router.py – before
1
2
3
4
5
6
async def update_result(
    result_id: str = Path(...),
    updates: dict = ...,
    user_id: str = Depends(get_current_user_id)
):
    result = result_service.update_result(result_id, updates, user_id)
app/api/v1/results_router.py – after
1
2
3
4
5
6
7
8
9
async def update_result(
    result_id: str = Path(...),
    updates: ResultUpdateSchema = ...,
    user_id: str = Depends(get_current_user_id)
):
    update_data = updates.model_dump(exclude_unset=True, by_alias=False)
    if not update_data:
        raise HTTPException(status_code=400, detail="No fields provided for update")
    result = result_service.update_result(result_id, update_data, user_id)

Each update schema uses extra = "forbid" to reject unknown fields at the validation boundary, and exclude_unset=True on serialisation to ensure only explicitly provided fields reach the database. The same pattern was applied across thoughts, prompts, insights, results and journals. Alongside this, composite Firestore indexes for (user_id, ai_context) queries, null filtering on update payloads, non-whitespace path parameter validation and a logging tidy-up rounded out the month.

Not Quite Ready

By the end of January the app was in the best shape it had been. The feature work felt complete. There was a real temptation to ship. But there was a persistent nag about two things that weren't in place: a proper security audit and metering. Without metering Firestore reads and Cloud Run compute represented an uncapped cost liability. Launching without those felt reckless. Sensibility held out over the desire to go live and Phases 10 and 11 followed.

Key Commits from This Phase

API 898559c 2025-11-21
[Performance] Full batching overhaul for graph traversal + repo/service refactor
Web c0ccc36 2025-12-10
[Feat/UI] Three-state AI context privacy toggle with animated visual feedback (Phase 1 – UI only)
API 3831302 2025-12-24
[Feat] AI context privacy controls with three-tier visibility model
Web 3d247fb 2025-12-26
[Feat/UI] Persist AI context privacy toggle with backend-backed node updates
API b6e358f 2026-01-09
[Refactor/Thoughts] Introduce base schema for create
API fa2abe0 2026-01-09
[Fix/Results] Enforce schema for update endpoint
API ba9e140 2026-01-09
[Fix/Prompts] Validate update payload schema
API abd1a1a 2026-01-10
Merge pull request #6: AI Context Privacy Controls & Schema-Driven PATCH Normalisation

Get In Touch

Prefer using email? Say hi at hello@lundie.io