Skip to content

[SILO-1466] feat(v2): variant F surface — flat path, loaded rows, typed filters - #70

Open
Prashant-Surya wants to merge 95 commits into
mainfrom
feat/silo-1466-python-sdk-v2
Open

[SILO-1466] feat(v2): variant F surface — flat path, loaded rows, typed filters#70
Prashant-Surya wants to merge 95 commits into
mainfrom
feat/silo-1466-python-sdk-v2

Conversation

@Prashant-Surya

@Prashant-Surya Prashant-Surya commented Aug 30, 2026

Copy link
Copy Markdown
Member

What this is

The v2 surface on variant F — the design approved by the team. Two ways in, and they compose:

# 1. Flat path — each segment consumes exactly one URL path id
client.v2.workspaces.projects.states.list("acme", "ENG")

# 2. Loaded rows — a fetched row carries its ids; nothing repeats
project = client.v2.workspaces.projects.retrieve("acme", "ENG")
project.cycles.list()
project.work_items.create(CreateWorkItem(name="Ship it"))

The premise: there is no such thing as an object without data. A retrieved row is both data and a place to navigate from.

Scope

  • 90 resource classes, all migrated, wired and reachable from client.v2
  • 407/407 operations verified against the API golden
  • 19 navigable row types; a fetched workspace reaches 25 children, a project 19
  • Path ids named after their resource, singular, no _id suffix — retrieve(slug, project, state)
  • Generated Literal aliases and TypedDict filters, so **filters is typed; py.typed ships (PEP 561)

Field projection raises rather than lying

Reading a field that fields= excluded raises FieldNotRequested, naming what the server actually returned. Presence comes from the response (model_fields_set), not from the request — a server that omits a field you asked for is still absent.

This differs deliberately from the Node SDK, where the same mistake is a compile-time type error. Each language uses its strongest available check.

Four enforcement sweeps

Most of this migration ran in large batches without per-task review; these sweeps were the compensating control. Each enumerates every resource class rather than selecting by shape — the recurring defect in this work was a check that selected its own subject set and silently skipped members. Path-id naming, expand exposure, fields exposure, and navigation completeness, plus pagination and singleton-verb coverage. Every one was proved by introducing the violation it targets and confirming it fails by name.

Verified against a live server

323 passing integration tests against a running Plane instance. Zero SDK defects. The remaining failures all trace to one operation — workspaces_retrieve — that the test instance's API predates; the other 406 pass.

The integration suite previously skipped silently in its entirety — 385 tests reporting green while testing nothing. It now carries a guard with one sanctioned dormant reason, decided at collection time: any other all-skip state, or a fixture swallowing an error into a skip, fails the session. An offline half runs mypy over the live suites, giving Python the compile check it otherwise lacks.

Unit: 997 passing. mypy plane/api/v2 clean.

Known follow-up (not in this PR)

FieldNotRequested protects resources that have children, because only those return a Loaded row. Resources without children — states, labels — still return None for an unrequested field, which is the ambiguity this design set out to remove. Verified live. Making presence tracking uniform across every v2 read model is a design change worth deciding separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs

…operations

`client.v2` exposes every api_v2 operation through a single chained form rooted at
the workspace, mirroring the API's own scope tree:

    ws = client.v2.workspace("acme")          # zero-I/O locator
    proj = ws.project("ENG")                  # key or UUID
    proj.work_items.create(WorkItemWrite(name="Fix login bug", state="Todo"))
    ws.work_items.retrieve_by_identifier("ENG-12")
    ws.wiki.pages.create(PageWrite(name="Runbook"))   # public page -> default collection
    client.v2.users.me()                      # the six non-workspace operations

- Kernel: transport with RFC 9457 errors, offset/cursor envelopes with a stall
  guard, ?fields/?expand/?order_by validated per operation against the golden,
  upsert, bulk create/update/delete with per-row results, find_by_name, custom
  verb actions, scope-bound resources (`V2Resource(transport, **scope)`).
- Spec-generated constants (`scripts/generate_v2_constants.py`) for all 406
  operations; every implemented operation is declared in exactly one resource's
  `operations` map and a two-way coverage test enforces 406/406.
- Method set is identical to @makeplane/plane-node-sdk (snake_case vs camelCase).
- Offline tests under tests/v2 (responses); live tests under tests/v2/integration
  skip without PLANE_BASE_URL/PLANE_API_KEY/WORKSPACE_SLUG.
- CI: .github/workflows/test.yml runs the offline suite; a secret-gated
  `v2-golden-drift` job regenerates the constants against plane-ee's golden.
- Version 0.3.0. v1 surface untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015XXZ9CT96T1dZoiSYmtiNe
@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 298 files, which is 198 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 95f150eb-188d-42bc-9f4e-43a8c7e6bae0

📥 Commits

Reviewing files that changed from the base of the PR and between 4fdaded and 5661f95.

⛔ Files ignored due to path filters (2)
  • plane/api/v2/_generated/__init__.py is excluded by !**/_generated/**
  • plane/api/v2/_generated/constants.py is excluded by !**/_generated/**
📒 Files selected for processing (298)
  • .github/workflows/test.yml
  • .gitignore
  • CLAUDE.md
  • README.md
  • plane/__init__.py
  • plane/api/v2/__init__.py
  • plane/api/v2/_kernel/__init__.py
  • plane/api/v2/_kernel/errors.py
  • plane/api/v2/_kernel/loaded.py
  • plane/api/v2/_kernel/pagination.py
  • plane/api/v2/_kernel/resource.py
  • plane/api/v2/_kernel/transport.py
  • plane/api/v2/_loaded/__init__.py
  • plane/api/v2/_loaded/automation.py
  • plane/api/v2/_loaded/collection.py
  • plane/api/v2/_loaded/customer.py
  • plane/api/v2/_loaded/cycle.py
  • plane/api/v2/_loaded/estimate.py
  • plane/api/v2/_loaded/initiative.py
  • plane/api/v2/_loaded/milestone.py
  • plane/api/v2/_loaded/module.py
  • plane/api/v2/_loaded/project.py
  • plane/api/v2/_loaded/release.py
  • plane/api/v2/_loaded/webhook.py
  • plane/api/v2/_loaded/work_item.py
  • plane/api/v2/_loaded/work_item_property.py
  • plane/api/v2/_loaded/work_item_type.py
  • plane/api/v2/_loaded/workflow.py
  • plane/api/v2/_loaded/workspace.py
  • plane/api/v2/artifacts.py
  • plane/api/v2/assets.py
  • plane/api/v2/audit_logs.py
  • plane/api/v2/automations/__init__.py
  • plane/api/v2/automations/activities.py
  • plane/api/v2/automations/edges.py
  • plane/api/v2/automations/nodes.py
  • plane/api/v2/collections/__init__.py
  • plane/api/v2/collections/members.py
  • plane/api/v2/collections/pages.py
  • plane/api/v2/customer_properties.py
  • plane/api/v2/customers/__init__.py
  • plane/api/v2/customers/customers.py
  • plane/api/v2/customers/property_values.py
  • plane/api/v2/customers/requests.py
  • plane/api/v2/customers/work_items.py
  • plane/api/v2/cycles.py
  • plane/api/v2/estimates/__init__.py
  • plane/api/v2/estimates/points.py
  • plane/api/v2/features.py
  • plane/api/v2/group_sync/__init__.py
  • plane/api/v2/group_sync/config.py
  • plane/api/v2/group_sync/project_mappings.py
  • plane/api/v2/group_sync/workspace_mappings.py
  • plane/api/v2/initiatives/__init__.py
  • plane/api/v2/initiatives/initiatives.py
  • plane/api/v2/initiatives/labels.py
  • plane/api/v2/initiatives/projects.py
  • plane/api/v2/initiatives/work_items.py
  • plane/api/v2/intakes.py
  • plane/api/v2/invitations.py
  • plane/api/v2/labels.py
  • plane/api/v2/members.py
  • plane/api/v2/milestones.py
  • plane/api/v2/modules.py
  • plane/api/v2/pages.py
  • plane/api/v2/permission_schemes.py
  • plane/api/v2/permissions.py
  • plane/api/v2/projects.py
  • plane/api/v2/releases/__init__.py
  • plane/api/v2/releases/changelog.py
  • plane/api/v2/releases/comments.py
  • plane/api/v2/releases/labels.py
  • plane/api/v2/releases/links.py
  • plane/api/v2/releases/tags.py
  • plane/api/v2/releases/work_items.py
  • plane/api/v2/roles.py
  • plane/api/v2/states.py
  • plane/api/v2/stickies.py
  • plane/api/v2/teamspaces.py
  • plane/api/v2/users.py
  • plane/api/v2/views/__init__.py
  • plane/api/v2/views/project.py
  • plane/api/v2/views/workspace.py
  • plane/api/v2/webhook_logs.py
  • plane/api/v2/webhooks.py
  • plane/api/v2/wiki_node.py
  • plane/api/v2/work_item_properties/__init__.py
  • plane/api/v2/work_item_properties/contexts.py
  • plane/api/v2/work_item_properties/options.py
  • plane/api/v2/work_item_properties/workspace_options.py
  • plane/api/v2/work_item_relation_definitions.py
  • plane/api/v2/work_item_templates/__init__.py
  • plane/api/v2/work_item_templates/project.py
  • plane/api/v2/work_item_templates/workspace.py
  • plane/api/v2/work_item_types/__init__.py
  • plane/api/v2/work_item_types/properties.py
  • plane/api/v2/work_items/__init__.py
  • plane/api/v2/work_items/activities.py
  • plane/api/v2/work_items/attachments.py
  • plane/api/v2/work_items/comments.py
  • plane/api/v2/work_items/dependencies.py
  • plane/api/v2/work_items/links.py
  • plane/api/v2/work_items/relations.py
  • plane/api/v2/work_items/worklogs.py
  • plane/api/v2/work_items/workspace.py
  • plane/api/v2/workflows/__init__.py
  • plane/api/v2/workflows/states.py
  • plane/api/v2/workflows/transitions.py
  • plane/api/v2/workflows/workflows.py
  • plane/api/v2/worklogs.py
  • plane/api/v2/workspaces.py
  • plane/client/plane_client.py
  • plane/config.py
  • plane/models/v2/__init__.py
  • plane/models/v2/artifacts.py
  • plane/models/v2/assets.py
  • plane/models/v2/audit_logs.py
  • plane/models/v2/automations.py
  • plane/models/v2/collections.py
  • plane/models/v2/common.py
  • plane/models/v2/customer_properties.py
  • plane/models/v2/customers.py
  • plane/models/v2/cycle_actions.py
  • plane/models/v2/cycles.py
  • plane/models/v2/estimates.py
  • plane/models/v2/features.py
  • plane/models/v2/group_sync.py
  • plane/models/v2/initiatives.py
  • plane/models/v2/intakes.py
  • plane/models/v2/invitations.py
  • plane/models/v2/labels.py
  • plane/models/v2/members.py
  • plane/models/v2/milestone_work_items.py
  • plane/models/v2/milestones.py
  • plane/models/v2/module_work_items.py
  • plane/models/v2/modules.py
  • plane/models/v2/pages.py
  • plane/models/v2/permission_schemes.py
  • plane/models/v2/permissions.py
  • plane/models/v2/project_role_distribution.py
  • plane/models/v2/projects.py
  • plane/models/v2/releases.py
  • plane/models/v2/roles.py
  • plane/models/v2/states.py
  • plane/models/v2/stickies.py
  • plane/models/v2/teamspaces.py
  • plane/models/v2/users.py
  • plane/models/v2/views.py
  • plane/models/v2/webhook_logs.py
  • plane/models/v2/webhooks.py
  • plane/models/v2/work_item_properties.py
  • plane/models/v2/work_item_relation_definitions.py
  • plane/models/v2/work_item_templates.py
  • plane/models/v2/work_item_types.py
  • plane/models/v2/work_items.py
  • plane/models/v2/workflows.py
  • plane/models/v2/worklogs_summary.py
  • plane/models/v2/workspaces.py
  • plane/py.typed
  • pyproject.toml
  • scripts/generate_v2_constants.py
  • tests/scripts/test_generate_v2_constants.py
  • tests/v2/__init__.py
  • tests/v2/conftest.py
  • tests/v2/fixtures/__init__.py
  • tests/v2/fixtures/_hidden/__init__.py
  • tests/v2/fixtures/_hidden/resource.py
  • tests/v2/fixtures/nested/__init__.py
  • tests/v2/fixtures/nested/deep/__init__.py
  • tests/v2/fixtures/nested/deep/resource.py
  • tests/v2/integration/__init__.py
  • tests/v2/integration/_guard.py
  • tests/v2/integration/conftest.py
  • tests/v2/integration/helpers.py
  • tests/v2/integration/test_artifacts.py
  • tests/v2/integration/test_assets.py
  • tests/v2/integration/test_audit_logs.py
  • tests/v2/integration/test_automations.py
  • tests/v2/integration/test_bulk.py
  • tests/v2/integration/test_collections.py
  • tests/v2/integration/test_crud.py
  • tests/v2/integration/test_customer_properties.py
  • tests/v2/integration/test_customers.py
  • tests/v2/integration/test_cycle_actions.py
  • tests/v2/integration/test_errors.py
  • tests/v2/integration/test_estimates.py
  • tests/v2/integration/test_features.py
  • tests/v2/integration/test_find_one.py
  • tests/v2/integration/test_full_scenario.py
  • tests/v2/integration/test_group_sync.py
  • tests/v2/integration/test_initiatives.py
  • tests/v2/integration/test_intakes.py
  • tests/v2/integration/test_invitations.py
  • tests/v2/integration/test_members.py
  • tests/v2/integration/test_milestone_work_items.py
  • tests/v2/integration/test_module_work_items.py
  • tests/v2/integration/test_pages.py
  • tests/v2/integration/test_pagination.py
  • tests/v2/integration/test_permission_schemes.py
  • tests/v2/integration/test_permissions.py
  • tests/v2/integration/test_project_work_item_types_flow.py
  • tests/v2/integration/test_projects.py
  • tests/v2/integration/test_releases.py
  • tests/v2/integration/test_roles.py
  • tests/v2/integration/test_scope_parity.py
  • tests/v2/integration/test_stickies.py
  • tests/v2/integration/test_teamspaces.py
  • tests/v2/integration/test_upsert.py
  • tests/v2/integration/test_users.py
  • tests/v2/integration/test_views.py
  • tests/v2/integration/test_webhook_logs.py
  • tests/v2/integration/test_webhooks.py
  • tests/v2/integration/test_work_item_properties.py
  • tests/v2/integration/test_work_item_relation_definitions.py
  • tests/v2/integration/test_work_item_sub_resources.py
  • tests/v2/integration/test_work_item_templates.py
  • tests/v2/integration/test_work_item_types.py
  • tests/v2/integration/test_work_items.py
  • tests/v2/integration/test_workflows.py
  • tests/v2/integration/test_worklogs_summary.py
  • tests/v2/integration/test_workspace_work_item_types_flow.py
  • tests/v2/integration/test_workspace_work_items.py
  • tests/v2/test_artifacts_resource.py
  • tests/v2/test_assets_resource.py
  • tests/v2/test_audit_logs_resource.py
  • tests/v2/test_automations_resource.py
  • tests/v2/test_bulk.py
  • tests/v2/test_collections_resource.py
  • tests/v2/test_customer_properties_resource.py
  • tests/v2/test_customers_resource.py
  • tests/v2/test_cycles_resource.py
  • tests/v2/test_errors.py
  • tests/v2/test_estimates_resource.py
  • tests/v2/test_expand_coverage.py
  • tests/v2/test_features_resource.py
  • tests/v2/test_fields_coverage.py
  • tests/v2/test_find_one.py
  • tests/v2/test_generated_constants.py
  • tests/v2/test_group_sync_resource.py
  • tests/v2/test_initiatives_resource.py
  • tests/v2/test_intakes_resource.py
  • tests/v2/test_integration_guard.py
  • tests/v2/test_integration_surface.py
  • tests/v2/test_invitations_resource.py
  • tests/v2/test_iterate_parity.py
  • tests/v2/test_kernel_resource.py
  • tests/v2/test_labels_resource.py
  • tests/v2/test_live_smoke.py
  • tests/v2/test_loaded.py
  • tests/v2/test_loaded_automations.py
  • tests/v2/test_loaded_collections_customers.py
  • tests/v2/test_loaded_exports.py
  • tests/v2/test_loaded_families.py
  • tests/v2/test_loaded_navigation.py
  • tests/v2/test_loaded_project.py
  • tests/v2/test_loaded_release_initiative.py
  • tests/v2/test_loaded_work_item_properties.py
  • tests/v2/test_loaded_work_item_types.py
  • tests/v2/test_loaded_workflows.py
  • tests/v2/test_loaded_workspace.py
  • tests/v2/test_members_resource.py
  • tests/v2/test_milestones_resource.py
  • tests/v2/test_modules_resource.py
  • tests/v2/test_operations_coverage.py
  • tests/v2/test_owned_sub_resources.py
  • tests/v2/test_packaging.py
  • tests/v2/test_pages_resource.py
  • tests/v2/test_pagination.py
  • tests/v2/test_pagination_coverage.py
  • tests/v2/test_path_id_naming.py
  • tests/v2/test_permission_schemes_resource.py
  • tests/v2/test_permissions_resource.py
  • tests/v2/test_projects_resource.py
  • tests/v2/test_release_tags_resource.py
  • tests/v2/test_releases_resource.py
  • tests/v2/test_resource.py
  • tests/v2/test_roles_resource.py
  • tests/v2/test_shapes.py
  • tests/v2/test_singleton_verbs.py
  • tests/v2/test_states_resource.py
  • tests/v2/test_stickies_resource.py
  • tests/v2/test_teamspaces_resource.py
  • tests/v2/test_transport.py
  • tests/v2/test_tree.py
  • tests/v2/test_typing.py
  • tests/v2/test_users_resource.py
  • tests/v2/test_views_resource.py
  • tests/v2/test_webhook_logs_resource.py
  • tests/v2/test_webhooks_resource.py
  • tests/v2/test_work_item_properties_resource.py
  • tests/v2/test_work_item_relation_definitions_resource.py
  • tests/v2/test_work_item_templates_resource.py
  • tests/v2/test_work_item_types_resource.py
  • tests/v2/test_work_items_resource.py
  • tests/v2/test_workflows_resource.py
  • tests/v2/test_worklogs_summary_resource.py
  • tests/v2/test_workspace_work_items_resource.py
  • tests/v2/tree_walk.py

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@makeplane

makeplane Bot commented Aug 30, 2026

Copy link
Copy Markdown

Linked to Plane Work Item(s)

This comment was auto-generated by Plane

…ookups (review feedback)

Review feedback on the v2 surface (runs/sdk-v2-foundation/plans/2026-09-03-team-feedback.md,
items 1, 2 and the SDK-now part of 5).

Renames (work item type properties, project + workspace scoped):
- work_item_types.properties.attach(type_id, property_ids) -> link(type_id, property_ids)
- work_item_types.properties.detach(type_id, property_id)  -> unlink(type_id, property_id)
  (operations keys stay attach/detach; unlink docstring carries the web app warning)

Removed (manage verbs) -> replaced by bridge sub-resources with add/remove:
- cycles.manage_work_items        -> cycles.work_items.add/remove
- modules.manage_work_items       -> modules.work_items.add/remove
- milestones.manage_work_items    -> milestones.work_items.add/remove
- customers.manage_work_items     -> customers.work_items.add/remove
- releases.manage_work_items      -> releases.work_items.add/remove
- releases.manage_labels          -> releases.labels.add/remove
- initiatives.manage_work_items   -> initiatives.work_items.add/remove
- initiatives.manage_projects     -> initiatives.projects.add/remove
- initiatives.manage_labels       -> initiatives.labels.add/remove
- wiki.collections.members.manage -> wiki.collections.members.add/remove
- wiki.collections.pages.manage   -> wiki.collections.pages.add/remove
  add POSTs {"add": [...]} and returns `added`; remove POSTs {"remove": [...]} and
  returns `removed`; 0 or >100 ids raise ValueError before any request. One kernel
  helper, V2Resource._bridge(key=, ids=, **path_params), plus `bridge_path` for
  catalog resources whose own path is not the bridge URL. Each golden manage
  operationId moves to its bridge class (406/406 still declared exactly once).
  *Manage* request/response models stay as files but are no longer exported from
  plane.models.v2 (CollectionMemberAdd stays public).

Added lookups (server-side via _find_one, golden filters verified):
- roles.find_by_slug(slug, *, namespace=None)
- estimates.points.find_by_key(estimate_id, key)
- work_item_properties.find_by_name(name) and workspace sibling (name = property key)
- work_item_properties.options.find_by_name(property_id, name), workspace sibling,
  and workspace work_item_properties.contexts.find_by_name(property_id, name)

Tests: offline coverage for every new/renamed method incl. the 0/101-id guard and
exact JSON body per verb; all call sites converted, integration suite still
all-skip without env. README + CLAUDE.md v2 sections updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
@Prashant-Surya

Copy link
Copy Markdown
Member Author

Review feedback landed as one commit on top (a57823e), so the delta is reviewable on its own. Public method tree stays identical to the Node SDK (513 = 513 after normalisation).

Feedback What changed
attach/detachlink/unlink (work item type properties, matches the web app's "Unlink property") work_item_types.properties.link(type_id, ids) / .unlink(type_id, id) in both scopes
manage_work_itemsx.y.work_items.add / remove Every manage_* is gone. Bridges are sub-resources: proj.cycles.work_items.add(cycle_id, ids) / .remove(...), same for modules, milestones, customers, releases, initiatives (.work_items, .projects), ws.releases.labels.add(release_id, ids), ws.initiatives.labels.add(...), ws.wiki.collections.pages.add(...), .members.add(...). Each verb sends only its own key; 0 or >100 ids raise ValueError before any request; returns the ids actually changed.
Lookup by slug / name ws.roles.find_by_slug(slug, namespace=...), proj.estimates.points.find_by_key(estimate_id, key), find_by_name on work item properties (both scopes), property options and contexts. name on properties is the machine key (story-points); lookup by the UI label needs a ?display_name= filter, which is on a plane-ee branch pending spec review, and find_by_display_name will follow it.
archive_then_delete Parked. Only pages gate delete on archive, and that reads as an app defect to fix server-side rather than mirror here.
find_by_name + state group Skipped: state names are unique per project/workspace under governance.
Workspace by slug No workspace endpoint exists in v1 or v2 today; GET /api/v2/workspaces/{slug}/ (+ list) is on a plane-ee branch pending spec review, then ws.retrieve() lands here.
Batching chained calls On hold. Chaining is zero-I/O (only the leaf call hits the network); there is no multi-op batch endpoint, and the per-resource bulk_* methods are the batching primitive.

Checks on the new commit: pytest tests/v2 453 passed / 393 skipped, operations coverage 406/406, ruff clean, mypy clean on v2 files. Live suite not re-run (dev API was stopped).

@coldtea-pr-lens

coldtea-pr-lens Bot commented Sep 3, 2026

Copy link
Copy Markdown

◈ PR Lens

🟢 +10 new · 🟠 ~1 changed · 🔴 -0 removed · 2 flows · 27 files · commit 5661f95


Architecture

Architecture diagram for makeplane/plane-python-sdk at 5661f95

11 components touched across 5 lanes.

Open the interactive canvas


Inside the changed components — 2 views

Component view — V2 Kernel & Typed Navigation

The core V2 kernel infrastructure handling HTTP transport, CRUD operations, OpenAPI schema validation, and typed loaded-row navigation.

Architecture view of Component view — V2 Kernel & Typed Navigation in makeplane/plane-python-sdk

Component view — V2 Resource Hierarchy & Domain Routing

The flat attribute-based resource tree routing workspace and project sub-resources to the shared kernel engine.

Architecture view of Component view — V2 Resource Hierarchy & Domain Routing in makeplane/plane-python-sdk

Data flow

Data flow diagram for makeplane/plane-python-sdk at 5661f95

V2 Flat Query & Validation Flow · V2 Loaded Row Navigation Flow

Open the interactive canvas


The other flows — 1 sequence

V2 Loaded Row Navigation Flow

Sequence diagram of V2 Loaded Row Navigation Flow in makeplane/plane-python-sdk

Drill down
SDK Client & Core — 6 components
🟡 CHANGED PlaneClient

SDK entry point exposing authentication configuration, legacy v1 resources, and the new v2 namespace.

🟢 NEW V2 Namespace

Root of the v2 flat API path providing attribute-based access to users, workspaces, and project sub-resources.

🟢 NEW V2 Transport & Errors

Shared HTTP transport managing session pooling, API v2 URL prefixes, auth headers, retry policies, and RFC 9457 error decoding.

🟢 NEW V2 Resource Kernel

Base resource execution engine handling generic CRUD, batching, membership bridges, query validation, and pagination.

🟢 NEW Loaded & Owned Rows

Loaded row mixin and Owned wrapper enabling chained navigation with bound parent IDs and sparse field guards.

🟢 NEW V2 Generated Constants

Generated OpenAPI constants, filter TypedDicts, and Literal field/ordering schemas for compile-time validation.

Workspaces & Projects — 2 components
🟢 NEW V2 Workspaces & Workspace Resources

V2 workspaces and workspace-scoped sub-resources including roles, members, webhooks, stickies, and teamspaces.

🟢 NEW V2 Projects & Planning API

V2 project resources, cycles, milestones, modules, and estimates exposed via flat attributes and loaded project rows.

Work Items & Planning — 1 component
🟢 NEW V2 Work Items & Sub-resources

V2 work items management and sub-resources including comments, attachments, relations, activities, and worklogs.

Workflows & Governance — 1 component
🟢 NEW V2 Workflows & Governance API

V2 project and workspace workflows, states, labels, work item types, properties, and relation definitions.

CRM, Content & Automation — 1 component
🟢 NEW V2 CRM, Content & Automations API

V2 customers, customer properties, collections, initiatives, pages, and automations.


View

  • Architecture lens
  • Data flow lens
  • Expand every detail
  • Show unchanged neighbours

Tip

The diagrams follow your GitHub theme, so dark mode gets the dark render and light mode the light one, and the moving dots show this pull request's data in motion.

🪧 More tips
  • Run PR Lens on your own machine: npx skills add coldteadotai/pr-lens installs the agent skill. Then tell your coding agent: "Diagram the change you just made with PR Lens and attach it to the pull request."
  • Draw a diff before it is even a pull request: npx @coldtea/pr-lens-cli analyze --base origin/main reads the diff with your own model key, and npx @coldtea/pr-lens-cli render .pr-lens/graph.json draws the same lenses on your machine.
  • The boxes under View are live. Tick Architecture lens or Data flow lens to choose which diagrams appear, or Expand every detail to open every drill-down at once. The comment redraws in place a few seconds later.
  • Show unchanged neighbours lists the components this change did not touch alongside the ones it did, so the drill-down shows what the changed code sits next to.
  • GitHub will not let you zoom an image in a comment. The link under each diagram opens it on an interactive canvas, where you can zoom, pan and step through the flow.
  • The CLI's render picks up .github/pr-lens.yml automatically and applies your corrections (renames, exclusions, lane pins) at draw time.
  • Would you rather run it from CI on a key of your own? Add .github/workflows/pr-lens.yml with coldteadotai/pr-lens/packages/action@v0 and a model key in your repository secrets, say GEMINI_API_KEY. The Action asks Gemini by default, or OpenAI and any endpoint speaking /chat/completions through its provider input.
  • PR Lens is free for open source. A star on the repository is what keeps it going.
  • Push a new commit and the whole comment re-renders for the new head. An older run never overwrites a newer one, so a slow render cannot put a stale diagram back.

◈ Rendered by PR Lens · crafted with ❤️ by the Coldtea team · Come say hi on Discord

Prashant-Surya and others added 24 commits September 7, 2026 21:43
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…perations

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…name filter

The workspace-discovery worktree used previously was cut before the
display_name query parameter shipped on the property list operations,
so it silently dropped display_name from WorkItemPropertiesListFilters,
WorkspaceWorkItemPropertiesListFilters and CustomerPropertiesListFilters.
Regenerate from the stable origin/preview checkout instead (still 407
operations), and add a regression test that reads the committed
constants.py directly and pins display_name's presence, so a future
regeneration from a stale golden fails loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
A setup-time append landed on a file with no trailing newline, producing the
single broken entry "test.py.superpowers/" — which ignored neither path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…lly run

The 31 array-typed query parameters (assignee_id__in, priority__in,
state_group__in and friends) were emitted as bare str, which is exactly the
wrong type for the multi-value filters typed kwargs exist to help with. The
generator now derives the element type from schema.items.type and emits
Sequence[...]; the kernel already comma-joins sequences, matching the
goldens style: form, explode: false.

The display_name regression test lived in tests/scripts, which addopts
excludes from every default run, and running that file regenerated the real
constants.py from a one-operation fixture — corrupting the working tree. Real
assertions now live in tests/v2/test_generated_constants.py against the
imported module, and the fixture-based generator tests no longer touch the
committed file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
V2Resource.__init__ now takes only the transport; path parameters are
supplied per call as **path_params to _collection_url/_detail_url,
never bound at construction. _format_path fills the template solely
from the call's path_params (format_map, so a missing key raises a
clear KeyError).

Expected collateral damage: ~80 resource classes still construct with
bound scope and call self._scope, so the wider v2 suite goes red.
Later tasks re-author those classes; this task only touches the
kernel. tests/v2/test_kernel_resource.py is the only test file
required to pass here, and does (3 passed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Deviates from the task-6 brief: path ids are stored on Loaded._ids as a
tuple (URL order) rather than a dict, and Owned prepends them
positionally to child-resource methods instead of passing them as
keyword arguments. Every kernel resource takes its path ids as leading
positional-or-keyword parameters, so keyword-passing would collide with
a caller's own positional argument for the same parameter (e.g.
project.work_items.retrieve("ENG-12") sending "ENG-12" positionally
into `slug` while `slug=` also arrived as a keyword).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…_path at definition

Fix round 1 of 5 on tasks 5+6, addressing four review findings:

1. Add test coverage for Owned (positional prepend ahead of caller args,
   zero-arg calls, keyword passthrough, non-callable attributes pass
   through unwrapped).
2. Owned now also carries id *names* alongside the ids tuple; before
   dispatching a call it checks (via inspect.signature) that the
   resolved method's leading parameters are named exactly as expected,
   raising TypeError on mismatch instead of silently sending ids into
   the wrong parameters. Loaded.build gained a matching optional
   `names` param, recorded on `_id_names`.
3. Owned's `bound` closure is now functools.wraps(attribute)-decorated.
4. V2Resource.__init_subclass__ now raises TypeError if a subclass
   still declares the retired `bridge_path`, naming `extra_paths` as
   the replacement.

Finding 4's guard fires at class-body execution (import time), and
plane/__init__.py eagerly imports the full plane.api.v2 tree, so the
guard alone made `import plane` -- and therefore the entire test
suite, including the two files this round's review asked to verify
green -- fail to collect (three resources still declare bridge_path:
collections/pages.py, initiatives/labels.py, releases/labels.py).
Added narrowly-scoped try/except TypeError shims around the exact
import lines that compose each of those three classes, in
collections/__init__.py, initiatives/initiatives.py, and
releases/__init__.py (not the three protected resource files
themselves), binding the name to None with a comment explaining this
is transitional pending the later task that migrates them to
extra_paths. Also translated a throwaway bridge_path-declaring test
class in tests/v2/test_resource.py (an already fully Task-4-broken
file) to extra_paths, since it alone crashed collection of the whole
suite. See task-5-6-report.md, "Fix round 1 of 5", for full detail and
an explicit flag to the coordinator that this shim work was not part
of the four findings as written.

Verification:
  pytest tests/v2/test_loaded.py tests/v2/test_kernel_resource.py -p no:warnings
    -> 16 passed
  pytest tests -p no:warnings
    -> 15 failed, 169 passed, 685 skipped, 375 errors
       (was 11 failed, 167 passed, 685 skipped, 375 errors; +4 failed/-4
       passed is test_operations_coverage.py hitting the guard directly
       on the three still-broken modules, +6 passed is this round's new
       Owned/guard tests -- fully accounted for, no unexplained deltas)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…eclarations to extra_paths

Fix round 2 of 5. Round 1's __init_subclass__ import-time guard was
the wrong mechanism: plane/__init__.py eagerly imports the whole
plane.api.v2 tree, so an import-time guard on V2Resource made
`import plane` itself raise the moment it reached any of the three
resources still declaring bridge_path, and the try/except shims added
to route around that would have swallowed genuine import errors.

- Removed V2Resource.__init_subclass__ entirely.
- Reverted plane/api/v2/collections/__init__.py,
  plane/api/v2/initiatives/__init__.py,
  plane/api/v2/initiatives/initiatives.py,
  plane/api/v2/releases/__init__.py, and tests/v2/test_resource.py to
  their state at 8666353 (removing all four workaround shims and the
  test edit that existed only to survive them).
- Converted the three dead bridge_path declarations directly to
  extra_paths (same template for both "add"/"remove") in
  plane/api/v2/releases/labels.py, plane/api/v2/initiatives/labels.py,
  and plane/api/v2/collections/pages.py. Nothing else changed in these
  files; they remain broken for the unrelated Task 4 reason.
- Added a call-time guard instead: V2Resource.url_for now raises
  TypeError naming extra_paths if `hasattr(self, "bridge_path")`,
  firing exactly when _bridge would otherwise build a wrong URL from a
  stale bridge_path, never at import. Replaced the class-definition-time
  guard test with test_url_for_raises_for_a_subclass_still_declaring_bridge_path
  in tests/v2/test_kernel_resource.py, using a local throwaway subclass.

Round 1's Owned test coverage, the inspect.signature leading-parameter
guard, and functools.wraps are all kept unchanged.

Verification:
  python -c "import plane" -> import ok
  pytest tests/v2/test_loaded.py tests/v2/test_kernel_resource.py -p no:warnings
    -> 16 passed
  pytest tests -p no:warnings
    -> 11 failed, 173 passed, 685 skipped, 375 errors
       (reference 8666353: 11 failed, 167 passed, 685 skipped, 375
       errors; +6 passed is exactly this round's new tests, everything
       else matches)
  git diff 8666353..HEAD --stat
    -> only the two kernel files plus the three *labels.py/pages.py
       declarations plus the two test files; the four workaround files
       are back to their 8666353 state

See task-5-6-report.md, "Fix round 2 of 5", for full detail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
States.list/retrieve/create/update/delete/find_by_name now take slug and
project as explicit leading parameters (positional or keyword), matching
V2Resource's transport-only constructor. list/iterate use the generated
StatesListField/StatesListOrderBy/StatesListFilters typing aliases for
autocomplete; retrieve uses StatesRetrieveField. upsert and the three
bulk_* methods carry the same leading slug/project parameters.

test_create_posts_to_the_collection additionally passes color, which
CreateState has always required — the brief's version omitted it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
V2Namespace now exposes users, user_assets and workspaces directly --
client.v2.workspaces.projects.states.list(slug, project) reaches every
resource by plain attribute access, never a chain of locator calls.

- Labels re-authored on the flat form, mirroring States (task 7): slug/
  project as leading parameters on list/iterate/retrieve/create/update/
  delete/find_by_name/upsert/bulk_*, using the generated LabelsListField/
  LabelsListOrderBy/LabelsListFilters/LabelsRetrieveField aliases.
- New Workspaces resource (plane/api/v2/workspaces.py) with just
  `retrieve`, since workspaces_list was cut at Gate A. The workspace
  detail route has no pk -- the slug is the key -- so retrieve() goes
  straight to the transport (self.url_for("retrieve", slug=slug)) rather
  than through `_retrieve`, which would append a spurious `/None/`
  segment; `fields` still passes through `_query` for validation. New
  plane/models/v2/workspaces.py:Workspace read model backs it (every
  field but `id` optional, per WorkspacesRetrieveField).
- Projects.__init__ now builds .states and .labels as child attributes.
- Deleted the old chain-form locators: plane/api/v2/workspace.py,
  project.py and wiki.py (and tests/v2/test_locators.py, which only
  exercised that removed chain). Updated every remaining importer so
  `import plane` and the full test suite stay collectible:
  plane/api/v2/__init__.py's own imports/__all__, plus ~13 live
  integration test files that imported the deleted Project/Workspace
  classes purely for fixture type annotations (swapped to `Any`; their
  fixture bodies still call the old client.v2.workspace(...) API and
  remain broken until a later task migrates them, but they were already
  broken at runtime and are now at least collectible -- they stay
  skipped without live credentials).

Suite: 0 failed, 179 passed, 685 skipped, 371 errors (was 11 failed,
173 passed, 685 skipped, 375 errors). The workspaces_retrieve gap in
test_operations_coverage.py is closed now that Workspaces declares it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
States/Labels are the ~89-resource exemplar, so three review findings
are fixed here before the pattern gets cloned:

- create/update/upsert on both States and Labels now take a keyword-only
  fields: Sequence[<Op>Field] | None, passed through params={"fields":
  fields} exactly as retrieve already does. Without this the generated
  StatesCreateField/StatesPartialUpdateField/StatesUpsertField (and the
  Labels equivalents) were dead code.
- list on both resources gains explicit per_page: int | None and
  offset: int | None keyword params, merged into the same params dict.
  Runtime behavior was already correct (extra kwargs fell into
  **filters regardless of the Unpack[...] static type); this fixes the
  mypy call-arg error a caller hit passing them.
- Restored two invariants lost with tests/v2/test_locators.py in
  tests/v2/test_tree.py: V2Namespace exposes exactly
  {transport, users, user_assets, workspaces}, and
  labels.list(..., name=...) reaches the query string.

Suite: 0 failed, 186 passed, 685 skipped, 371 errors (was 179 passed).
mypy plane/api/v2/{states,labels,workspaces}.py: clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
LoadedProject(Loaded, Project) exposes `.states`/`.labels` as Owned,
built from the row's own ids -- no need to repeat slug/project to reach
a fetched project's children.

Projects.retrieve/create/list now route through `_load` and return
LoadedProject (list returns Page[LoadedProject]). Also finishes
migrating Projects to the flat pattern established by States/Labels:
every method takes `slug` (and `project` where relevant) as a leading
positional parameter, typed `fields`/`order_by` from the generated
constants replace bare Sequence[str]/**filters: Any, and
`role_distribution` is expressed via the kernel's `extra_paths`
override instead of hand-building its sibling URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
WorkItems moves to the flat pattern (slug, project leading positional
params, typed fields/order_by/filters from the generated constants),
and its retrieve/create/list now route through `_load`, returning
LoadedWorkItem (list returns Page[LoadedWorkItem]).

WorkItemComments becomes the depth-3 exemplar: `list`/`retrieve`/etc.
take `slug, project, work_item` as their leading path ids, matching
the names LoadedWorkItem.comments builds its Owned wrapper with.

LoadedWorkItem(Loaded, WorkItem) exposes `.comments` as Owned, built
from the row's own ids -- `work_item.comments.list()` needs no ids
repeated.

Projects gains a `.work_items` child alongside `.states`/`.labels`, so
the chain reaches `workspaces.projects.work_items.comments`.

tests/v2/test_work_items_resource.py is rewritten for the new flat
fixture; coverage for the still-unmigrated sub-resources (attachments,
links, worklogs, activities, relations, dependencies) is dropped along
with it -- those resources are out of scope for this change and their
old bound-scope-style tests no longer construct a valid fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…ge signpost

LoadedProject gains a `work_items` property (Owned over the project's
WorkItems resource, same ids/names as its `states`/`labels` siblings),
so the design's showcase chain --
`project.work_items.retrieve(...).comments.list()` -- works end to
end with no ids repeated at any level. Added a test asserting exactly
that chain, checking the final request URL. No other project-scoped
resource is currently wired onto `Projects` (several already-migrated
ones aren't attached yet -- that's their own future task), so no other
`LoadedProject` property was added.

Added a six-case parametrized skip in test_work_items_resource.py
naming the sub-resources (attachments, links, worklogs, activities,
relations, dependencies) whose coverage was dropped in the prior
commit pending their own flat-pattern migration, so the gap shows up
as self-explaining skips instead of silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…he flat tree

- Wiki (new plane/api/v2/wiki_node.py): a plain grouping node consuming no
  path id of its own, holding only WikiPages -- ws.wiki.pages.list(slug).
- WorkspaceFeatures (features.py): a singleton with no primary key --
  get(slug)/update(slug, data) hit url_for directly, no pk. ProjectFeatures
  left untouched (pre-flat, unattached, out of scope).
- ReleaseLabels (releases/labels.py): re-authored flat with typed generated
  aliases; add/remove bridge via extra_paths + url_for to the per-release
  path, list/retrieve/create/update/delete keep the primary catalog path.
- ProjectPages/WikiPages (pages.py): re-authored flat and typed, mirroring
  states.py.
- Workspaces now wires self.wiki/.features/.releases.
- WorkspaceFeature.id relaxed to optional (models/v2/features.py): the
  golden singleton payload doesn't always carry it.
- releases/__init__.py: Releases.__init__ still used the retired bound-scope
  constructor (self._scope, same bug as Collections); fixed to flat
  construction since Workspaces now attaches it eagerly. Its six children's
  own method bodies (still missing slug params) are untouched -- out of
  scope, unexercised.
- Wiki.collections is deliberately not wired: Collections has the same
  retired bound-scope bug and migrating it is separate, larger work.

Suite: 0 failed, 211 passed (+4), 691 skipped, 344 errors (unchanged) --
was 0 failed, 207 passed, 691 skipped, 344 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Projects._load and WorkItems._load called Loaded.build with no
`fields` argument, so it defaulted to None and treated every declared
model field as present -- a sparse response (?fields=id) silently read
unrequested fields as None instead of raising FieldNotRequested,
defeating the entire point of the loaded-row design.

_load/_load_page in both projects.py and work_items/__init__.py now
take a `fields` parameter and thread it into `.build(..., fields=fields)`.
Every call site checked: retrieve/create/list (the only places that
build a Loaded* row in either class) now pass their own `fields`
through; update/upsert/iterate don't build Loaded* rows at all in
either class, so nothing to fix there.

Added HTTP-layer regression tests (not calling `.build` directly,
which is why the existing unit tests missed this) in both
test_loaded_project.py and test_work_items_resource.py: retrieve with
fields=["id"] against a sparse response raises FieldNotRequested on an
unrequested field, the same for a row taken from a list() page, and a
requested-but-null field still reads as None.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…ature.id

Projects.iterate/update/upsert and WorkItems.iterate/update/upsert now
route through _load, same as retrieve/create/list, forwarding the
caller's `fields` the same way -- a caller who switches from `list` to
`iterate` to page through results (or calls `update`/`upsert`) no
longer silently loses navigation. `iterate` wraps each row lazily as
it is yielded (a generator expression over the underlying iterator),
so it does not materialise the whole result set to thread `fields`
through.

Added HTTP-driven tests in both test_loaded_project.py and
test_work_items_resource.py: a row from iterate() is navigable and
reaches a child at the right URL, a row from update() is navigable,
and a sparse iterate(fields=["id"]) raises FieldNotRequested on an
unrequested field.

Also restored `WorkspaceFeature.id` to required (plane/models/v2/features.py):
the golden schema lists it as required and this plan's rows always
carry an id; a prior change had relaxed it to optional to paper over a
test mock that omitted it. Fixed the actual offending mock instead
(tests/v2/test_shapes.py::test_singleton_has_no_pk).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Rewrite the v2 sections of README.md and CLAUDE.md for the flat-tree +
Loaded-rows shape (bound-locator chain is gone), documenting only what's
actually wired today: states, labels, projects, work_items (with comments),
workspaces, wiki.pages, features, releases.labels. wiki.collections stays
unwired (Collections isn't migrated). Notes FieldNotRequested and that
~85 of ~120 resource groups remain on the pre-migration shape.

Add tests/v2/test_typing.py: a subprocess-mypy probe proving the generated
TypedDict filter types reject an unknown keyword (call-arg error on
not_a_filter, distinct from the pre-existing 82-error baseline in
unmigrated files).

Fix two incidental ruff findings surfaced by the full-scope gate command:
tests/v2/test_loaded.py (B018, assign the FieldNotRequested-raising
attribute access) and tests/v2/test_packaging.py (I001, import sort).

Gate results (full details in task-12-report.md):
- pytest (full default suite): 0 failed, 224 passed, 691 skipped, 344
  errors -- baseline before this task's changes was 223 passed (matches
  the expected count exactly); the 344 errors are fixture setup failures
  in the ~85 still-unmigrated resources' tests, unchanged.
- ruff check plane/api/v2 tests/v2: clean.
- black --check on every file this migration slice owns: clean (9
  pre-existing formatting issues remain in unmigrated resource files,
  untouched, out of scope).
- mypy plane/api/v2/states.py plane/api/v2/projects.py plane/api/v2/_kernel/:
  0 errors from the three target areas (confirmed via
  --follow-imports=silent); 82 errors in 42 unrelated files is the same
  pre-existing baseline Task 11 already established.
- tests/v2/test_typing.py: 1 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
README's "what else is wired" and CLAUDE.md's Bridges bullet documented
ReleaseLabels.add/.remove as add(release_id, ids)/remove(release_id, ids),
dropping the leading slug the real bridge URL requires -- calling it as
written raised TypeError. Replaced with a runnable example carrying the
slug (client.v2.workspaces.releases.labels.add("acme", release.id,
[label.id])) and corrected the general framing to "every leading path id
the bridge's own URL needs, in path order, then the ids".

Re-verified every remaining bridge/singleton mention in both files against
inspect.signature (full table in task-12-report.md); all others already
matched real signatures.

0 failed, 224 passed, 691 skipped, 344 errors (unchanged -- prose-only fix).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`Loaded.build` computed `_present` from the caller's `fields=` argument, so a
partial row returned with no `fields=` in play -- which is exactly what the API
does for collection deferral -- marked every declared field present. Reading a
field the server never sent then returned a silent `None` instead of raising
`FieldNotRequested`, defeating the central claim of the design (spec 3.3).

Presence now comes from `row.model_fields_set` (the response's own record of
which keys arrived), intersected with `fields=` when the caller supplied one so
asking for less than the server sent still narrows. A requested-and-genuinely-
null field still reads as `None`.

Adds HTTP-driven coverage for the no-`fields=` partial-response case on both
`Projects` and `WorkItems`, plus the narrowing case, and rewrites the unit test
that asserted the old "no fields= means everything is present" behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`Owned.__getattr__` was declared as returning `Any`, so `project.states` was a
bare `Owned`, `project.states.list()` was `Any`, and every call reached through a
loaded row -- roughly half the public v2 surface -- had no autocomplete and no
type checking. Spec 3.2/4 make typed navigation properties an explicit decision.

`Owned` is now generic over the resource it wraps, and both its `__getattr__` and
`Loaded.__getattr__` are hidden behind `if not TYPE_CHECKING` so a checker stops
collapsing every attribute to `Any` (runtime behaviour is unchanged). Each
navigable row declares a per-child typed view built from `bind1`/`bind2`/`bind3`
kernel helpers, which use `Concatenate` to express "this method minus `self` and
the N path ids the parent already supplied" -- one line per method, evaluated only
by the type checker.

mypy now resolves `project.states.list()` to `Page[State]`,
`project.work_items.retrieve(...).comments.list()` to `Page[WorkItemComment]`, and
flags a misspelled method or field on a loaded row. Proved in tests/v2/test_typing.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Prashant-Surya and others added 27 commits September 8, 2026 19:46
The naming and `expand` rules are enumerated sweeps over every migrated class;
`fields` was enforced by CLAUDE.md prose alone -- the same hole this batch closed
twice elsewhere, and plan 4 copies the pattern seven more times.

`tests/v2/test_fields_coverage.py` walks the migrated set against the golden's
`FIELDS` table and fails naming any method that omits a `fields` its own operation
offers, with the same `iterate`->`list` alias and 204-no-body exclusion the `expand`
sweep uses. It also holds the shape (keyword-only, passed through `params` so the
kernel validates it) and keeps the exception list honest.

The exceptions are the four one-time responses the hand sweep found:
`Webhooks.regenerate` (a secret minted once), `WorkItemAttachments.create` and both
asset creates (presigned `upload_data` that exists only in that reply). The ruling
named the first two; the asset creates fall on the same side for the same reason, so
naming them here makes it a decision rather than an oversight. Each must state the
reason in its own docstring -- `UserAssets.create` only pointed at the module
docstring, so it now says it outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`WorkItemRelations.list` and `WorkItemDependencies.list` called `transport.request`
by hand where `_retrieve_singleton(action="list")` produces the identical URL plus
`_query` validation of anything the golden declares on the operation. Harmless
today, but these two are the copy source for plan 4's dict-shaped resources, and a
hand-rolled request is exactly what CLAUDE.md warns silently skips validation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`test_no_opted_out_class_is_actually_migrated` is the guard that makes the opt-out
list shrink on its own, but its shape half judged `list` alone. A bridge, a
singleton or a dict-shaped resource has no `list` to judge, so an opted-out one
could be migrated and stay opted out with nothing noticing -- `CustomerWorkItems`,
`InitiativeProjects`, `ReleaseChangelogResource` and `CollectionPages` are the four
on today's list in exactly that position, and until somebody wires them there is no
reachability signal either.

`flat_shaped_resource_classes()` now asks of every public method what the naming
sweep asks: does it open with the path ids its own URL template names, under the
flat spelling? It picks up 54 of the 55 migrated classes (`Releases`, whose own
methods are all `@pending_flat_migration`, is the exception) and none of the 35
opted-out ones.

Verified: flat-shaping `CustomerWorkItems`' two bridge methods while leaving it
opted out and unwired is invisible to the old `list` heuristic and fails the new
guard by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Six `endswith` assertions remained. A suffix match accepts the right leaf under the
wrong workspace or project -- exactly the failure a loaded row's bound ids could
produce -- so they compare against the full URL now, the way the rest of the file
already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Four rules gained enforcement in this wave and CLAUDE.md is where the next plan
reads them: a loaded row must reach every child its resource attaches (swept by
`test_loaded_navigation.py`, one documented alias); loaded rows keep undeclared
server fields; the opt-out ratchet is on membership and its shape signal covers
every public method, not `list`; and the `fields` rule is a sweep with its
exceptions enumerated rather than prose alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Migrates seven V2Resource classes across two families to the flat call
shape and gives both families navigable rows:

- Collections (+ members, pages)
- Customers (+ requests, property_values, work_items)

Collections.default() keeps its exact is_default-filter behaviour and
docstring; CollectionMembers.add still takes member objects, not bare
ids; CollectionPages' search URL moves into extra_paths alongside its
add/remove bridge; CustomerPropertyValues keeps its dict-shaped
GET/POST-to-collection-URL behaviour, now through _retrieve_singleton
and _custom_request instead of hand-rolled transport.request calls.

Removes the seven names from UNMIGRATED_RESOURCES in
tests/v2/tree_walk.py; all four rule sweeps (path-id naming, expand
coverage, fields coverage, loaded navigation) pass with them in scope.

Neither family is wired onto the flat tree yet -- that is a later task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Initiatives (+labels, projects, work_items) and the four remaining Releases
children (comments, links, changelog, work_items) migrated to the flat shape.
Releases' own CRUD and children wiring, left pending in the working tree, is
finished here too, since ws.releases was already on the tree. Both families
are navigable (LoadedInitiative, LoadedRelease). Initiatives stays unwired on
Workspaces by design (task 6). Removed all eight names from
UNMIGRATED_RESOURCES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
ProjectAutomations/WorkspaceAutomations and their edges/nodes/activities
children (8 classes) migrated to the flat shape. Project-scoped resources
open with slug, project, automation (depth 3); workspace-scoped ones open
with slug, automation (depth 2) -- otherwise identical, kept diffable.
set_status on both parents and regenerate_webhook_secret on both node
classes now go through the kernel's _void_action/_custom_action helpers
instead of hand-built URLs. Both scopes are navigable (LoadedProjectAutomation,
LoadedWorkspaceAutomation in one _loaded/automation.py file), each exposing
typed .edges/.nodes/.activities. regenerate_webhook_secret's response has no
?fields= in the golden at all, so no projection parameter applies and no
ONE_TIME_RESPONSES entry is needed -- documented in the method's docstring.
Removed all eight names from UNMIGRATED_RESOURCES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…ties

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Migrate Workflows/WorkflowStates/WorkflowTransitions to the flat call
shape: leading path ids (slug, project, workflow) on every method,
typed fields/order_by/filters keyed off each method's own operation
id, per_page/offset on list, and no more **scope constructor.

WorkflowStates.attach is the one non-mechanical piece: it POSTs
{state_ids} to the collection URL and answers an array of membership
rows, not a single row -- the golden's $ref for workflow_states_create
is wrong (a known, previously recorded spec defect), so attach goes
through the kernel's _custom_action_list instead of _create, preserving
the real behaviour rather than the documented one.

Workflows gains navigable rows: LoadedWorkflow (new
plane/api/v2/_loaded/workflow.py) exposes .states/.transitions as
typed Owned views, following the automation/work_item_type exemplars.

UNMIGRATED_RESOURCES in tests/v2/tree_walk.py is now empty -- workflows
were the last family on the plan-4 backlog. Ran all four rule sweeps
(test_path_id_naming, test_expand_coverage, test_fields_coverage,
test_loaded_navigation) over all 90 resource classes for the first
time; all pass with no hidden violations surfaced in previously
migrated classes. Retired the now-vacuous BASELINE_OPT_OUT ratchet in
test_path_id_naming.py per its own docstring instruction, replacing it
with a direct assertion that the opt-out list is empty.

Suite: 959 passed, 685 skipped, 0 failed (was 927 passed, 8 errors).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Every `V2Resource` subclass in the package was migrated to the flat shape by
tasks 1-5, but ten families were reachable only by direct import. They are
attached now, so all 90 classes are reachable from `client.v2`:

* `Workspaces` gains `customers`, `initiatives`, `automations`,
  `work_item_properties` and `work_item_types` (and, through them, their own
  children -- customer requests/property values/work items, initiative
  labels/projects/work items, automation edges/nodes/activities, property
  contexts/options, type properties);
* `Projects` gains `automations`, `work_item_types`, `work_item_properties`
  and `workflows`, taking the project band from fifteen children to nineteen;
  `LoadedProject` gains the four matching navigation properties with their
  typed `bind2` views, so a fetched project reaches them with no ids repeated;
* `Wiki.collections` stops being a placeholder and becomes the real
  `Collections` (with `.members` and `.pages`).

`WORKSPACE_TREE_ATTACHMENTS` and `PROJECT_TREE_ATTACHMENTS` carry a row per
newly reachable resource -- 20 and 11 -- so each is proved to be the right
class at the right URL rather than merely present; the rows whose template
carries an id of its own are proved by the two new `_collection_url` tests.
Both completeness assertions were checked by deleting a row and watching them
fail by name.

`InitiativeLabels` joins `CATALOG_SIBLINGS`: like `ReleaseLabels`, its catalog
CRUD is workspace-wide (`(slug,)`) while only its `add`/`remove` bridge takes
`(slug, initiative)`, so its `list` is legitimately shorter than
`Initiatives.loaded_names`.

With `wiki.collections` real, `PendingMigration` had no users left, so
`plane/api/v2/_kernel/pending.py` and `tests/v2/test_pending.py` are deleted
along with `tree_walk.is_pending` -- a mechanism for tracking unfinished work
should not outlive the work. `test_no_placeholders_remain` walks the live tree
and refuses any placeholder, `test_the_pending_migration_mechanism_is_deleted`
proves the module is gone, and
`test_every_resource_class_is_reachable_from_the_namespace` states this task's
purpose once: nothing in the package needs a direct import any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Rewrite README.md and CLAUDE.md's v2 sections to describe a finished
migration rather than one in progress: all 90 V2Resource subclasses are
on the flat shape and reachable from client.v2 (UNMIGRATED_RESOURCES is
now empty). Fix the three inaccuracies flagged for this task -- the
55/90 split, wiki.collections as a placeholder, and PendingMigration
described as a live mechanism (the module is deleted) -- and correct
further staleness found on a full re-read of both v2 sections.

Extend tests/v2/test_typing.py with a probe reaching three levels deep
through a navigable row (project -> work_items -> comments), asserting
the resolved types are real rather than Any and that a misspelled
method at that depth is a type error for that reason specifically.

Every code sample in both documents was verified: fenced samples via
responses-mocked execution, and signatures mentioned only in running
prose via inspect.signature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`Workspaces` is the design's navigable row #1 (variant-F design, "Navigable row
types") and was the only fetch in the SDK that answered a bare model:
`client.v2.workspaces.retrieve("acme")` returned a `Workspace`, so
`workspace.projects` raised `AttributeError` on the most-used entry point in the
package.

`LoadedWorkspace` (`_loaded/workspace.py`) exposes all 24 resources
`Workspaces.__init__` attaches, as declared typed properties with `if
TYPE_CHECKING` `bind1` views -- the `project.py` shape, one id bound instead of
two. `retrieve` routes through `_load` with `fields` forwarded. `_row_id` is the
row's `slug`: children open with `/workspaces/{slug}/`, which does not accept the
UUID `id` every other resource falls back to, so `retrieve` fills in the slug the
caller addressed the row by when a projection dropped it -- without widening what
the caller may read, which `test_a_projection_that_drops_the_slug_still_navigates`
pins.

The blind spot was causal, not incidental: `navigable_resource_classes()` selects
classes that declare a `loaded_model`, so a resource with children and no
`loaded_model` is invisible to the navigation sweep -- it passes by never being
looked at. Sweep 4 now also enumerates:
`test_every_resource_with_children_declares_a_loaded_model` runs over every class
in the package, and `test_the_child_bearing_sweep_bites` runs the same check
against a synthetic class with children and no `loaded_model` so the failure is
demonstrated rather than assumed. Removing `loaded_model` from `Workspaces` --
the original defect exactly -- makes the new sweep fail naming the class and all
24 children.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
CLAUDE.md read as an exhaustive list of the server-side lookups, naming three.
Enumerated over the package: 33 of the 38 `find_by_*` methods go through
`_find_one` server-side and 5 scan client-side, so the wording described the
exception as if it were the rule. The method docstrings were correct throughout;
only this artifact was inverted.

Now states the default and names all five exceptions -- `Collections`,
`ProjectPages`, `WikiPages`, `Roles` (its `find_by_slug` sibling is server-side)
and `WorkItemRelationDefinitions` -- each of which already carries its reason in
its own docstring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`WorkspaceWorkItems` returns plain `WorkItem`s where every other work-item fetch
answers a `LoadedWorkItem`, contradicting CLAUDE.md's absolute claim with no
explanation anywhere. It is structurally unavoidable -- the workspace-wide route's
URL band has no project segment, so there is nothing to bind the
`("slug", "project", "work_item")` a loaded row's children need, and `Owned` would
refuse the call. Reading the path id off the row's `project_id` field instead
would make navigation depend on the caller's projection, since `?fields=` and
collection deferral can both omit it.

Says so now in the class, module and per-method docstrings, and CLAUDE.md admits
the exception by name rather than stating an absolute the code does not keep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…ases

`release.tags` was a navigation property on which every call raised: `Owned`
compares a child method's leading parameters against the parent's `loaded_names`
literally, and `ReleaseTags` takes `(slug,)` where `LoadedRelease` binds
`(slug, release)`. Its `_OwnedReleaseTags` view bound no methods at all and said
so -- the property existed only so the navigation sweep would find a child behind
it.

The defect is the attachment, not the property. `ReleaseTags` is a workspace-level
catalog (`/workspaces/{slug}/releases/tags/`, one path id) and a release points at
a tag through its own `tag_id` field, with no per-release association -- unlike
`ReleaseLabels`, which earns its place under `Releases` through a real
per-release `add`/`remove` bridge. The variant-F design's own navigable-row table
lists `Release`'s children as work_items, labels, comments, links and changelog:
no tags.

So it moves to `client.v2.workspaces.release_tags`, and the property is deleted
rather than kept as a shell. Follows through: `LoadedWorkspace` gains the child,
`LoadedRelease` loses it, the `("Releases", "ReleaseTags")` exemption in
`test_path_id_naming.py`'s CATALOG_SIBLINGS is stale and gone, the tree test that
pinned the old wiring now pins the new one and asserts `releases.tags` is absent,
and the three duplicate tag tests in `test_releases_resource.py` fold into the
dedicated `test_release_tags_resource.py` (keeping the `version:`-prefixed pk
round-trip).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Six methods built their own `transport.request` where a kernel helper already
existed, which is how a call quietly skips `_query`'s `fields`/`expand`/`order_by`
validation against the golden and `_format_path`'s `MissingPathId` reporting:

- `Users.me` -> `_retrieve_singleton(action="me")`: `/users/me/` *is* the row.
- `WorkspaceAssets.create`, `UserAssets.create`, `Artifacts.create` ->
  `_custom_action`, each answering an envelope that is not the resource's `model`
  (an upload result, a lean `Artifact`). `Artifacts.create` was the odd one out in
  its own file -- `publish` and `update` next to it already used the helper.
- `Projects.summary` -> `_custom_action(pk=...)`, which builds the identical
  `{detail}/summary/` URL. The f-string it replaces also omitted the action name
  from `_detail_url`, so a missing path id reported `<call>` rather than `summary`.
- `Projects.role_distribution` -> `_custom_action`, URL from `url_for` and its
  `extra_paths` override.

No URL, body or response shape changes: the existing per-resource tests pin every
one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
A cheap guard on the one bug this migration shipped: a navigable class whose
`list` went through `_load_page` while its `iterate` handed back raw rows, so the
type diverged only at the call site. All 18 navigable listers were executed by
hand and agree today -- there is no live bug. This is so nobody has to do that by
hand again, and so a family added later is covered the moment it declares a
`loaded_model`.

Derived, not tabled: subjects are every class with a `loaded_model` that has both
`list` and `iterate`, and each one's path ids and collection URL are read off its
own `path` template. A second page is registered deliberately -- `iterate`
re-loads from a *different* response, which is where a parent id captured
per-page rather than per-call would be right on page 1 and wrong on page 2, so the
sweep asserts ids as well as types.

Shown to bite, twice, against `Cycles.iterate`: returning raw rows fails the type
assertion naming `['Cycle', 'Cycle']` vs `LoadedCycle`; threading a wrong parent
id fails the ids assertion naming `('id-slug', 'WRONG')`. Both reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`test_path_id_naming.py` was already non-conforming at line 131 (an implicit
string concat black would join); the `black --check` gate covers every touched
file, so it comes along rather than being left for the next person to trip over.
No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
All 385 tests in `tests/v2/integration/` have been dead since the bound-locator
chain was removed, and every run reported them green: the session-scoped credential
fixtures skipped before any body could raise `AttributeError`, so `385 skipped` was
indistinguishable from an honest run with no credentials. Python has no compiler to
catch that, so the refresh comes second -- the way to tell a fixed suite from a
still-broken one comes first.

`_guard.py` enforces three properties at runtime:

* the whole suite may lie dormant for exactly one reason, a missing env var, and
  that decision is made once at collection time rather than inside a fixture where
  it can fire by accident;
* every other skip must be declared through `skip_absent_capability`, so a fixture
  swallowing its own setup failure into `pytest.skip` is converted to a failure;
* with credentials present, collecting tests and executing none of them fails the
  session, because "we tested nothing" must never read as success.

`tests/v2/test_integration_guard.py` proves each property fires, by running
synthetic suites through the shipped guard in a subprocess and asserting on the
session's exit code -- including the exact retired per-fixture credential skip.

The conftest's own fixtures stop skipping (`os.environ`, not `os.getenv` + skip),
and the session project fixture stops hand-rolling `transport.request`: it goes
through `Projects.create` and yields the `LoadedProject` the surface answers with,
so the one object the suite is built around is now the thing under test.

That fixture wants a type annotation, which surfaced the first SDK defect: none of
the 19 `Loaded*` row types were importable from `plane.api.v2`. They are the
declared return type of every `retrieve`/`list`/`iterate`/`find_by_*`/verb on a
navigable resource, so a caller could not name a value the SDK handed them without
importing out of the private `_loaded` package. They are exported now, and
`tests/v2/test_loaded_exports.py` sweeps the set by enumeration so a navigable
family added later cannot forget to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`paginate` and `count` are reserved query params in the constants generator --
deliberately kept out of every `*Filters` TypedDict, because each belongs on the
method as an explicit typed parameter. Reserving them is where it stopped. Neither
was ever added to a single one of the 68 list methods, for the whole life of the v2
surface, and nothing could see it: `FIELDS` and `EXPAND` are the only golden tables
the generator emits, so the two rules that *are* swept were the only two that could
be.

What it cost:

* `AuditLogs` could not be listed at all. `AuditLogViewSet.count_styles_enabled` is
  `False` server-side, so the offset envelope is refused with
  `count_pagination_disabled` and `?paginate=cursor` is the only way in. The
  resource ships, is wired, has offline tests and passes every rule sweep; every
  call it can make 400s.
* No resource could be traversed deeply. The cursor envelope exists to drop the
  `COUNT(*)`; `parse_page` discriminates it and `iterate` follows it, and its branch
  was unreachable from any public method.
* `count=false` was unsendable, while the kernel's own `_find_one` had been sending
  it internally since it was written.

`list` now takes `paginate`/`cursor`/`count` and `iterate` takes
`per_page`/`paginate`/`cursor`, in both cases only where the golden declares them --
`work_item_relation_definitions_list` is the one list operation with no `paginate`,
and it does not get one. `iterate` deliberately does not take `offset` or `count`:
it owns its own walk, and a `COUNT(*)` per page is the exact cost the cursor
envelope exists to avoid.

The generator emits a `PAGINATION` table (regeneration is otherwise byte-identical),
and `tests/v2/test_pagination_coverage.py` is the third sibling of the `expand` and
`fields` sweeps, with a bite test built from a class shaped like the 68 that shipped
broken. `test_roles_resource.py`'s hand-written filter pin reads its envelope set out
of that table now rather than listing it, since listing it is what made it drift.

Found by refreshing the live integration suite: `test_audit_logs.py` and
`test_pagination.py` were written against `paginate="cursor"` and have never once
been executed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…ource

`Owned.__getattr__` bound the parent's ids onto anything callable it forwarded and
returned everything else as-is. A sub-resource of a child is not callable, so
`project.estimates.points` came back **raw**: the right class, apparently usable,
and silently missing every id the row was carrying.

The failure it produced is worse than a missing attribute. The natural spelling --
`project.estimates.points.create(estimate_id, data)`, mirroring
`project.states.create(data)` one level up -- raises `MissingPathId` for a `slug`
the caller supplied at fetch time; and passing the ids again makes it work, so the
same expression means two different things depending on how many arguments follow.
This is the exact class of silent misrouting the leading-parameter-name check in
the same method exists to prevent one level up.

It refuses now, naming both routes that are typed: the flat path, or fetching the
row in between (`estimate.estimate_points`). Binding it instead would work at
runtime -- a sub-resource's leading path ids are its parent's, by URL nesting --
but the per-child view classes declare methods, not nested children, so it would be
an untyped navigation hop, which the design rules out.

The new sweep enumerates every navigable child of `Projects` that has children of
its own rather than spot-checking estimates: the hole was in `Owned`, so it was all
of them.

Found refreshing the live integration suite, where `test_estimates.py` had been
written as `proj.estimates.points.create(estimate.id, ...)` against the old
bound-locator chain -- where it worked -- and had not been executed since.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…at surface

Retires the `client.v2.workspace(slug).project(id)` locator across the shared
harness and 20 test files, and picks a way in per file rather than a default.

`helpers.ResourceSpec` now offers both: `spec.flat(client)` for the static tree and
`spec.on(project)` for navigation off a loaded row. The parity scenarios
(`test_crud`, `test_scope_parity`) must be flat -- they put a project uuid and its
identifier in the same path slot, and `Projects._row_id` is `identifier`, so a row
fetched by uuid still binds its children by key. Everything generic that is not
about the URL (`find_one`/`upsert`/`bulk`/`errors`/`pagination`) goes through the
loaded row, so `Owned` is exercised against a real server by the same scenarios
rather than only by hand-written one-offs.

`CONVERTED_SPECS` is gone. It named the three families not yet migrated off the
locator; with the migration finished the distinction is empty, so `test_scope_parity`
now covers cycles, modules and milestones for the first time.

The session fixtures changed shape too: `project` is the `LoadedProject` that
`Projects.create` answers rather than a hand-rolled `transport.request` dict, and a
new `workspace` fixture is the `LoadedWorkspace` reaching all 25 workspace-scoped
families. Files whose subject is the URL itself keep the flat path and say so:
`group_sync` is a grouping node and structurally absent from a loaded workspace,
`InitiativeLabels` is a workspace catalog and a per-initiative bridge behind one
class, and an id that does not exist has no row to load.

Server-capability skips (feature flags, licence gates, workspace-vs-project work
item type mode) now go through `_guard.skip_absent_capability`, so they are declared
rather than incidental; every other skip in this suite is a failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`WorkspaceFeatures` spelled its read `get` while `ProjectFeatures`, three lines below
it in the same module, spelled it `retrieve`. So `workspace.features` and
`project.features` are the same operation at two scopes under two names, and a caller
has to know which side of the file they are on. `GroupSyncConfigResource` had copied
the `get` spelling from the first one.

`retrieve` wins on every count: it is the CRUD read verb the rest of the package uses
(having no primary key changes how the URL is built, not what the operation is), it is
what the golden's own operationIds say (`workspace_features_retrieve`,
`group_sync_config_retrieve`), and it is what the live test suite had already been
written against.

Found refreshing that suite: `test_features.py` calls `workspace_features.retrieve()`,
which did not exist, and had never been executed.

`tests/v2/test_singleton_verbs.py` sweeps every method in the package that reads
through `_retrieve_singleton`, so a third singleton cannot pick a third name. `me()`
is excluded deliberately -- `users.me()` and the two `permissions.me()` answer "for
whoever is asking", which is a different question with its own established name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
The remaining ~19 files move off the retired locator, and `tests/v2/test_live_smoke.py`
with them -- it sat outside `tests/v2/integration/` and had been skipping green for
exactly as long, which is the argument for the guard being about *live suites* rather
than about one directory.

The way in is chosen per file and stated in each file's docstring, because the two
halves of the surface fail differently and a suite that only uses one of them tests
half of it:

* **Loaded rows** wherever the subject is a resource's behaviour, and they go deep:
  `work_item.comments.list()`, `estimate.estimate_points.create(...)`,
  `release.changelog.update(...)`, `customer.requests.list()`,
  `option_prop.property_options.create(...)`, `wtype.properties.link([...])`.
  `test_work_item_sub_resources.py` reaches all seven of a work item's children off
  the row and never writes the work item id twice.
* **The flat path** wherever the subject is the URL: the uuid-vs-key parity checks
  (a loaded project normalises both spellings to `identifier`, so it cannot express
  them), the grouping nodes `wiki` and `group_sync` (no `V2Resource` base, so a
  loaded workspace deliberately does not reach them), the catalog halves of
  `ReleaseLabels`/`InitiativeLabels`/`ReleaseTags`, `Roles` (whose own `?slug=`
  filter is renamed `role_slug` to clear the path id), and any id that does not
  exist and therefore cannot be fetched.

`tests/v2/test_integration_surface.py` is the offline half of the guard, and the one
that would have caught this in the state everybody runs: mypy over both live suites,
so a vanished locator, a renamed method, a dropped path id or a navigation property
that no longer exists is a hard failure with no server, no credentials and no network.
Three probes prove it bites, including on the exact historical regression. A fourth
test greps for bare `pytest.skip` in live tests, so the runtime rule ("declare it or
it is a failure") also holds at rest -- every capability skip in the suite now goes
through `_guard.skip_absent_capability` and names the server capability it is waiting
for.

Six `transport.request` bypasses are gone too: they skipped the kernel's own
`fields`/`expand` validation and hand-parsed envelopes, so the suite was not testing
the request path it claims to. The one deliberate use left is the rate-limit retry
wrapper, which has to wrap the transport.

No assertion was weakened to get here. Two vacuous ones were replaced by real ones
(`assert projects.archive(...) is None` on a `-> None` method; `page.next` now
narrowed with an explicit `isinstance(page, OffsetPage)` that says which envelope is
expected), a bulk-result id list is narrowed on `result != "failed"` rather than
indexed blindly, and `test_workspace_work_items.py` gained a test asserting the
documented `WorkspaceWorkItems` navigation exemption instead of trusting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…t replaced

Nine live tests asserted `row.<field> is None` after a `fields=` fetch -- the
pre-migration contract. A loaded row now raises `FieldNotRequested` on a field
the server did not return, precisely so an absent value cannot be mistaken for
an empty one, so those assertions raised instead of passing: each test was
pinning the behaviour the migration removed.

They now assert what the surface actually guarantees -- the requested fields are
present and equal to the written row, and reading an unrequested one raises with
a message naming what the server did return, which is the part that tells a
caller what to re-request.

test_crud is parametrized across both row shapes, and they differ: cycles,
modules and milestones have children to navigate to and answer `Loaded` rows,
while states and labels answer plain read models where an unrequested field
still reads as the model's `None` default. `assert_not_requested` names both
rather than hiding the split behind a looser check -- the inconsistency is real
and worth being visible in the suite.

`test_sparse_fields_leave_others_none` is renamed to
`test_sparse_fields_hide_the_rest`: nothing is left as `None` any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`Webhooks.create` answers a `WebhookCreateResult`, but the log test annotated
the fixture `dict[str, Any]` and subscripted it, so the only live test of
`webhook_logs.list` died on `TypeError` before reaching the request. The stale
annotation is why it type-checked: `Any` accepts the subscript.

Now annotated `WebhookCreateResult` and read as `webhook.id` -- typed, so the
same mistake is a mypy error next time. A sweep of the rest of the live suite
found no other model subscripted this way; the two `_custom_field` helpers that
subscript do so behind an `isinstance(row, dict)` check on genuinely dict-valued
custom-field payloads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
@Prashant-Surya
Prashant-Surya marked this pull request as ready for review September 9, 2026 10:55
Prashant-Surya and others added 2 commits September 9, 2026 16:53
Only conflict was the version: this branch had already bumped to 0.3.0 for the
v2 surface, while main remained at 0.2.24. Kept 0.3.0.

Two commits came in. "add parent to work item comment models" (#71) touches
plane/models/work_items.py, which is v1; this branch never touched it, so it
merged cleanly and both `parent` fields survive. No v2 mirror is needed -- the
v2 golden does not declare `parent` on work item comment create.

The other, bumping black 24.8.0 -> 26.3.1, needed checking rather than trusting.
CI does not run `black --check`, but it does regenerate plane/api/v2/_generated/
constants.py and `git diff --exit-code` it, and the generator formats its output
with black -- so a formatter that disagreed with the committed file would fail
CI for a reason unrelated to any code change. Verified against black 26.3.1 in
an isolated environment: constants.py is left byte-identical, so the drift check
still passes.

That check also showed the two files here were formatted by an older black. They
are reformatted with the now-pinned version. The other 59 files the new black
would touch predate this branch and are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`v2-golden-drift` regenerates plane/api/v2/_generated/constants.py and requires
it byte-identical, and the generator records its source path in the file header
-- so the committed spelling must match the one CI passes, which the workflow
spells `../plane-ee/apps/api/plane/api_v2/core/schema/openapi`.

The header here read `../plane-ee-preview/...`, recorded when the golden was
regenerated from a preview checkout to pick up operations the stale one lacked.
That would have failed the drift check the moment PLANE_EE_CHECKOUT_TOKEN was
configured. The job is skipped without the token, so CI was green and would have
stayed green right up until the token existed.

Regenerated against the same preview content with the path spelled as CI spells
it: the header is the only line that differs, all 407 operations are identical,
and the file is unchanged under the black version requirements.txt now pins.

Found while investigating a Node CI failure; the Node SDK carried the identical
bug for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant