[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 85 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 Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks 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 12 components touched across 5 lanes. Inside the changed components — 2 viewsComponent view — v2 Kernel & Transport Internal architecture of the v2 CRUD engine, HTTP session transport, and generated OpenAPI validation constants. Component view — Navigable Rows & Loaded Models The Loaded row pattern and Owned proxy binding parent path IDs onto child resource operations. 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
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
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:
Partial data fails loudly rather than reading as
None: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
py.typedmarker. Without it no type checker read this package's annotations at all.Literalaliases for field andorder_byvalues, andTypedDictfilter kwargs, for all 407 operations. A misspelled filter name is now a mypy error.p.states.list()resolves toPage[State], andp.states.lst()is a type error.tests/v2/test_typing.pyruns 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
NotImplementedErrornaming 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
🤖 Generated with Claude Code
https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs