Say which attached resources stopped running and why

The agent and workflow reads now return resource_states to people who may
edit them: every attached tool, source and prompt (and workflow node tool
and source) with active or stopped and the reason: deleted,
owner_lost_access, the sponsor reasons, connection_needs_reconnect,
connection_removed or connector_disabled. Each entry names the sponsor,
someone other than the reader who can fix it, the service for a connection
reason, and whether the reader may take it over or reconnect it. When
something can be taken over, sponsor_audience says who it would reach.

The state comes from the checks the run itself uses (ref_access,
resolve_holder_tool and the tool's connection as the run resolves it), so
the page and the run can't disagree. A run that leaves a resource out logs
resource_stopped with the holder, type, id and reason.

The workflow read gives sponsor details, run state and node resource names
only to people who may edit it, and names only resources the workflow runs,
someone sponsored, or the reader can see. Owner saves and new workflows
now refuse node tools and sources the owner can't use, like editor saves.
This commit is contained in:
arc53-machine committed 2026-09-29 17:11:58 +01:00
1 parent 90b385e9ea
commit 2359e4546b
11 files changed
+1165 -74

No files matched your search

+19 -11
View File
@@ -156,20 +156,27 @@ def authorized_prompt_id(prompt_id: Any, principal: Optional[str], agent: Option
pid = str(prompt_id)
if is_composed_preset(pid) or pid in _PROMPT_PRESETS_WITHOUT_ROW:
return prompt_id
from docsgpt.api.user.resource_access import active_sponsor, resolve
from docsgpt.api.user.resource_access import (
REASON_OWNER_LOST_ACCESS,
log_stopped,
ref_access,
)
# The same check the agent page's run state uses: the principal, else a
# live sponsor on the agent.
holder = {**agent, "user_id": principal} if agent and agent.get("id") else {"user_id": principal}
try:
with db_readonly() as conn:
ra = resolve(conn, "prompt", pid, principal) if principal else None
usable = ra is not None and ra.can("use")
if not usable and agent and agent.get("id"):
usable = active_sponsor(conn, "agent", agent, "prompt", pid) is not None
access = ref_access(conn, "agent", holder, "prompt", pid) if principal else None
except Exception:
logger.exception("Prompt access check failed for %s", pid)
usable = False
if usable:
access = None
if access is not None and access.principal:
return prompt_id
logger.info("prompt %s not usable by %s; using the default prompt", pid, principal)
log_stopped(
"agent" if agent else "chat", holder, "prompt", pid,
access.reason if access is not None else REASON_OWNER_LOST_ACCESS,
)
return "default"
@@ -180,10 +187,11 @@ def _agent_source_doc(conn: Any, sources_repo: Any, agent: dict, source_id: Any)
editor who attached it while they still qualify. Read unscoped once
authorized: an owner-scoped read misses a team-shared source.
"""
from docsgpt.api.user.resource_access import ref_principal
from docsgpt.api.user.resource_access import log_stopped, ref_access
if not ref_principal(conn, "agent", agent, "source", str(source_id)):
logger.info("agent %s source %s not usable; skipped", agent.get("id"), source_id)
access = ref_access(conn, "agent", agent, "source", str(source_id))
if not access.principal:
log_stopped("agent", agent, "source", source_id, access.reason)
return None
return sources_repo.get_by_id(str(source_id))
+12 -2
View File
@@ -35,7 +35,9 @@ from docsgpt.api.user.resource_access import (
plan_sponsors,
require,
resolve,
resource_states,
settings_many,
sponsor_audience,
sponsor_details,
sponsor_refusal,
)
@@ -560,6 +562,8 @@ class GetAgent(Resource):
user = decoded_token["sub"]
agent = None
sponsored: list = []
states: list = []
audience = None
with db_readonly() as conn:
# Anyone who can see the agent reads it (a viewer needs it to
# chat); what they get back is trimmed by their actions.
@@ -567,10 +571,13 @@ class GetAgent(Resource):
if ra is not None:
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
# Edit-page detail: who vouches for resources the owner can't
# use, with their names. Only for people who may edit the
# agent: the names can be an editor's private resources.
# use, and which attached resources stopped running and why,
# with their names. Only for people who may edit the agent:
# the names can be an editor's private resources.
if agent and ra.can("edit"):
sponsored = sponsor_details(conn, "agent", agent, viewer=user)
states = resource_states(conn, "agent", agent, agent_refs(agent), user)
audience = sponsor_audience(conn, "agent", agent, states, sponsored)
if not agent:
return {"status": "Not found"}, 404
is_owner = ra.access == "owner"
@@ -582,6 +589,9 @@ class GetAgent(Resource):
access=ra.payload(),
)
data["resource_sponsors"] = sponsored
data["resource_states"] = states
if audience is not None:
data["sponsor_audience"] = audience
return make_response(jsonify(data), 200)
except Exception as e:
current_app.logger.error(f"Agent fetch error: {e}", exc_info=True)
+401 -4
View File
@@ -513,12 +513,148 @@ def ref_principal(
The owner when they may use it (the default), else a live sponsor.
"""
return ref_access(conn, holder_type, holder, resource_type, resource_id).principal
# --- Run state of attached resources ----------------------------------------
#
# One check decides whether an attached resource runs, for the run and for the
# edit page alike (``ref_access``, ``resolve_holder_tool``), so the page never
# says a resource runs when the run drops it, or the other way round.
# Why an attached resource doesn't run (``resource_states`` ``reason``), next
# to the sponsor reasons above.
REASON_DELETED = "deleted"
REASON_OWNER_LOST_ACCESS = "owner_lost_access"
REASON_CONNECTION_NEEDS_RECONNECT = "connection_needs_reconnect"
REASON_CONNECTION_REMOVED = "connection_removed"
REASON_CONNECTOR_DISABLED = "connector_disabled"
_REF_TABLES = {"source": "sources", "prompt": "prompts", "tool": "user_tools"}
@dataclass(frozen=True)
class RefAccess:
"""Who a holder runs one referenced resource as, or why it doesn't run.
Attributes:
principal: The user it is authorized as (the holder's owner or a live
sponsor); None when it doesn't run.
reason: None while it runs; else :data:`REASON_DELETED`,
:data:`REASON_OWNER_LOST_ACCESS` or a sponsor reason.
sponsor: The recorded sponsor, running or not.
"""
principal: Optional[str]
reason: Optional[str] = None
sponsor: Optional[str] = None
def _ref_exists(conn: Connection, resource_type: str, resource_id: str) -> bool:
"""Whether a row with this id exists, whoever owns it."""
table = _REF_TABLES.get(resource_type)
if table is None or not looks_like_uuid(str(resource_id)):
return False
return conn.execute(
text(f"SELECT 1 FROM {table} WHERE id = CAST(:id AS uuid)"), {"id": str(resource_id)}
).first() is not None
def ref_access(
conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
) -> RefAccess:
"""Whether and as whom a holder runs one referenced resource.
The run and the edit page both ask this. The owner runs it when they may
use it, else a live sponsor does; otherwise it is stopped, because the row
is gone, a recorded sponsor no longer qualifies, or the owner lost access.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row (needs ``user_id``; ``id`` and
``resource_sponsors`` for sponsors).
resource_type: ``source``, ``prompt`` or ``tool``.
resource_id: The referenced id.
Returns:
RefAccess: The principal, or the reason it doesn't run.
"""
if not holder:
return None
return RefAccess(None, REASON_OWNER_LOST_ACCESS)
rid = str(resource_id)
owner = holder.get("user_id")
if can_use_ref(conn, resource_type, str(resource_id), owner):
return owner
return active_sponsor(conn, holder_type, holder, resource_type, resource_id)
if can_use_ref(conn, resource_type, rid, owner):
return RefAccess(owner)
sponsor, sponsor_reason = sponsor_state(conn, holder_type, holder, resource_type, rid)
if sponsor and sponsor_reason is None:
return RefAccess(sponsor, None, sponsor)
if not _ref_exists(conn, resource_type, rid):
return RefAccess(None, REASON_DELETED, sponsor)
return RefAccess(None, sponsor_reason or REASON_OWNER_LOST_ACCESS, sponsor)
def resolve_holder_tool(
conn: Connection, holder_type: str, holder: Optional[dict], tool_id: str, *, tools_repo=None
) -> tuple[Optional[dict], RefAccess]:
"""The tool row a holder runs ``tool_id`` with, and its access.
Builtin and default tool ids resolve to their synthesized rows. A
``user_tools`` row resolves as the holder's owner, else as its live
sponsor (see :func:`ref_access`); the row is the tool owner's either way.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
tool_id: The referenced tool id.
tools_repo: A ``UserToolsRepository`` on ``conn`` to reuse.
Returns:
``(row, access)``: the row, None when it doesn't run, and why.
"""
# Lazy: default_tools imports this module lazily too.
from docsgpt.agents.default_tools import resolve_tool_by_id
repo = tools_repo or UserToolsRepository(conn)
owner = (holder or {}).get("user_id")
row = resolve_tool_by_id(tool_id, owner, user_tools_repo=repo)
if row is not None:
return row, RefAccess(owner)
access = ref_access(conn, holder_type, holder, "tool", str(tool_id))
if access.principal:
row = resolve_tool_by_id(tool_id, access.principal, user_tools_repo=repo)
if row is None:
reason = access.reason or REASON_DELETED
return None, RefAccess(None, reason, access.sponsor)
return row, access
def log_stopped(
holder_type: str, holder: Optional[dict], resource_type: str, resource_id, reason: Optional[str]
) -> None:
"""Log one attached resource a run leaves out, greppable by ``resource_stopped``.
Args:
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
resource_type: ``source``, ``prompt`` or ``tool``.
resource_id: The referenced id.
reason: Why it doesn't run.
"""
holder_id = str((holder or {}).get("id") or "")
logger.info(
"resource_stopped holder=%s:%s type=%s id=%s reason=%s",
holder_type, holder_id, resource_type, resource_id, reason,
extra={
"event": "resource_stopped",
"holder_type": holder_type,
"holder_id": holder_id,
"resource_type": resource_type,
"resource_id": str(resource_id),
"reason": reason,
},
)
def _sponsorable(resource_type: str, resource_id: str) -> bool:
@@ -879,3 +1015,264 @@ def sponsor_details(
}
)
return out
def holder_editable_by(conn: Connection, holder_type: str, holder: dict, user_id: Optional[str]) -> bool:
"""Whether ``user_id`` may edit the agent or workflow ``holder``.
Its owner always may; anyone else needs ``edit`` on the agent (for a
workflow, on one of its owner's agents that run it).
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
user_id: The reader.
Returns:
bool: Whether they may edit it.
"""
if not user_id or not holder:
return False
if user_id == holder.get("user_id"):
return True
return _holder_editable_by(conn, holder_type, holder, user_id)
def _ref_rows(conn: Connection, refs: Iterable[tuple[str, str]]) -> dict[str, dict]:
"""``"<type>:<id>" -> {name, user_id}`` for referenced rows, owner-agnostic, per type in one query."""
queries = {
"source": "SELECT id, name, user_id FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))",
"prompt": "SELECT id, name, user_id FROM prompts WHERE id = ANY(CAST(:ids AS uuid[]))",
"tool": (
"SELECT id, COALESCE(NULLIF(custom_name, ''), NULLIF(display_name, ''), name), user_id "
"FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[]))"
),
}
by_type: dict[str, list[str]] = {}
for resource_type, resource_id in refs:
if resource_type in queries and looks_like_uuid(str(resource_id)):
by_type.setdefault(resource_type, []).append(str(resource_id))
out: dict[str, dict] = {}
for resource_type, ids in by_type.items():
for rid, name, owner in conn.execute(text(queries[resource_type]), {"ids": ids}).fetchall():
out[sponsor_key(resource_type, str(rid))] = {"name": name, "user_id": owner}
return out
def _user_labels(conn: Connection, user_ids: Iterable[Optional[str]]) -> dict[str, str]:
"""``user_id -> email`` for the ones with an email on file."""
ids = sorted({u for u in user_ids if u})
if not ids:
return {}
return dict(
conn.execute(
text(
"SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
"AND email IS NOT NULL AND email <> ''"
),
{"ids": ids},
).fetchall()
)
def _connection_state(conn: Connection, tool: dict, owner: Optional[str], policies_box: list) -> tuple:
"""``(reason, connection)`` for a connection-backed tool the holder runs as ``owner``."""
from docsgpt.connectors import catalog, service
from docsgpt.connectors.resolve import connection_stop_reason, resolve_connection
if not tool.get("connection_id") and not catalog.definition_for_tool(tool.get("name") or ""):
return None, None
if not policies_box:
policies_box.append(service.load_policies(conn))
resolved = resolve_connection(tool, owner, conn=conn, policies=policies_box[0])
reason = connection_stop_reason(tool, resolved)
if reason is None:
return None, None
if resolved is not None:
connection = {
"id": resolved.connection_id,
"connector_key": resolved.connector_key,
"name": resolved.connector_name,
}
else:
definition = catalog.definition_for_tool(tool.get("name") or "")
connection = {"id": None, "connector_key": definition.key, "name": definition.name}
return reason, connection
def resource_states(
conn: Connection,
holder_type: str,
holder: dict,
refs: Iterable[tuple[str, str]],
viewer: Optional[str],
) -> list[dict]:
"""Whether each resource a holder references runs, for its edit page.
Uses the run's own checks (:func:`ref_access`, :func:`resolve_holder_tool`
and the tool's connection as the run resolves it for the owner), so a
resource shown as stopped is one the run leaves out. Presets and builtin
tools always run and are not listed. Only for readers who may edit the
holder: the caller checks that.
A name is given only for a resource that runs, that the reader can see
themselves, that someone sponsored on the holder, or that is attached to
an agent (agent saves check every reference), so a reference to someone
else's resource never reveals its name.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
refs: The ``(type, id)`` resources it references.
viewer: The user reading the page.
Returns:
list: Per resource ``{key, type, id, name, state, reason, sponsor,
contact, connection, can_confirm, can_reconnect}``. ``state`` is
``active`` or ``stopped``; ``reason`` (None while active) is one of
``deleted``, ``owner_lost_access``, the sponsor reasons,
``connection_needs_reconnect``, ``connection_removed`` or
``connector_disabled``. ``sponsor`` and ``contact`` are
``{user_id, label}`` or None: the recorded sponsor, and someone other
than the reader who can fix it. ``connection`` (``{id,
connector_key, name}``) names the service for a connection reason.
``can_confirm``: the reader may take it over on their next save;
``can_reconnect``: the reader owns the connection that needs signing
in again.
"""
owner = holder.get("user_id")
seen: set[str] = set()
pairs: list[tuple[str, str]] = []
for resource_type, resource_id in refs:
rid = str(resource_id).lower()
key = sponsor_key(resource_type, rid)
if key in seen or resource_type not in REF_USE_ACTION or not _sponsorable(resource_type, rid):
continue
seen.add(key)
pairs.append((resource_type, rid))
if not pairs:
return []
rows = _ref_rows(conn, pairs)
recorded = {k.lower(): v for k, v in (holder.get("resource_sponsors") or {}).items()}
viewer_edits = bool(viewer and viewer != owner and _holder_editable_by(conn, holder_type, holder, viewer))
tools_repo = UserToolsRepository(conn)
policies_box: list = []
entries = []
for resource_type, rid in pairs:
key = sponsor_key(resource_type, rid)
info = rows.get(key) or {}
connection = None
if resource_type == "tool":
tool_row, access = resolve_holder_tool(conn, holder_type, holder, rid, tools_repo=tools_repo)
reason = access.reason
if tool_row is not None and reason is None:
reason, connection = _connection_state(conn, tool_row, owner, policies_box)
else:
access = ref_access(conn, holder_type, holder, resource_type, rid)
reason = access.reason
sponsor = recorded.get(key)
sponsor = sponsor if sponsor and sponsor != owner else None
resource_owner = info.get("user_id")
can_confirm = bool(
reason in (REASON_OWNER_LOST_ACCESS, REASON_CANNOT_EDIT_HOLDER, REASON_CANNOT_EDIT_RESOURCE)
and viewer_edits
and can_sponsor_ref(conn, resource_type, rid, viewer)
)
contact = None
if reason == REASON_OWNER_LOST_ACCESS:
# The owner asks whoever shared it; an editor asks the owner.
contact = resource_owner if viewer == owner else owner
elif reason in (REASON_CONNECTION_NEEDS_RECONNECT, REASON_CONNECTION_REMOVED):
contact = resource_owner
if contact == viewer:
contact = None
name_visible = bool(
reason is None
or sponsor
or holder_type == "agent"
or (viewer and resolve(conn, resource_type, rid, viewer) is not None)
)
entries.append({
"key": key,
"type": resource_type,
"id": rid,
"name": info.get("name") if name_visible else None,
"state": "active" if reason is None else "stopped",
"reason": reason,
"sponsor": sponsor,
"contact": contact,
"connection": connection,
"can_confirm": can_confirm,
"can_reconnect": bool(
reason == REASON_CONNECTION_NEEDS_RECONNECT
and connection
and connection.get("id")
and viewer
and viewer == resource_owner
),
})
labels = _user_labels(conn, [u for e in entries for u in (e["sponsor"], e["contact"])])
for entry in entries:
for field_name in ("sponsor", "contact"):
user_id = entry[field_name]
entry[field_name] = {"user_id": user_id, "label": labels.get(user_id) or user_id} if user_id else None
return entries
def visible_ref_ids(
conn: Connection, holder_type: str, holder: dict, refs: Iterable[tuple[str, str]], viewer: Optional[str]
) -> set[str]:
"""Keys of references whose names ``viewer`` may read on the holder's edit page.
Those the holder runs (its owner or a live sponsor may use them), those
someone sponsored on it, and those the reader can see themselves.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
refs: ``(type, id)`` pairs.
viewer: The reader.
Returns:
set: ``"<type>:<id>"`` keys, ids lowercased.
"""
recorded = {k.lower() for k in (holder.get("resource_sponsors") or {})}
out: set[str] = set()
for resource_type, resource_id in refs:
rid = str(resource_id).lower()
key = sponsor_key(resource_type, rid)
if key in out or resource_type not in REF_USE_ACTION:
continue
if (
key in recorded
or ref_access(conn, holder_type, holder, resource_type, rid).principal
or (viewer and resolve(conn, resource_type, rid, viewer) is not None)
):
out.add(key)
return out
def sponsor_audience(
conn: Connection, holder_type: str, holder: dict, states: list[dict], sponsors: list[dict]
) -> Optional[dict]:
"""The holder's audience when the reader may take something over, else None.
A take-over runs the resource with the reader's access for everyone who
uses the holder, so the page shows them who that is before they agree.
Args:
conn: Open database connection.
holder_type: ``agent`` or ``workflow``.
holder: The holder row.
states: :func:`resource_states` for the reader.
sponsors: :func:`sponsor_details` for the reader.
Returns:
dict or None: :func:`holder_audience`, when any item has ``can_confirm``.
"""
if not any(item.get("can_confirm") for item in [*states, *sponsors]):
return None
return holder_audience(conn, holder_type, holder)
+52 -13
View File
@@ -10,14 +10,18 @@ from docsgpt.agents.workflows.cel_evaluator import (
CelEvaluationError,
validate_cel_expression,
)
from docsgpt.api.user import resource_access
from docsgpt.api.user.resource_access import (
AccessDenied,
can_use_ref,
parse_confirmations,
plan_sponsors,
resolve,
resource_states,
sponsor_audience,
sponsor_details,
sponsor_refusal,
visible_ref_ids,
)
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
@@ -127,24 +131,35 @@ def _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]:
return refs
def _node_ref_details(nodes: List[Dict]) -> Dict[str, List[Dict]]:
"""Names of every tool and source the graph's agent nodes reference.
def _node_ref_details(nodes: List[Dict], visible: Optional[Set[str]] = None) -> Dict[str, List[Dict]]:
"""Names of the tools and sources the graph's agent nodes reference.
Looked up by id whoever owns them, so an editor's node pickers can show
(and remove) the owner's private tools and sources.
(and remove) the owner's private tools and sources. Builtin tool ids
always resolve; any other id only when its ``"<type>:<id>"`` key is in
``visible`` (see ``resource_access.visible_ref_ids``), so a node naming
someone else's resource never reveals its name.
Args:
nodes: Nodes in builder shape.
visible: Keys whose names may be read; None allows every id.
Returns:
dict: ``tools`` as ``[{id, name, display_name}]`` and ``sources`` as
``[{id, name}]``, each id once.
"""
from docsgpt.agents.default_tools import is_synthesized_tool_id
from docsgpt.api.user.base import resolve_source_details, resolve_tool_details
tool_ids: List[str] = []
source_ids: List[str] = []
for resource_type, resource_id in _node_refs(nodes):
if (
visible is not None
and not (resource_type == "tool" and is_synthesized_tool_id(resource_id))
and f"{resource_type}:{resource_id.lower()}" not in visible
):
continue
bucket = tool_ids if resource_type == "tool" else source_ids
if resource_id not in bucket:
bucket.append(resource_id)
@@ -162,13 +177,16 @@ def _new_node_ref_denied(
A workflow runs as its owner, so an editor saving the owner's graph must
not reference the owner's private tools or sources: the caller's own
access counts (``use_in_own`` for a tool, ``use`` for a source), not the
owner's. Refs already in the stored graph stay, like an agent's.
owner's. The owner's own saves are checked the same way, so no graph
names a resource its owner never could use. Refs already in the stored
graph stay, like an agent's.
Args:
conn: Open database connection.
previous_nodes: The stored graph's nodes, in builder shape.
previous_nodes: The stored graph's nodes, in builder shape (empty
when creating the workflow).
new_nodes: The nodes being saved.
caller: The editor saving.
caller: The user saving, owner or editor.
Returns:
An :class:`AccessDenied` to return, or None when every new ref is fine.
@@ -629,6 +647,9 @@ class WorkflowList(Resource):
try:
with db_session() as conn:
denied = _new_node_ref_denied(conn, [], nodes_data, user_id)
if denied is not None:
return _denied(denied)
repo = WorkflowsRepository(conn)
workflow = repo.create(user_id, name, description=description)
pg_workflow_id = str(workflow["id"])
@@ -660,9 +681,24 @@ class WorkflowDetail(Resource):
edges = WorkflowEdgesRepository(conn).find_by_version(
pg_workflow_id, graph_version,
)
sponsored = sponsor_details(conn, "workflow", workflow, viewer=user_id)
serialized_nodes = [serialize_node(n) for n in nodes]
ref_details = _node_ref_details(serialized_nodes)
serialized_nodes = [serialize_node(n) for n in nodes]
# Edit-page detail (sponsors, run state, names of node
# resources) only for people who may edit the workflow.
sponsored: list = []
states: list = []
audience = None
visible: Optional[Set[str]] = None
if resource_access.holder_editable_by(conn, "workflow", workflow, user_id):
refs = _node_refs(serialized_nodes)
sponsored = sponsor_details(conn, "workflow", workflow, viewer=user_id)
states = resource_states(conn, "workflow", workflow, refs, user_id)
audience = sponsor_audience(conn, "workflow", workflow, states, sponsored)
visible = visible_ref_ids(conn, "workflow", workflow, refs, user_id)
ref_details = (
_node_ref_details(serialized_nodes, visible)
if visible is not None
else {"tools": [], "sources": []}
)
except Exception as err:
return _workflow_error_response("Failed to fetch workflow", err)
@@ -672,7 +708,9 @@ class WorkflowDetail(Resource):
"nodes": serialized_nodes,
"edges": [serialize_edge(e) for e in edges],
"resource_sponsors": sponsored,
"resource_states": states,
"ref_details": ref_details,
**({"sponsor_audience": audience} if audience is not None else {}),
}
)
@@ -712,10 +750,11 @@ class WorkflowDetail(Resource):
pg_workflow_id, current_graph_version,
)
]
if acting != user_id:
denied = _new_node_ref_denied(conn, previous_nodes, nodes_data, user_id)
if denied is not None:
return _denied(denied)
# Every newly referenced node tool or source must be one the
# caller may use, the owner included.
denied = _new_node_ref_denied(conn, previous_nodes, nodes_data, user_id)
if denied is not None:
return _denied(denied)
# A node tool/source the owner can't use runs as the editor
# who attached it (its sponsor): only someone who owns or
# edits it, and only once ``confirm_sponsor`` lists it.