entity-connections
Read the plan↔entity join, work.entity_connections, and bind an epic to a plan.
| Endpoint | https://engineering.mcp.devfellowship.com/mcp |
| Tools | 2 |
| Backing data | work.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. |
| Tool | Description |
|---|---|
list_entity_connections | Read-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_plan | Binds 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.
Where each target type is written
Section titled “Where each target type is written”| Target type | Write it with | Server |
|---|---|---|
| epic | link_epic_to_plan | engineering (this page) |
| task | set_plan_tasks | plans |
| diagram, document, image, ux_path, spec_run | attach_entity (inserts the token into the plan body; the app derives the row) | plans |
Authorisation — and its honest limit
Section titled “Authorisation — and its honest limit”The four RLS policies on the table are not symmetric:
| Command | Policy for authenticated |
|---|---|
| SELECT | plans.current_user_can_read(entity_id) AND work.entity_target_is_readable(target_type, target_id) |
| INSERT | auth.uid() = created_by |
| UPDATE | auth.uid() = created_by |
| DELETE | auth.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:
- 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 reportsplan_not_found— neverforbidden— so it cannot confirm that a stranger’s personal plan exists. - It then applies the edit half locally: owner, or IAM level ≥ 80, using
public.get_my_iam_role(), which the database evaluates fromauth.uid(). - A plans-app that does not answer produces
plans_app_unavailableand 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_readable | Meaning |
|---|---|
| true | You can read the plan. It genuinely has no links of that kind. |
| false | The 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_idis the plan slug, not a uuid — that is how every plan row in this table is addressed.entity_namenames the source entity (the plan title), matching how the plans-app populates it.created_byis 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: trueand 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.