Skip to content

fix(memory): L4 GoalAncestry can never render — ancestry chain never passed at the only call site #13687

Closed

Description

@mrveiss

Part of #13685 · Wave 1 · no blockers

Problem

Layer4GoalAncestry (GH#6469) can never render. Both ends of the feature were built and merged;
the one line connecting them was not.

TieredContextBuilder.build() declares the parameter with a None default
(chat_history/layers.py:295):

goal_ancestry: list | None = None,

and gates the layer on it (:359-367):

async def should_load(self, goal_ancestry: list | None) -> bool:
    return bool(goal_ancestry)

The only production call site — chat_workflow/llm_handler.py:810-816 — passes user_message,
model_name, session_id, memory_graph, knowledge_service, and never passes
goal_ancestry
. So should_load(None) is always False and L4 never appears in a prompt,
even with the feature flag on.

The producer already exists and matches exactly

llc/services/goal.py:239:

async def get_goal_ancestry_for_work_item(
    self, session: AsyncSession, goal_id: uuid.UUID,
) -> List[dict]:
    """Return root-first goal ancestry chain for a goal_id (GH#6469).

    Includes the goal itself as the last element.  Returns plain dicts
    (``id``, ``title``, ``level``, ``status``) so callers avoid importing
    LLCGoal.  Returns ``[]`` when the goal is not found.
    """

Root-first, plain dicts, title + level — precisely what Layer4GoalAncestry.render reads
(chat_history/layers.py:368-380), and it cites GH#6469 in its own docstring. The consumer's
docstring names the producer; the producer's docstring names the consumer. Only the call is
missing.

Fix

Resolve the goal for the current session/work item and pass the chain at
chat_workflow/llm_handler.py:810.

Open question for the implementer to resolve from the code, not to guess: how a chat session maps
to a goal_id. Check session.metadata (the same dict already read at :798 for company_id)
and the LLC work-item linkage before adding any new plumbing. If no goal is associated with a
session, goal_ancestry stays None and L4 correctly stays silent — that is the designed
behaviour, not a failure.

The lookup must be non-fatal and must not add a blocking DB round-trip to every turn where no
goal is linked: skip the query entirely when the session carries no goal reference.

Acceptance criteria

  • With TIERED_CONTEXT_ENABLED=true and a session linked to a goal, the prompt contains a
    ## Goal Ancestry breadcrumb, evidenced by captured prompt output.
  • A session with no linked goal issues no goal query and renders no L4 block.
  • A goal-lookup failure is non-fatal: the turn completes without L4.
  • Layer4GoalAncestry itself is unchanged — this is a call-site fix.

Activity

  1. mrveiss commented on Aug 8, 2026

    @mrveiss
    OwnerAuthor

    Blocked on #13704 — and the wiring this issue describes is unsafe as written

    Recording the blocker here, since this issue is open and gave no indication of why it had stalled.

    #13696 removed the L4 wiring rather than shipping it. Review found two problems with the approach this issue specifies:

    1. It cannot work. The only carrier for work_item_id into the prompt path is api/chat.py:1479 — request_data.get("context", {}), a raw client JSON bag. A repo-wide grep finds only readers of that key; nothing server-side ever sets it for a chat turn. L4 would have stayed dark regardless of the call-site fix.

    2. Wiring it as described opens a cross-tenant read. Neither WorkItemService.get (llc/services/work_item_service.py:305) nor GoalService.get (llc/services/goal.py:114) filters on company_id, though both models carry it. Any authenticated user posting {"context": {"work_item_id": "<any uuid>"}} would have had another company's goal title/level/status, plus every ancestor, rendered into their prompt.

    This also invalidates the trust argument in build_governed_identity's own docstring (chat_workflow/models.py:407-411), which justifies trusting that bag on the grounds that a caller can only add restrictions to their own run. Turning the same field into a read selector breaks that reasoning.

    What unblocks this

    #13704 delivers the two prerequisites: a server-side session→work-item binding that is not settable from the request body, and tenant-scoped lookups. Its design is settled — it mirrors the existing trusted-overlay seam (SessionRoleService / apply_role, chat_workflow/manager.py:3636-3644) rather than inventing a new trust model, so context["work_item_id"] becomes a server-written value and the existing build_governed_identity path reads something trusted.

    Once that lands, this issue is what it always claimed to be: a call-site fix.

    Correction to the issue body

    Only the connection is missing.

    Not accurate. The producer and consumer both exist, but there is no safe carrier between them — and no carrier at all. That is why this could not be closed alongside #13686.

  2. mrveiss commented on Aug 8, 2026

    @mrveiss
    OwnerAuthor

    Closed by #13728 (6324def74), which delivered the two prerequisites this issue was blocked on: a server-side session→work-item binding and tenant-scoped goal lookups.

    L4 GoalAncestry now renders. Proven through the production path — test_a_bound_session_renders_the_goal_ancestry_block starts from the binding and asserts ## Goal Ancestry reaches a captured prompt, rather than injecting a chain into the builder (which only ever proved Layer4GoalAncestry renders, and that was never in doubt).

    Correction to this issue's premise

    The body said "Only the connection is missing." That was wrong twice over, and #13696 removed the wiring rather than ship it:

    1. There was no carrier. work_item_id reached the prompt path only from api/chat.py:1479 — a raw client JSON bag — and nothing server-side ever set it. L4 would have stayed dark regardless of the call-site fix.
    2. Wiring it as described opened a cross-tenant read, since neither LLC lookup filtered on company_id.

    The producer and consumer both existed; what was missing was a safe carrier between them. That is why this took a prerequisite issue rather than a one-line change.

    Full closure evidence is on #13704.

  3. added 7 commits that reference this issue on Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions