Skip to content

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

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

[SILO-1466] feat(v2): variant F surface — flat path, loaded rows, typed filters#70
Prashant-Surya wants to merge 85 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

Summary

Brings the v2 surface onto variant F, the shape the team approved. This replaces the bound-chain design that earlier commits on this branch introduced — that form is deleted, not deprecated.

Two ways in, and no object that carries navigation without data:

# flat path — each segment consumes one URL path id, left to right
client.v2.workspaces.projects.states.list("acme", "ENG", fields=["id", "name"])
client.v2.workspaces.projects.work_items.comments.list("acme", "ENG", "ENG-12")
client.v2.workspaces.wiki.pages.list("acme")     # `wiki` groups, consumes no id
client.v2.workspaces.features.get("acme")        # singleton, no primary key

# loaded rows — a fetched row carries its data and reaches its children with no ids repeated
p = client.v2.workspaces.projects.retrieve("acme", "ENG")
p.name
p.states.list()
p.work_items.retrieve("ENG-12").comments.list()

Partial data fails loudly rather than reading as None:

p = client.v2.workspaces.projects.retrieve("acme", "ENG", fields=["id"])
p.name      # raises FieldNotRequested, naming what was requested

Presence is derived from what the server actually returned, not from what the caller asked for, so a partial response with no fields= at all is still caught.

Typing and autocomplete

  • Ships a py.typed marker. Without it no type checker read this package's annotations at all.
  • Generated Literal aliases for field and order_by values, and TypedDict filter kwargs, for all 407 operations. A misspelled filter name is now a mypy error.
  • Navigation is typed too: p.states.list() resolves to Page[State], and p.states.lst() is a type error.
  • tests/v2/test_typing.py runs mypy against a probe and asserts it rejects an unknown filter, so the typing cannot silently rot.

Scope — this is a foundation, not the finished migration

Deliberately incomplete. This PR delivers the kernel, the typing generator, the flat tree, loaded rows, and eight resources proving every structural shape: depth 1/2/3, grouping node, singleton, bridge with an alternate path, and two navigable row types.

Roughly 81 resource classes remain on the old shape and are exposed as placeholders that raise NotImplementedError naming themselves. They are handled by follow-on plans. The suite is 279 passed, 0 failed, with 344 collection errors from those unmigrated fixtures.

Marked draft until that migration completes.

Test plan

  • 279 passing, 0 failing
  • mypy clean on every migrated file; the typing probe proves the generated types bind
  • ruff and black clean on all touched files
  • 407/407 operations declared exactly once
  • Live suite against plane-dev — not yet run, the instance is stopped

🤖 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

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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 · 🟠 ~2 changed · 🔴 -0 removed · 2 flows · 23 files · commit 1e50666


Architecture

Architecture diagram for makeplane/plane-python-sdk at 1e50666

12 components touched across 5 lanes.

Open the interactive canvas


Inside the changed components — 2 views

Component view — v2 Kernel & Transport

Internal architecture of the v2 CRUD engine, HTTP session transport, and generated OpenAPI validation constants.

Architecture view of Component view — v2 Kernel & Transport in makeplane/plane-python-sdk

Component view — Navigable Rows & Loaded Models

The Loaded row pattern and Owned proxy binding parent path IDs onto child resource operations.

Architecture view of Component view — Navigable Rows & Loaded Models in makeplane/plane-python-sdk

Data flow

Data flow diagram for makeplane/plane-python-sdk at 1e50666

Querying resources via the flat v2 hierarchy · Navigating child resources through loaded rows

Open the interactive canvas


The other flows — 1 sequence

Navigating child resources through loaded rows

Sequence diagram of Navigating child resources through loaded rows in makeplane/plane-python-sdk

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

Primary SDK client mounting the new v2 namespace alongside existing v1 resource clients.

🟡 CHANGED Configuration

Holds credentials, retry policies, v1 base_path, and unversioned api_root used by v2.

v2 Kernel & Infrastructure — 6 components
🟢 NEW V2Transport

HTTP session transport managing authentication headers, connection retry policies, and /api/v2 routing.

🟢 NEW V2Resource Kernel

Generic CRUD kernel resolving URL path parameters, validating query fields, and managing pagination.

🟢 NEW Loaded & Owned Rows

Navigable row wrappers with field presence tracking that bind parent IDs onto child method calls.

🟢 NEW PendingMigration

Explicit placeholders on the flat tree for unmigrated v2 endpoints that raise clear error messages.

🟢 NEW Generated Constants

Generated lookup tables defining valid fields, expand relationships, and sorting options per operation.

🟢 NEW V2 Pydantic Models

Typed request, response, and pagination data models for all v2 API entities.

v2 Resource Tree — 4 components
🟢 NEW V2Namespace

Static entry point for the flat v2 tree rooted at client.v2, exposing workspaces and users.

🟢 NEW Workspace & Project Resources

Flat endpoints for workspaces, projects, states, labels, features, releases, and wiki pages.

🟢 NEW Work Item Resources

Flat endpoints for work items and nested sub-resources including comments and custom properties.

🟢 NEW User & Asset Resources

Root-level v2 endpoints operating directly without a workspace slug.


View

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

Tip

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.

🪧 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.
  • 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.
  • 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.

◈ 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 30 commits September 8, 2026 18:39
Migrates Estimates + EstimatePoints, Webhooks + WebhookLogs, and the six
work-item children (activities, attachments, links, relations, dependencies,
worklogs) to the flat path-id shape: leading positional-or-keyword path ids
in URL order, typed fields/order_by/expand/filters keyed off each method's
own operation id, and per-resource path-id naming (no _id suffix, including
each resource's own pk).

WebhookLogs takes slug, webhook (parent id lives in the collection path
itself) with detail methods taking a third id, log. WorkItemRelations and
WorkItemDependencies keep their dict-shaped, non-paginated list/create and
delete by the (work_item, related_work_item) pair. EstimatePoints.find_by_key
takes an int key. webhooks.create/regenerate and attachments.create keep no
fields param (richer one-time/upload envelopes), now via _custom_action.
Added missing expand coverage on estimates_* and worklogs create/update.

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

Cycles, Milestones, Modules, Estimates and Webhooks now mix in
LoadsNavigableRows and return LoadedCycle/LoadedMilestone/LoadedModule/
LoadedEstimate/LoadedWebhook from retrieve/create/update/upsert/list/iterate/
find_by_name, each exposing its one child as a declared, typed Owned property
(work_items for cycles/milestones/modules, estimate_points for estimates,
logs for webhooks). iterate wraps rows lazily; fields is forwarded into
_load/_load_page throughout.

LoadedEstimate's child is named estimate_points, not points: Estimate.points
is itself a real API field (inline data from expand=["points"]), so a
navigation property literally named points would be captured by pydantic's
subclass-field-default machinery instead of staying a property, silently
losing both the navigation and the expand data.

Also fixes a latent Loaded.build bug this surfaced: it read row.model_dump()
to build the loaded row's data, which recursively flattens nested pydantic
models (Estimate.points: list[EstimatePoint]) into plain dicts; model_construct
then never re-validates them back into model instances. Reading values off the
row directly (getattr) instead keeps already-validated nested instances intact.
No prior migrated resource had a nested-model field, so this was unreachable
before Estimates went through _load.

Updates five pre-existing sparse-response tests (cycles/milestones/modules/
estimates) whose plain-model assertions (absent field reads as None) no
longer hold now that these resources return Loaded rows, which raise
FieldNotRequested for a field neither requested nor returned instead.

Webhooks.create/regenerate keep returning WebhookCreateResult (not a Webhook
row) unchanged -- not navigable, since the response isn't a row of the
resource's own model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Webhooks.create now exposes fields (typed from the generated
WebhooksCreateField alias, threaded through the existing _custom_action
params dict) since it returns an ordinary, re-fetchable row.

Webhooks.regenerate and WorkItemAttachments.create keep their fields
omission -- both docstrings now name the reason: each returns a
one-time, unrecoverable payload (a secret shown once; presigned
upload fields that exist only in that reply) that a projection could
silently drop beyond recovery.

Records the exception beside the existing "every option the golden
offers must be reachable" rule in CLAUDE.md.

Also tightens two request-URL assertions (EstimatePoints.list,
WorkItemLinks.list) from startswith/endswith to exact ==, per the
recipe's "every method asserts its exact request URL".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Replace the six PendingMigration placeholders on WorkItems (attachments,
links, worklogs, activities, relations, dependencies) with the real,
already-migrated resource classes, and add a declared, typed Owned
property per child on LoadedWorkItem, following the existing comments
property's exact pattern. All seven children of a fetched work item are
now navigable with no ids repeated.

Removes the six test_tree.py rows that asserted these children still
raised NotImplementedError; the real coverage now lives in
test_work_items_resource.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`Projects.__init__` attached only `states`, `labels` and `work_items`, so the
twelve project-scoped resources migrated by tasks 1-3 were unreachable from
`client.v2`. Attach `cycles`, `milestones`, `modules`, `estimates`, `intakes`,
`members`, `views`, `features`, `permissions`, `work_item_templates`,
`worklogs` and `pages` -- `ProjectPages` had been migrated by an earlier plan
and never wired at all.

`Webhooks` already carried `logs`, but nothing wired `Webhooks` itself onto
`Workspaces`, so `webhooks.logs` was equally unreachable; attach it there.

`tests/v2/test_tree.py` gains `PROJECT_TREE_ATTACHMENTS`, the project-scoped
twin of `WORKSPACE_TREE_ATTACHMENTS`: the same
`(name, getter, expected_class, expected_url)` rows and the same completeness
assertion against the live attribute set, so attaching a resource without a row
fails by name rather than passing unproven. Verified by deleting the `pages`
row and watching it fail.

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

The rule sweeps drew their subjects from a union of two opportunistic
discoveries: classes wired onto the live tree, and classes whose `list` already
consumed every path id its URL template names. Both miss by construction. 18 of
the 90 `V2Resource` subclasses have no `list` at all -- every membership bridge,
every singleton, and the workspace root -- so no way of writing them could bring
them into either sweep; and a class migrated by one task but wired by a later
one is unswept in between, which is where `ProjectPages` sat for a whole plan.
This is the same shape of failure that let the path-id rule break across 16
classes: a rule enforced by a mechanism with an unmeasured hole.

