Blog · July 24, 2026
Ask task_link to create the same illegal cross-tenant edge two different ways. One way fails silently. The other throws.
task_link is the one RPC that mutates a task's place in the dependency graph — add or remove a depends_on edge, set or clear a parent. Tenant isolation on both operations rests on the same database guarantee: task_dependencies and tasks.parent_task_id are both foreign-keyed on (tenant_id, target_id), not just target_id, so a target row from another tenant simply doesn't exist in the tenant-scoped half of that pair. Point either operation at a task you don't own and Postgres raises the identical error — 23503, foreign key violation. That part is airtight, and closing the pgTAP gap on this function (it had never once been called from the test suite, despite existing since the first migration) confirmed it: no exploitable cross-tenant write, on either path.
Same violation, two different callers see two different things
The function wraps the add_depends_on loop in its own per-edge exception handler — on purpose, because a legitimate same-tenant edge can also fail, when it would close a dependency cycle, and the caller is meant to find out which edges landed and which didn't without the whole call aborting:
foreach v_dep in array coalesce(p_add_depends_on, '{}') loop
begin
insert into public.task_dependencies (tenant_id, task_id, depends_on_task_id, created_by_actor_id)
values (v_tenant, p_task_id, v_dep, v_actor)
on conflict do nothing;
exception when others then
v_cycle_rejected := v_cycle_rejected || jsonb_build_array(jsonb_build_array(p_task_id, v_dep));
end;
end loop;
when others doesn't distinguish a cycle from a foreign-key violation from anything else — it catches every reason that insert could fail and reports all of them the same way: ok: true, the pair appended to cycle_rejected, no error, no code, no hint which of the two actually happened. Ask it to link a task you own to one you don't, and the response looks exactly like asking it to close a cycle. Both are illegal, both get quietly declined, and the caller has to already know why to tell them apart.
set_parent_task_id reaches the exact same constraint through a bare update, with no begin/exception around it at all:
elsif p_set_parent_task_id is not null then
update public.tasks set parent_task_id = p_set_parent_task_id
where tenant_id = v_tenant and id = p_task_id; -- parent-cycle trigger guards this
end if;
The comment is about the cycle trigger, which does fire here too and is likewise silent by database design (a before trigger raising inside a statement that has no surrounding catch just propagates). But so does the cross-tenant case, because there's no catch to swallow it. Set a task's parent to a task in another tenant and the call doesn't return ok:true with a note — it throws 23503 straight at whoever called it.
Neither behavior is wrong on its own. A silent decline is a defensible API shape for "this specific edge didn't stick, here's the list, keep going." A hard throw is a defensible shape too. What isn't defensible, once you write the test that exercises both paths side by side, is that an agent calling this RPC has no way to predict which one it's going to get from the operation alone — it depends on which parameter you passed, for reasons that trace back to which line of task_link's implementation happens to sit inside a begin block, not to anything about the request itself.
Why this is a content post, not a fix commit
We didn't change the behavior. Both paths correctly refuse the cross-tenant write — the finding isn't a security gap, it's an ergonomics gap, and changing which one throws is an observable-behavior change for every existing caller of task_link, in either direction. Making add_depends_on throw would break anyone currently relying on inspecting cycle_rejected after a batch of edges. Making set_parent_task_id swallow would hide a real caller mistake behind a false ok:true. Picking one is a real API design decision, not a bug fix, so it's staying as a named, tested trade-off rather than something we silently pick a side on mid-sweep.
What did change: task_link now has pgTAP coverage proving both shapes exactly as they exist today — own-tenant add/remove/set/clear all succeed, a cross-tenant add_depends_on returns ok:true with the pair in cycle_rejected and never touches the table, a cross-tenant set_parent_task_id throws 23503, and an in-process cycle takes the same silent path as the cross-tenant add. If anyone changes which side of that inconsistency to land on, this is now the test that has to be edited on purpose to do it — not a behavior that drifts because nobody was watching that function.
Start at ledgenter.com.