Skip to content

entity-connections

Read the plan↔entity join, work.entity_connections, and bind an epic to a plan.

Endpointhttps://engineering.mcp.devfellowship.com/mcp
Tools2
Backing datawork.entity_connections (read all target types, insert epic only), read of work.epics, and a read of the plans-app GET /api/plans/<slug> as the caller.
ToolDescription
list_entity_connectionsRead-only. Lists plan↔entity links across every target type — task, epic, diagram, document, image, ux_path, spec_run. Filter by plan_slug, target_type and/or target_id (the reverse lookup: which plans point at this entity). Paginated, RLS-scoped to the caller.
link_epic_to_planBinds one work.epics epic to one plan, as a single target_type = 'epic' row. Idempotent. Authorised like the plans-app: readable plan and owner-or-admin.

Why the writer is epic-scoped, and stays that way

Section titled “Why the writer is epic-scoped, and stays that way”

work.entity_connections holds seven target types. The plans-app owns and reconciles six of them. On every publish it runs

DELETE FROM work.entity_connections
WHERE entity_id = $1 AND target_type = ANY($2::text[]);
-- $2 = {diagram, document, image, ux_path, spec_run}

and re-inserts exactly what the plan body’s {{dfl-entity:…}} tokens say (dfl-plans → lib/entity-registry.js, REGISTRY_TARGET_TYPES). PATCH /api/plans/:slug/tasks does the same for target_type = 'task'.

A row of any of those six written from this server would be deleted by the next publish of that plan. The tool call returns success; the row is gone later, with nothing to point at. That is why there is no general writer here and why link_epic_to_plan has no target_type argument.

task and epic are excluded from the reconcile scope deliberately, by name, with a unit test in dfl-plans that fails if anyone widens it. task already has a writer — set_plan_tasks on the plans MCP. epic had a live reader (the dfl-learn epics panel) and, until this tool, no writer anywhere in the fleet. That is the gap this fills.

Target typeWrite it withServer
epiclink_epic_to_planengineering (this page)
taskset_plan_tasksplans
diagram, document, image, ux_path, spec_runattach_entity (inserts the token into the plan body; the app derives the row)plans

The four RLS policies on the table are not symmetric:

CommandPolicy for authenticated
SELECTplans.current_user_can_read(entity_id) AND work.entity_target_is_readable(target_type, target_id)
INSERTauth.uid() = created_by
UPDATEauth.uid() = created_by
DELETEauth.uid() = created_by

Reads are plan-scoped in the database. list_entity_connections therefore needs no gate of its own: it runs with the caller’s JWT and the policy does the filtering. It never uses service_role.

Writes are not. The INSERT policy checks only that you stamp your own uid; it says nothing about the plan. The real permission gate for plan links lives in the plans-app HTTP layer (canEdit() — owner, or admin at IAM level 80+).

link_epic_to_plan closes that by delegating rather than re-implementing:

  1. It calls GET https://plans.devfellowship.com/api/plans/<slug> carrying the caller’s own JWT. The plans-app resolves that token itself and answers 404 for a plan the caller may not read. The tool reports plan_not_found — never forbidden — so it cannot confirm that a stranger’s personal plan exists.
  2. It then applies the edit half locally: owner, or IAM level ≥ 80, using public.get_my_iam_role(), which the database evaluates from auth.uid().
  3. A plans-app that does not answer produces plans_app_unavailable and writes nothing. An unanswered gate is never rounded down to permission.

Reading is permission-filtered, and says so

Section titled “Reading is permission-filtered, and says so”

Since dfl-schema #791 the SELECT policy is plan-scoped. An RLS-filtered SELECT does not fail — it succeeds and returns nothing. So “this plan has no links” and “you may not read this plan” arrive as the same empty array.

list_entity_connections always returns an rls_note saying the list is scoped to you, and when a plan_slug filter produced zero rows it additionally reports plan_readable:

Value of plan_readableMeaning
trueYou can read the plan. It genuinely has no links of that kind.
falseThe plan is absent or not yours. The empty list proves nothing about its links.
”unknown”The plans-app did not answer. Treat the empty list as undetermined.
  • entity_id is the plan slug, not a uuid — that is how every plan row in this table is addressed.
  • entity_name names the source entity (the plan title), matching how the plans-app populates it.
  • created_by is stamped with the caller’s uid. Since the DELETE policy is the same predicate, whoever created a link can remove it.
  • Re-linking the same plan and epic returns already_linked: true and inserts nothing; a lost unique-violation race is reported as success, because the end state is the intended one.
  • There is deliberately no unlink tool in this group yet.