Invert it. `all_resource_classes()` enumerates every `V2Resource` subclass by
walking the package, and `migrated_resource_classes()` is that set minus
`UNMIGRATED_RESOURCES`, an explicit opt-out naming the 35 plan-4 families that
are still pre-flat. Inclusion is now the default: a newly migrated class is
swept the moment it exists, wired or not, `list` or not.

Four guards keep the opt-out honest: it may only shrink (ratcheted against a
ceiling), it may not name a class that no longer exists, it may not name a class
that is wired or flat-shaped (i.e. already migrated), and the unswept set must
equal it exactly.

Measured over identical library code (the commit before the project band was
wired), the subject set rises from 49 classes / 305 methods to 55 / 315. The six
it gains -- CycleWorkItems, MilestoneWorkItems, ModuleWorkItems, ProjectFeatures,
ProjectPermissions, ProjectWorklogs -- were all migrated by tasks 1-2 and all
outside both sweeps; each passes on inclusion, so no violation was hiding there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
The sweep flagged any parameter whose name ended in `_id` on a migrated class.
That is broader than the rule it enforces. The rule exists because `Owned`
compares a child's leading *path* parameters against its parent's `loaded_names`
literally, so a suffixed path id breaks navigation; a request body field is
outside that mechanism entirely. The over-broad check forced `Cycles.transfer`'s
destination from `new_cycle_id` to `new_cycle` even though the URL template
never names it and the golden's body still sends `new_cycle_id`. Plan 4 migrates
seven families with many custom actions, so it would have kept doing that.

`path_id_offenders()` now only considers parameters that correspond to a path id
of the resource: a `{...}` placeholder in its own `path`/`extra_paths`, or its
own primary key, derived from the trailing literal segment of the template
(`.../cycles/` admits `cycle`/`cycle_id`; `.../work-item-types/` admits both
`work_item_type` and `type`). The pk needs deriving because the kernel appends
it to the collection URL rather than naming it in the template, so it would
otherwise escape the check -- which is the one case the rule cares most about.

Proved both ways rather than asserted, on a resource built for it and on the
real `Cycles`: a body field spelled `new_cycle_id` is no longer flagged, while
`retrieve(slug, project, cycle_id)` still is. `Cycles.transfer` keeps
`new_cycle` -- harmless and now consistent, as agreed.

Also brings `test_expand_coverage.py`'s header in line with the enumerated
subject set and raises its vacuity floor from 20 to 50 (it now sweeps 82
expandable methods).

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
`Projects.__init__` attaches fifteen resources; `LoadedProject` exposed three, so
`workspaces.projects.cycles.list(slug, project)` worked while `project.cycles.list()`
raised `AttributeError` -- half the design missing on its most important row.

Adds the twelve missing navigation properties (`cycles`, `milestones`, `modules`,
`estimates`, `intakes`, `members`, `views`, `features`, `permissions`,
`work_item_templates`, `worklogs`, `pages`), each with its `if TYPE_CHECKING`
`Owned` view so the signatures survive a type checker.

Closes the gap that let it happen: `tests/v2/test_loaded_navigation.py` sweeps every
resource declaring a `loaded_model` and requires its `Loaded` type's navigation
properties to be exactly the child resources the resource attaches -- and that each
property wraps its own child, not a copy-pasted sibling. Without the sweep the same
gap reopens once per family, and plan 4 has seven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`Loaded.__getattr__` shadows `BaseModel.__getattr__` and never consulted
`__pydantic_extra__`, so a field the server sends and the model does not declare was
readable on a plain row and `AttributeError` on a loaded one -- while `model_dump()`
still showed it and `_present` still counted it present. The v2 read models are
`extra="allow"` exactly so an API-side addition stays readable before the SDK catches
up (CLAUDE.md, "Response models"), and that guarantee died the moment a family became
navigable. Seven families today, fourteen after plan 4.

Falls through to `BaseModel.__getattr__` before raising, keeping both other outcomes
intact: a declared-but-absent field still raises `FieldNotRequested`, and a name that
is neither still raises `AttributeError`. `fields=` narrowing applies to extras just
as it does to declared fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`len(UNMIGRATED_RESOURCES) <= OPT_OUT_CEILING` admits a swap: drop one name, add
another, the count is unchanged and the guard stays green while a brand-new class
quietly opts itself out of every rule sweep. Equality on the length has the same
hole.

Replaces the ceiling with `BASELINE_OPT_OUT`, a frozenset of today's 35 names, and
asserts `UNMIGRATED_RESOURCES <= BASELINE_OPT_OUT`. Every addition fails by name;
removals stay free, so plan 4 pays the backlog down without lowering a constant.

Verified: swapping "Workflows" for "Stickies" leaves the count at 35 (the old ceiling
passes) and fails the new assertion naming Stickies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
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
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