13 KiB
| title | date | category | module | problem_type | component | severity | applies_when | tags | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Bitable Agent Tool Parity: Action Registration and Ownership Patterns | 2026-07-04 | docs/solutions/architecture-patterns/ | bitable | architecture_pattern | agent_tool | medium |
|
|
Bitable Agent Tool Parity: Action Registration and Ownership Patterns
Context
The Bitable companion service ships 28 REST endpoints, but the BitableTool (the interface agents call during ReAct loops) initially exposed only 6 actions. This gap creates an agent orphan condition: agents cannot reach existing backend capabilities without falling back to raw HTTP, which breaks the tool-use contract (structured input/output, schema validation, evolution hooks).
Closing this gap (U6 / R15a) introduced three architecture patterns worth recording for the next agent-tool extension or reviewer:
- Dual-sync action registration — handlers dict and
input_schema.action.enummust be updated together - 404-before-403 ownership check — for DELETE endpoints where resource existence itself is sensitive
- 409 last-view protection — preventing deletion of the last view on a table
These patterns are not framework defaults; they are project-specific contracts that surfaced during ce-code-review and would otherwise be re-derived from first principles each time.
Guidance
1. Dual-Sync Action Registration (KTD10)
When a tool exposes its action list via both a runtime handlers dict and a static JSON schema enum, the two sources of truth must stay in lockstep. A new handler without a matching enum entry is unreachable by the LLM (the schema rejects it); a new enum entry without a matching handler raises at runtime.
The contract. input_schema.properties.action.enum and handlers dict keys must contain the exact same action set. The enum is the LLM's menu; the handlers dict is the runtime dispatch. Neither is derivable from the other.
# src/agentkit/tools/bitable_tool.py
# 1. Static schema enum — the LLM sees this
INPUT_SCHEMA = {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create_table", "import_excel", "import_database", "collect_api",
"upsert_records", "query_records",
"create_view", "update_view", "update_field", "delete_view", # U6 additions
],
},
# ... other fields
},
}
# 2. Runtime dispatch — the executor sees this
self.handlers = {
"create_table": self._create_table,
"import_excel": self._import_excel,
"import_database": self._import_database,
"collect_api": self._collect_api,
"upsert_records": self._upsert_records,
"query_records": self._query_records,
"create_view": self._create_view,
"update_view": self._update_view,
"update_field": self._update_field,
"delete_view": self._delete_view,
}
# 3. Assert the contract at construction — fail loud, not silent
assert len(INPUT_SCHEMA["properties"]["action"]["enum"]) == len(self.handlers), \
"action enum and handlers dict drifted"
Test contract. Two tests pin the contract so a future addition that updates one source but not the other fails CI:
# tests/unit/bitable/test_bitable_tool.py
def test_action_enum_has_10_actions():
actions = BitableTool.INPUT_SCHEMA["properties"]["action"]["enum"]
assert len(actions) == 10
assert "delete_view" in actions
def test_execute_handlers_dict_has_10_actions():
tool = BitableTool(...)
assert len(tool.handlers) == 10
assert "delete_view" in tool.handlers
Why dual-sync instead of single source. The schema is a static JSON document consumed by the LLM at planning time; the handlers dict is a runtime dispatch table. They live in different layers (data vs. code) and cannot share a single declaration without a metaprogramming layer. The assertion + test pair is the smallest reliable guard.
2. 404-Before-403 Ownership Check (KTD9)
For DELETE endpoints where the mere existence of a resource is sensitive (e.g., a view name might leak a competitor's table structure), return 404 Not Found when the caller does not own the resource — not 403 Forbidden. A 403 confirms the resource exists, which is itself an information disclosure.
The pattern. Ownership check happens after existence lookup; if the caller is not the owner, treat it as if the resource does not exist:
# src/agentkit/bitable/service.py
async def delete_view(self, view_id: str, user: dict, internal_token: str | None) -> bool:
view = await self._repo.get_view(view_id)
if view is None:
raise HTTPException(404, "view not found")
# Ownership: internal token bypasses (service-to-service), users must own
if not internal_token and view.owner_id != user["sub"]:
# NOT 403 — return 404 to avoid confirming existence
raise HTTPException(404, "view not found")
# Last-view protection (see pattern 3)
siblings = await self._repo.list_views(view.table_id)
if len(siblings) <= 1:
raise LastViewDeletionError("cannot delete the last view on a table")
return await self._repo.delete_view(view_id)
Why 404, not 403. A 403 response leaks that the resource exists but the caller lacks permission. For endpoints that mutate or delete, this leak enables enumeration attacks (an attacker can map the resource space by sending DELETEs and watching for 403 vs 404). The 404-before-403 pattern collapses "not yours" and "does not exist" into the same response, denying the attacker the existence signal.
Internal token bypass. Service-to-service callers (e.g., the agent runtime calling BitableTool) authenticate via X-Internal-Token verified with hmac.compare_digest. Internal token holders bypass the ownership check because they operate with elevated privileges — but they never bypass validation or the last-view guard.
3. 409 Last-View Protection
A table without any views is structurally invalid (no way to render or interact with records). Deleting the last view on a table returns 409 Conflict, not 200, to prevent the system from entering an unrecoverable state.
# src/agentkit/bitable/service.py
class LastViewDeletionError(HTTPException):
def __init__(self, detail: str):
super().__init__(status_code=409, detail=detail)
# In delete_view:
if len(siblings) <= 1:
raise LastViewDeletionError("cannot delete the last view on a table")
Why 409, not 400. 409 Conflict signals "the request conflicts with the current state of the resource" — the resource exists and the caller has permission, but the operation would leave the system in an invalid state. 400 would imply the request itself was malformed, which it was not.
Frontend contract. The frontend store's deleteView action must handle 409 distinctly from 404 — a 409 warrants a warning toast ("cannot delete the last view"), while a 404 warrants a refresh ("this view no longer exists"):
// src/agentkit/server/frontend/src/stores/bitable.ts
async deleteView(viewId: string) {
try {
await api.deleteView(viewId);
// ... remove from local state
} catch (e) {
if (e.response?.status === 409) {
message.warning("无法删除最后一个视图");
} else if (e.response?.status === 404) {
// already gone — refresh
await this.fetchViews();
}
}
}
Why This Matters
These three patterns close the agent parity gap without weakening the security posture that the companion service established (see bitable-companion-service-security-reliability-patterns.md for the underlying IDOR/SSRF/SQL-injection baseline).
- Dual-sync registration ensures agents can actually reach new actions. Without the assertion + test pair, a schema/handler drift silently makes new actions unreachable or runtime-crashes — both failure modes are hard to detect in CI because the LLM never calls the missing action during unit tests.
- 404-before-403 prevents resource enumeration via DELETE endpoints. The agent parity work exposed new mutating endpoints (delete_view, update_field); applying this pattern from the start avoids a security regression that would otherwise surface in a later audit.
- 409 last-view protection preserves structural invariants. A table with zero views is a data integrity issue that the schema layer cannot enforce (views are a presentation layer over records); the service layer must.
When to Apply
- Dual-sync registration: any tool that exposes
action(or equivalent) via both a JSON schema enum and a runtime dispatch table. If the tool has only one source of truth (e.g., handlers inferred from schema at runtime), this pattern does not apply. - 404-before-403: any DELETE or mutation endpoint where resource existence is sensitive. For endpoints where existence is already public (e.g., a public profile view), 403 is acceptable.
- 409 last-item guard: any "last of kind" resource where deletion would leave the parent in an invalid state. Examples: last view on a table, last column on a board, last admin in an org.
Examples
Adding a new action (the dual-sync checklist)
When extending BitableTool with a new action, follow this exact sequence:
- Add the action name to
INPUT_SCHEMA["properties"]["action"]["enum"] - Add the handler method
_new_actionto the tool class - Register the handler in
self.handlersdict - Update the two contract tests (
test_action_enum_has_N_actionsandtest_execute_handlers_dict_has_N_actions) with the new count - Add a focused test for the new handler's happy path and one error path
Skipping step 1 makes the action unreachable (LLM never calls it). Skipping step 2 or 3 raises KeyError at runtime. Skipping step 4 lets a future regression slip through CI.
Ownership check with internal token bypass
async def delete_view(self, view_id: str, user: dict, internal_token: str | None) -> bool:
view = await self._repo.get_view(view_id)
if view is None:
raise HTTPException(404, "view not found")
if not internal_token and view.owner_id != user["sub"]:
raise HTTPException(404, "view not found") # 404, not 403
# ... last-view guard + delete
The internal_token check uses hmac.compare_digest at the route layer (not shown) before reaching the service. The service trusts the route's token verification and only checks whether to apply the ownership bypass.
Known Limitations
- TOCTOU race in delete_view: the
list_viewscount and thedelete_viewcall are not atomic. Two concurrent DELETEs for the last two views can both pass thelen(siblings) <= 1check before either commits, leaving the table with zero views. Fix: atomic conditional DELETE (DELETE ... WHERE table_id=:t AND (SELECT COUNT(*) FROM views WHERE table_id=:t) > 1 RETURNING id) orSELECT ... FOR UPDATEtransaction. Acceptable for v1 single-user target; flagged as P2 follow-up. _update_fieldsilent type drop: the tool forwardstypeasfield_typebutUpdateFieldRequest(Pydantic, extra=ignore) silently drops it. Agents receive 200/204 for a type change that did not persist. Fix: extendUpdateFieldRequestto acceptfield_type, or return 400/422 whentypeis set but unsupported. Flagged as P2 follow-up.
Cross-References
- bitable-companion-service-security-reliability-patterns.md — underlying IDOR, SSRF, SQL-injection, and internal-token patterns for the Bitable companion service
docs/plans/2026-07-03-001-feat-bitable-p0-ux-and-agent-parity-plan.md— U6 implementation plan (R15a)src/agentkit/tools/bitable_tool.py— dual-sync contract (lines 57-68 enum, lines 175-186 handlers)src/agentkit/bitable/service.py—delete_viewwith 404-before-403 and 409 last-view guardtests/unit/bitable/test_bitable_tool.py— dual-sync contract teststests/unit/bitable/test_routes.py— DELETE /views 204/404/409/IDOR/token coverage