fischer-agentkit/docs/solutions/architecture-patterns/bitable-agent-tool-parity-p...

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
Extending an agent tool with new actions that mirror existing REST endpoints (agent parity)
Registering actions in a tool that uses both a handlers dict and a JSON schema enum (dual sync contract)
Designing ownership checks for DELETE endpoints where resource existence itself is sensitive (404-before-403)
Protecting last-of-kind resources from accidental deletion (409 last-item guard)
Bridging a Vue 3 frontend and a Python agent tool over the same domain schema (Pydantic v2 + TypeScript Literal)
agent-parity
tool-registration
ownership-check
idor
last-view-protection
pydantic-v2
agent-native-contract
bitable

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:

  1. Dual-sync action registration — handlers dict and input_schema.action.enum must be updated together
  2. 404-before-403 ownership check — for DELETE endpoints where resource existence itself is sensitive
  3. 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:

  1. Add the action name to INPUT_SCHEMA["properties"]["action"]["enum"]
  2. Add the handler method _new_action to the tool class
  3. Register the handler in self.handlers dict
  4. Update the two contract tests (test_action_enum_has_N_actions and test_execute_handlers_dict_has_N_actions) with the new count
  5. 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_views count and the delete_view call are not atomic. Two concurrent DELETEs for the last two views can both pass the len(siblings) <= 1 check 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) or SELECT ... FOR UPDATE transaction. Acceptable for v1 single-user target; flagged as P2 follow-up.
  • _update_field silent type drop: the tool forwards type as field_type but UpdateFieldRequest (Pydantic, extra=ignore) silently drops it. Agents receive 200/204 for a type change that did not persist. Fix: extend UpdateFieldRequest to accept field_type, or return 400/422 when type is 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.pydelete_view with 404-before-403 and 409 last-view guard
  • tests/unit/bitable/test_bitable_tool.py — dual-sync contract tests
  • tests/unit/bitable/test_routes.py — DELETE /views 204/404/409/IDOR/token coverage