[SILO-1466] feat(v2): variant F surface — flat path, loaded rows, typed filters - #70
[SILO-1466] feat(v2): variant F surface — flat path, loaded rows, typed filters#70Prashant-Surya wants to merge 95 commits into
Conversation
…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
|
Important Review skippedToo 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (2)
📒 Files selected for processing (298)
You can disable this status message by setting the 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. Comment |
|
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
|
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).
Checks on the new commit: |
◈ PR Lens
Architecture 11 components touched across 5 lanes. Inside the changed components — 2 viewsComponent view — V2 Kernel & Typed Navigation The core V2 kernel infrastructure handling HTTP transport, CRUD operations, OpenAPI schema validation, and typed loaded-row navigation. Component view — V2 Resource Hierarchy & Domain Routing The flat attribute-based resource tree routing workspace and project sub-resources to the shared kernel engine. Data flow
The other flows — 1 sequence
Drill down
|
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
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
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
What this is
The v2 surface on variant F — the design approved by the team. Two ways in, and they compose:
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
client.v2_idsuffix —retrieve(slug, project, state)Literalaliases andTypedDictfilters, so**filtersis typed;py.typedships (PEP 561)Field projection raises rather than lying
Reading a field that
fields=excluded raisesFieldNotRequested, 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,
expandexposure,fieldsexposure, 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 passingintegration 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/v2clean.Known follow-up (not in this PR)
FieldNotRequestedprotects resources that have children, because only those return aLoadedrow. Resources without children —states,labels— still returnNonefor 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