Skip to content

[SILO-1463] feat: add new v2 client for v2 apis - #56

Merged
Prashant-Surya merged 62 commits into
mainfrom
feat/silo-1463-node-sdk-v2
Sep 16, 2026
Merged

Prashant-Surya merged 62 commits into
mainfrom
feat/silo-1463-node-sdk-v2

Conversation

@Prashant-Surya

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

Copy link
Copy Markdown
Member

What this is

The v2 surface, reshaped onto variant F — the design approved by the team. Supersedes the earlier locator-based design this PR originally carried; the review comments above predate it.

There are two ways in, and they compose:

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

// 2. Loaded rows — a fetched row carries its ids; nothing repeats
const project = await client.v2.workspaces.projects.retrieve("acme", "ENG");
await project.cycles.list();
await project.workItems.create({ name: "Ship it" });

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

Scope

  • 90 resource classes, all migrated, wired and reachable from client.v2
  • 407/407 operations verified against the API golden
  • 19 navigable row types; a fetched workspace reaches 25 children, a project 19
  • Path ids are named after their resource, singular, no Id suffix — retrieve(slug, project, state)

Field projection is compile-time

fields narrows the return type via Pick<T, F | "id">, so reading a field you did not request is a type error, not undefined at runtime. This differs deliberately from the Python SDK, which raises FieldNotRequested at runtime — each language uses its strongest available check.

Six enforcement sweeps

Tasks covering 84 of the 90 classes ran without per-task review; these sweeps were the compensating control. Each enumerates every resource rather than selecting by shape — six separate defects in this migration came from a check that selected its own subject set and silently skipped members:

Sweep Enforces
Path-id naming Every path id named after its resource
expand / fields Exposed wherever the golden declares them
Filters Every declared query filter reachable
ORDER_BY Every declared ordering reachable
Navigation A resource's children match its loaded row's properties
Projection list/iterate/create/update/upsert narrow soundly

Every sweep was proved by introducing the violation it targets and confirming it fails by name.

Verified against a live server

43/50 e2e suites, 258/267 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.

Unit: 955 passing. tsc clean across src + tests/e2e + examples. Lint at baseline. Bundle type-checks (it previously shipped 14 invalid declarations).

Breaking changes

None to any published surface — the v2 namespace has never been published (npm is at 0.2.14, whose tarball contains zero dist/api/v2 files). Within v2: GroupSyncConfig.get is now retrieve, matching every other singleton; the retired locator chain (client.v2.workspace(...)) is deleted.

Known follow-ups

  • Owned is a mapped type, so hovering a navigated method shows no JSDoc — TypeScript attaches none to a synthesized symbol. The caveat sits one hop earlier, on all 82 navigation properties.
  • The exports map / sideEffects / ESM question is left alone deliberately: plane-ee's web editor deep-imports dist/types.bundle.d.ts?raw, so changing it is a packaging policy call.

🤖 Generated with Claude Code

https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs

…operations

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

    const ws = client.v2.workspace("acme");      // zero-I/O locator
    const proj = ws.project("ENG");              // key or UUID
    await proj.workItems.create({ name: "Fix login bug", state: "Todo" });
    await ws.workItems.retrieveByIdentifier("ENG-12");
    await ws.wiki.pages.create({ name: "Runbook" }); // public page -> default collection
    await client.v2.users.me();                  // the six non-workspace operations

- Kernel: own axios instance with RFC 9457 errors and PlaneNetworkError,
  offset/cursor envelopes with a stall guard, ?fields/?expand/?order_by validated
  per operation against the golden, typed `Pick<T, F>` field projection, upsert,
  bulk create/update/delete with per-row results, findByName, custom verb
  actions, scope-bound resources (`new Resource(transport, scope)`).
- Spec-generated constants (`pnpm codegen:v2`) 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 plane-sdk (Python) (camelCase vs snake_case).
- Root exports keep every v1 name; v2 types that collide are aliased `V2*`, and
  `scripts/check-types-bundle.mjs` guards the public export surface on build.
- Unit tests under tests/unit/v2 (nock); e2e under tests/e2e/v2 skip without env.
- CI: unit tests gated; a secret-gated `v2-golden-drift` job regenerates the
  constants against plane-ee's golden.
- Version 0.3.0. v1 surface untouched.

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

coderabbitai Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 341 files, which is 241 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.

Usage-priced reviews support at most 300 files.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 2b92f80f-e13b-483b-a7ff-b8054a35627c

📥 Commits

Reviewing files that changed from the base of the PR and between 05c2ea5 and e038cd2.

⛔ Files ignored due to path filters (2)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • src/api/v2/generated/constants.ts is excluded by !**/generated/**
📒 Files selected for processing (341)
  • .github/workflows/build-test.yaml
  • .github/workflows/publish-node-sdk.yml
  • .gitignore
  • .oxlintrc.json
  • AGENTS.MD
  • CLAUDE.md
  • README.md
  • examples/README.md
  • examples/bootstrap-project.ts
  • jest.config.js
  • package.json
  • scripts/__fixtures__/types-bundle-exports.snapshot.txt
  • scripts/check-types-bundle.mjs
  • scripts/generate-v2-constants.mjs
  • src/api/v2/Artifacts.ts
  • src/api/v2/Assets.ts
  • src/api/v2/AuditLogs.ts
  • src/api/v2/Automations/ProjectAutomationActivities.ts
  • src/api/v2/Automations/ProjectAutomationEdges.ts
  • src/api/v2/Automations/ProjectAutomationNodes.ts
  • src/api/v2/Automations/ProjectAutomations.ts
  • src/api/v2/Automations/WorkspaceAutomationActivities.ts
  • src/api/v2/Automations/WorkspaceAutomationEdges.ts
  • src/api/v2/Automations/WorkspaceAutomationNodes.ts
  • src/api/v2/Automations/WorkspaceAutomations.ts
  • src/api/v2/Automations/index.ts
  • src/api/v2/Collections/Members.ts
  • src/api/v2/Collections/Pages.ts
  • src/api/v2/Collections/index.ts
  • src/api/v2/CustomerProperties.ts
  • src/api/v2/Customers/PropertyValues.ts
  • src/api/v2/Customers/Requests.ts
  • src/api/v2/Customers/WorkItems.ts
  • src/api/v2/Customers/index.ts
  • src/api/v2/Cycles/WorkItems.ts
  • src/api/v2/Cycles/index.ts
  • src/api/v2/Estimates/Points.ts
  • src/api/v2/Estimates/index.ts
  • src/api/v2/Features.ts
  • src/api/v2/GroupSync/Config.ts
  • src/api/v2/GroupSync/ProjectMappings.ts
  • src/api/v2/GroupSync/WorkspaceMappings.ts
  • src/api/v2/GroupSync/index.ts
  • src/api/v2/Initiatives/Labels.ts
  • src/api/v2/Initiatives/Projects.ts
  • src/api/v2/Initiatives/WorkItems.ts
  • src/api/v2/Initiatives/index.ts
  • src/api/v2/Intakes.ts
  • src/api/v2/Invitations.ts
  • src/api/v2/Labels.ts
  • src/api/v2/Members.ts
  • src/api/v2/Milestones/WorkItems.ts
  • src/api/v2/Milestones/index.ts
  • src/api/v2/Modules/WorkItems.ts
  • src/api/v2/Modules/index.ts
  • src/api/v2/Pages.ts
  • src/api/v2/PermissionSchemes.ts
  • src/api/v2/Permissions.ts
  • src/api/v2/ProjectWorklogs.ts
  • src/api/v2/Projects.ts
  • src/api/v2/Releases/Changelog.ts
  • src/api/v2/Releases/Comments.ts
  • src/api/v2/Releases/Labels.ts
  • src/api/v2/Releases/Links.ts
  • src/api/v2/Releases/Tags.ts
  • src/api/v2/Releases/WorkItems.ts
  • src/api/v2/Releases/index.ts
  • src/api/v2/Roles.ts
  • src/api/v2/States.ts
  • src/api/v2/Stickies.ts
  • src/api/v2/Teamspaces.ts
  • src/api/v2/UserAssets.ts
  • src/api/v2/UsersMe.ts
  • src/api/v2/Views.ts
  • src/api/v2/WebhookLogs.ts
  • src/api/v2/Webhooks.ts
  • src/api/v2/Wiki.ts
  • src/api/v2/WikiPages.ts
  • src/api/v2/WorkItemProperties/Options.ts
  • src/api/v2/WorkItemProperties/index.ts
  • src/api/v2/WorkItemRelationDefinitions.ts
  • src/api/v2/WorkItemTemplates/ProjectTemplates.ts
  • src/api/v2/WorkItemTemplates/WorkspaceTemplates.ts
  • src/api/v2/WorkItemTemplates/index.ts
  • src/api/v2/WorkItemTypes/Properties.ts
  • src/api/v2/WorkItemTypes/index.ts
  • src/api/v2/WorkItems/Activities.ts
  • src/api/v2/WorkItems/Attachments.ts
  • src/api/v2/WorkItems/Comments.ts
  • src/api/v2/WorkItems/Dependencies.ts
  • src/api/v2/WorkItems/Links.ts
  • src/api/v2/WorkItems/Relations.ts
  • src/api/v2/WorkItems/WorkLogs.ts
  • src/api/v2/WorkItems/index.ts
  • src/api/v2/Workflows/States.ts
  • src/api/v2/Workflows/Transitions.ts
  • src/api/v2/Workflows/index.ts
  • src/api/v2/WorkspaceMembers.ts
  • src/api/v2/WorkspaceViews.ts
  • src/api/v2/WorkspaceWorkItemProperties/Contexts.ts
  • src/api/v2/WorkspaceWorkItemProperties/Options.ts
  • src/api/v2/WorkspaceWorkItemProperties/index.ts
  • src/api/v2/WorkspaceWorkItemTypes/Properties.ts
  • src/api/v2/WorkspaceWorkItemTypes/index.ts
  • src/api/v2/WorkspaceWorkItems.ts
  • src/api/v2/Workspaces.ts
  • src/api/v2/index.ts
  • src/api/v2/kernel/bulk.ts
  • src/api/v2/kernel/loaded.ts
  • src/api/v2/kernel/pagination.ts
  • src/api/v2/kernel/resource.ts
  • src/api/v2/kernel/transport.ts
  • src/api/v2/loaded/Automation.ts
  • src/api/v2/loaded/Collection.ts
  • src/api/v2/loaded/Customer.ts
  • src/api/v2/loaded/Cycle.ts
  • src/api/v2/loaded/Estimate.ts
  • src/api/v2/loaded/Initiative.ts
  • src/api/v2/loaded/Milestone.ts
  • src/api/v2/loaded/Module.ts
  • src/api/v2/loaded/Project.ts
  • src/api/v2/loaded/Release.ts
  • src/api/v2/loaded/Webhook.ts
  • src/api/v2/loaded/WorkItem.ts
  • src/api/v2/loaded/WorkItemProperty.ts
  • src/api/v2/loaded/WorkItemType.ts
  • src/api/v2/loaded/Workflow.ts
  • src/api/v2/loaded/Workspace.ts
  • src/client/plane-client.ts
  • src/errors/MissingPathIdError.ts
  • src/errors/PlaneApiError.ts
  • src/errors/PlaneNetworkError.ts
  • src/errors/index.ts
  • src/index.ts
  • src/models/v2/Artifact.ts
  • src/models/v2/AuditLog.ts
  • src/models/v2/Automation.ts
  • src/models/v2/AutomationActivity.ts
  • src/models/v2/AutomationEdge.ts
  • src/models/v2/AutomationNode.ts
  • src/models/v2/Bridge.ts
  • src/models/v2/Collection.ts
  • src/models/v2/Customer.ts
  • src/models/v2/CustomerProperty.ts
  • src/models/v2/CustomerPropertyValue.ts
  • src/models/v2/CustomerRequest.ts
  • src/models/v2/Cycle.ts
  • src/models/v2/CycleTransfer.ts
  • src/models/v2/Estimate.ts
  • src/models/v2/EstimatePoint.ts
  • src/models/v2/Feature.ts
  • src/models/v2/GroupMapping.ts
  • src/models/v2/GroupSyncConfig.ts
  • src/models/v2/Initiative.ts
  • src/models/v2/InitiativeLabel.ts
  • src/models/v2/IntakeWorkItem.ts
  • src/models/v2/Label.ts
  • src/models/v2/Member.ts
  • src/models/v2/Milestone.ts
  • src/models/v2/Module.ts
  • src/models/v2/Page.ts
  • src/models/v2/Permission.ts
  • src/models/v2/PermissionScheme.ts
  • src/models/v2/Project.ts
  • src/models/v2/ProjectRoleDistribution.ts
  • src/models/v2/Release.ts
  • src/models/v2/ReleaseChangelog.ts
  • src/models/v2/ReleaseComment.ts
  • src/models/v2/ReleaseLabel.ts
  • src/models/v2/ReleaseLink.ts
  • src/models/v2/ReleaseTag.ts
  • src/models/v2/Role.ts
  • src/models/v2/State.ts
  • src/models/v2/Sticky.ts
  • src/models/v2/Teamspace.ts
  • src/models/v2/UserAsset.ts
  • src/models/v2/View.ts
  • src/models/v2/Webhook.ts
  • src/models/v2/WebhookLog.ts
  • src/models/v2/WhoAmI.ts
  • src/models/v2/WorkItem.ts
  • src/models/v2/WorkItemActivity.ts
  • src/models/v2/WorkItemAttachment.ts
  • src/models/v2/WorkItemComment.ts
  • src/models/v2/WorkItemDependency.ts
  • src/models/v2/WorkItemLink.ts
  • src/models/v2/WorkItemProperty.ts
  • src/models/v2/WorkItemPropertyContext.ts
  • src/models/v2/WorkItemPropertyOption.ts
  • src/models/v2/WorkItemRelation.ts
  • src/models/v2/WorkItemRelationDefinition.ts
  • src/models/v2/WorkItemTemplate.ts
  • src/models/v2/WorkItemType.ts
  • src/models/v2/WorkItemWorklog.ts
  • src/models/v2/Workflow.ts
  • src/models/v2/WorkflowState.ts
  • src/models/v2/WorkflowTransition.ts
  • src/models/v2/WorklogSummary.ts
  • src/models/v2/Workspace.ts
  • src/models/v2/WorkspaceAsset.ts
  • src/models/v2/WorkspaceGroupMapping.ts
  • src/models/v2/WorkspaceInvite.ts
  • src/models/v2/common.ts
  • src/models/v2/index.ts
  • tests/e2e/project.test.ts
  • tests/e2e/support/rate-limit.ts
  • tests/e2e/v2/artifacts.e2e.test.ts
  • tests/e2e/v2/assets.e2e.test.ts
  • tests/e2e/v2/audit-logs.e2e.test.ts
  • tests/e2e/v2/automations.e2e.test.ts
  • tests/e2e/v2/bulk.e2e.test.ts
  • tests/e2e/v2/collections.e2e.test.ts
  • tests/e2e/v2/crud.e2e.test.ts
  • tests/e2e/v2/customer-properties.e2e.test.ts
  • tests/e2e/v2/customers.e2e.test.ts
  • tests/e2e/v2/cycle-actions.e2e.test.ts
  • tests/e2e/v2/cycles-modules-milestones.e2e.test.ts
  • tests/e2e/v2/errors.e2e.test.ts
  • tests/e2e/v2/estimates.e2e.test.ts
  • tests/e2e/v2/features.e2e.test.ts
  • tests/e2e/v2/find-by-name.e2e.test.ts
  • tests/e2e/v2/full-scenario.e2e.test.ts
  • tests/e2e/v2/group-sync.e2e.test.ts
  • tests/e2e/v2/initiatives.e2e.test.ts
  • tests/e2e/v2/intakes.e2e.test.ts
  • tests/e2e/v2/invitations.e2e.test.ts
  • tests/e2e/v2/members.e2e.test.ts
  • tests/e2e/v2/milestone-work-items.e2e.test.ts
  • tests/e2e/v2/module-work-items.e2e.test.ts
  • tests/e2e/v2/pages.e2e.test.ts
  • tests/e2e/v2/pagination.e2e.test.ts
  • tests/e2e/v2/permission-schemes.e2e.test.ts
  • tests/e2e/v2/permissions.e2e.test.ts
  • tests/e2e/v2/project-work-item-types-flow.e2e.test.ts
  • tests/e2e/v2/project-worklogs-summary.e2e.test.ts
  • tests/e2e/v2/projects.e2e.test.ts
  • tests/e2e/v2/releases.e2e.test.ts
  • tests/e2e/v2/roles.e2e.test.ts
  • tests/e2e/v2/stickies.e2e.test.ts
  • tests/e2e/v2/support/capability.ts
  • tests/e2e/v2/support/client.ts
  • tests/e2e/v2/support/env.ts
  • tests/e2e/v2/support/names.ts
  • tests/e2e/v2/support/project.ts
  • tests/e2e/v2/support/specs.ts
  • tests/e2e/v2/support/suite.ts
  • tests/e2e/v2/support/work-item-type-mode.ts
  • tests/e2e/v2/sweep-leftover-projects.e2e.test.ts
  • tests/e2e/v2/teamspaces.e2e.test.ts
  • tests/e2e/v2/upsert.e2e.test.ts
  • tests/e2e/v2/users-me.e2e.test.ts
  • tests/e2e/v2/views.e2e.test.ts
  • tests/e2e/v2/webhook-logs.e2e.test.ts
  • tests/e2e/v2/webhooks.e2e.test.ts
  • tests/e2e/v2/work-item-comments.e2e.test.ts
  • tests/e2e/v2/work-item-properties.e2e.test.ts
  • tests/e2e/v2/work-item-relation-definitions.e2e.test.ts
  • tests/e2e/v2/work-item-relations.e2e.test.ts
  • tests/e2e/v2/work-item-subresources.e2e.test.ts
  • tests/e2e/v2/work-item-templates.e2e.test.ts
  • tests/e2e/v2/work-item-types.e2e.test.ts
  • tests/e2e/v2/work-items.e2e.test.ts
  • tests/e2e/v2/workflows.e2e.test.ts
  • tests/e2e/v2/workspace-work-item-types-flow.e2e.test.ts
  • tests/unit/v2/artifacts.test.ts
  • tests/unit/v2/assets.test.ts
  • tests/unit/v2/audit-logs.test.ts
  • tests/unit/v2/automations.test.ts
  • tests/unit/v2/bands.test.ts
  • tests/unit/v2/bridges.test.ts
  • tests/unit/v2/bulk.test.ts
  • tests/unit/v2/collections.test.ts
  • tests/unit/v2/constants.test.ts
  • tests/unit/v2/customer-properties.test.ts
  • tests/unit/v2/customers.test.ts
  • tests/unit/v2/cycle-actions.test.ts
  • tests/unit/v2/cycles-modules-milestones.test.ts
  • tests/unit/v2/e2e-project-identifier.test.ts
  • tests/unit/v2/estimates.test.ts
  • tests/unit/v2/expand-coverage.test.ts
  • tests/unit/v2/exported-names.test.ts
  • tests/unit/v2/extra-paths.test.ts
  • tests/unit/v2/features.test.ts
  • tests/unit/v2/field-projection.test.ts
  • tests/unit/v2/fields-coverage.test.ts
  • tests/unit/v2/filters-coverage.test.ts
  • tests/unit/v2/find-one.test.ts
  • tests/unit/v2/group-sync.test.ts
  • tests/unit/v2/initiatives.test.ts
  • tests/unit/v2/intakes.test.ts
  • tests/unit/v2/invitations.test.ts
  • tests/unit/v2/labels.test.ts
  • tests/unit/v2/loaded-navigation.test.ts
  • tests/unit/v2/loader-routing.test.ts
  • tests/unit/v2/lookup-coverage.test.ts
  • tests/unit/v2/members.test.ts
  • tests/unit/v2/milestone-work-items.test.ts
  • tests/unit/v2/module-work-items.test.ts
  • tests/unit/v2/navigable-rows.test.ts
  • tests/unit/v2/navigation-types.test.ts
  • tests/unit/v2/operations-coverage.test.ts
  • tests/unit/v2/order-by-coverage.test.ts
  • tests/unit/v2/pages.test.ts
  • tests/unit/v2/pagination-coverage.test.ts
  • tests/unit/v2/pagination.test.ts
  • tests/unit/v2/path-id-naming.test.ts
  • tests/unit/v2/permission-schemes.test.ts
  • tests/unit/v2/permissions.test.ts
  • tests/unit/v2/project-worklogs-summary.test.ts
  • tests/unit/v2/projects.test.ts
  • tests/unit/v2/readme-samples.test.ts
  • tests/unit/v2/releases.test.ts
  • tests/unit/v2/resource.test.ts
  • tests/unit/v2/roles.test.ts
  • tests/unit/v2/singleton-verbs.test.ts
  • tests/unit/v2/states.test.ts
  • tests/unit/v2/stickies.test.ts
  • tests/unit/v2/teamspaces.test.ts
  • tests/unit/v2/transport.test.ts
  • tests/unit/v2/tree-walk.ts
  • tests/unit/v2/users-me.test.ts
  • tests/unit/v2/views.test.ts
  • tests/unit/v2/webhook-logs.test.ts
  • tests/unit/v2/webhooks.test.ts
  • tests/unit/v2/wiki-pages.test.ts
  • tests/unit/v2/work-item-comments.test.ts
  • tests/unit/v2/work-item-properties.test.ts
  • tests/unit/v2/work-item-relation-definitions.test.ts
  • tests/unit/v2/work-item-relations.test.ts
  • tests/unit/v2/work-item-subresources.test.ts
  • tests/unit/v2/work-item-templates.test.ts
  • tests/unit/v2/work-item-types.test.ts
  • tests/unit/v2/work-items.test.ts
  • tests/unit/v2/workflows.test.ts
  • tests/unit/v2/workspace-members.test.ts
  • tests/unit/v2/workspace-views.test.ts
  • tests/unit/v2/workspace-work-item-types.test.ts
  • tests/unit/v2/workspace-work-items.test.ts
  • tests/unit/v2/workspaces.test.ts
  • tsconfig.jest.json
  • tsconfig.json

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


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

❤️ Share

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

@makeplane

makeplane Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

Linked to Plane Work Item(s)

This comment was auto-generated by Plane

Comment thread tests/e2e/v2/full-scenario.e2e.test.ts Outdated
Comment thread tests/e2e/v2/full-scenario.e2e.test.ts Outdated
Comment thread tests/e2e/v2/full-scenario.e2e.test.ts Outdated
Comment thread tests/e2e/v2/full-scenario.e2e.test.ts
…ookups (review feedback)

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

Renames (work item type properties, project + workspace scoped):
- WorkItemTypeProperties.attach -> link, .detach -> unlink
- WorkspaceWorkItemTypeProperties.attach -> link, .detach -> unlink
  (operations map keys stay attach/detach: golden operationIds)

Removed every manage* method in favour of add(parentId, ids) / remove(parentId, ids)
bridge verbs that send one verb per call and resolve to the plain id array:
- Cycles.manageWorkItems      -> cycles.workItems.add/remove      (new CycleWorkItems)
- Modules.manageWorkItems     -> modules.workItems.add/remove     (new ModuleWorkItems)
- Milestones.manageWorkItems  -> milestones.workItems.add/remove  (new MilestoneWorkItems)
- Customers.manageWorkItems   -> customers.workItems.add/remove   (new CustomerWorkItems)
- Releases.manageWorkItems    -> releases.workItems.add/remove    (new ReleaseWorkItems)
- Releases.manageLabels       -> releases.labels.add/remove       (on the ReleaseLabels catalog)
- Initiatives.manageWorkItems -> initiatives.workItems.add/remove (new InitiativeWorkItems)
- Initiatives.manageProjects  -> initiatives.projects.add/remove  (new InitiativeProjects)
- Initiatives.manageLabels    -> initiatives.labels.add/remove    (on the InitiativeLabels catalog)
- CollectionMembers.manage    -> collections.members.add(collectionId, CollectionMemberAddItem[]) / .remove(collectionId, userIds)
- CollectionPages.manage      -> collections.pages.add/remove

Kernel: V2Resource.doBridge/doBridgeAt replace the hand-rolled transport.request copies;
1..BRIDGE_MAX_IDS (100) ids enforced client-side (RangeError/TypeError before any request).
The *ManageRequest/*ManageResponse model types are gone from the public surface (one
internal models/v2/Bridge.ts shape backs the kernel); the types-bundle export snapshot is
regenerated intentionally (1450 -> 1425 entries).

New server-side lookups via doFindOne:
- Roles.findBySlug(slug, { namespace? })
- EstimatePoints.findByKey(estimateId, key)
- WorkItemPropertyOptions.findByName / WorkspaceWorkItemPropertyOptions.findByName / WorkItemPropertyContexts.findByName (propertyId, name)

Cycles/Modules/Milestones moved from flat files into folders (index.ts + WorkItems.ts) per the
sub-resource convention; import paths are unchanged.

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

Copy link
Copy Markdown
Member Author

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

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

Checks on the new commit: jest tests/unit/v2 473 passed, operations coverage 406/406, tsc 0, oxlint at baseline, build + export snapshot regenerated (1425 entries). Live e2e not re-run (dev API was stopped).

@coldtea-pr-lens

coldtea-pr-lens Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

◈ PR Lens

🟢 +8 new · 🟠 ~2 changed · 🔴 -0 removed · 2 flows · 21 files · commit e038cd2


Architecture

Architecture diagram for makeplane/plane-node-sdk at e038cd2

10 components touched across 5 lanes.

Open the interactive canvas


Inside the changed components — 2 views

Component view — v2 Engine and Transport

The core request pipeline, validation, pagination, and error-decoding engine for v2 calls

Architecture view of Component view — v2 Engine and Transport in makeplane/plane-node-sdk

Component view — v2 Resource Tree and Row Navigation

The hierarchical v2 resource structure and the loaded row engine that binds child methods

Architecture view of Component view — v2 Resource Tree and Row Navigation in makeplane/plane-node-sdk

Data flow

Data flow diagram for makeplane/plane-node-sdk at e038cd2

Navigating nested resources on loaded rows · Query validation and RFC 9457 error decoding

Open the interactive canvas


The other flows — 1 sequence

Query validation and RFC 9457 error decoding

Sequence diagram of Query validation and RFC 9457 error decoding in makeplane/plane-node-sdk

Drill down
Client Layer — 2 components
🟡 CHANGED PlaneClient

Main SDK entry point, now exposing the v2 namespace alongside existing v1 resources.

🟢 NEW V2Namespace

Root entry point for API v2 (client.v2), exposing workspaces, users, and user assets.

Transport Layer — 2 components
🟢 NEW V2Transport

Dedicated HTTP transport targeting /api/v2 with RFC 9457 problem details decoding.

🟡 CHANGED SDK Errors

Error models extended with RFC 9457 PlaneApiError, MissingPathIdError, and network errors.

v2 Engine & Metadata — 3 components
🟢 NEW V2Resource Engine

Base resource engine providing URL formatting, query parameter validation, and pagination.

🟢 NEW Navigable Row Engine

Decorates fetched records with pre-bound child resources and $loaded metadata.

🟢 NEW Schema Constants

Generated schema constants defining allowed fields, order_by, and expand parameters.

v2 Resource Tree — 3 components
🟢 NEW v2 Workspaces Resource

Workspace resource mounting projects, members, views, and workspace-level resources.

🟢 NEW v2 Projects Resource

Project resource managing states, labels, cycles, modules, work items, and pages.

🟢 NEW v2 WorkItems Resource

Work items resource with sub-resources for comments, relations, and activities.


View

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

Tip

Would you rather run it from CI on a key of your own? Add .github/workflows/pr-lens.yml with coldteadotai/pr-lens/packages/action@v0 and a model key in your repository secrets, say GEMINI_API_KEY. The Action asks Gemini by default, or OpenAI and any endpoint speaking /chat/completions through its provider input.

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

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

Prashant-Surya and others added 19 commits September 8, 2026 23:30
Every method now passes its path ids per call rather than leaning on a scope a
locator bound once. The kernel gains what that needs:

- `extraPaths` + `urlFor(action, ids)`: a method whose URL is not built from the
  class `path` (a bridge that posts at its parent's URL, a report on a sibling
  route) declares its own template, keyed by method name, so `url_for` makes the
  same choice at call time that the sweeps make when they read it. The five
  hand-rolled template call sites move to `urlForTemplate`, which is the raw
  filler they were actually using.
- `MissingPathIdError`: a template key with no value behind it named the key and
  nothing else, which is the first failure a caller hits on the flat shape. It
  now names the resource, the method, the template, the missing id and the ids
  that were supplied. An empty string counts as missing — it would otherwise
  build `/projects//states/`, a well-formed URL pointing somewhere else. (Python
  shipped a bare error here and had to repair it a wave later.)
- singleton read/update helpers for routes whose row *is* the collection, a
  custom-action helper for the response envelopes that are not the resource's own
  row, and a void-action helper for the 204s — the four shapes that were being
  hand-rolled as `transport.request` blocks, one resource at a time.

`scope` survives on the constructor, marked retired, only so the not-yet-migrated
classes keep working mid-migration; the last task of the plan deletes it with the
locators.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
A fetched row becomes the place its children live: its own fields, one navigation
property per child, and `$loaded` carrying the ids that produced it plus the
field names the server actually returned.

`Owned<TResource, TIds>` is the load-bearing type. Generic over the child
resource *and* over the tuple of ids being bound, it maps each method to the same
method minus exactly those leading parameters — so `project.states.list()` is
`Promise<Page<State>>`, `project.states.retrieve("s-1")` is `Promise<State>`, and
`project.states.lst()` does not exist. Python's first attempt returned `Any` here
and it was a Critical finding; in Node this is the whole value proposition, so
`tests/unit/v2/navigable-rows.test.ts` pins the resolved types with a
bidirectional `Exact<>` check (an `extends` assertion alone would pass for `any`)
and with `@ts-expect-error` on a misspelling, on one argument too many, and on a
grandchild that needs its own row's id.

One limit, documented where it bites: TypeScript erases type parameters when it
infers through a conditional type, so a *generic* overload cannot survive the
transform. A navigated `list({ fields: [...] })` answers the full row type rather
than the narrowed one; the flat call keeps the projection. Matching the overload
set instead is strictly worse — the inference erases `F` to its constraint and
claims presence for fields the projection dropped.

`LoadsNavigableRows` gives a resource `load`/`loadPage`/`loadIterate`. `iterate`
has its own helper because it is the one that gets forgotten: Python shipped
`list` navigable and `iterate` plain, so paging silently lost navigation.

Runtime `owned()` prepends ids positionally and refuses a definite parameter-name
mismatch first — every path id is a `string`, so the type system cannot notice a
resource whose leading parameters are ordered differently, and the wrong values
would flow into a well-formed wrong URL.

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

The five resources every other one copies. Each takes the ids its URL template
names as leading positional parameters, in path order, named after the resource
they identify — singular, no `Id` suffix, the method's own primary key included:
`retrieve(slug, project, state)`, never `stateId`. Templates keep the golden's
`{project_id}` spelling.

`Projects` and `WorkItems` return navigable rows from every row-returning method
— `retrieve`, `create`, `update`, `upsert`, `findByName`, `list` and `iterate` —
and the loader is given the caller's `fields`, so presence reflects the response
rather than the request. `Projects.rowId` prefers the readable identifier, so a
fetched project's children hit `.../projects/ENG/states/`, not the UUID.

`Projects.summary` and `roleDistribution` stop hand-rolling their requests: one
goes through the custom-action helper on a row, the other through an `extraPaths`
override, and both are now validated against the golden like any other call.

The tree gains its flat root: `v2.projects`, with `states`/`labels`/`workItems`
under it and `comments` under those. A family joins the flat tree and leaves the
`workspace(slug).project(key)` locator chain in the same move, so neither shape is
ever a stale copy of the other — `locators.test.ts` pins both halves.

The six work-item sub-resources that have not migrated stay attached and stay off
the loaded row: an unmigrated child cannot be navigated to yet, and the opt-out
list is what makes that a scheduled migration rather than an omission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`tests/unit/v2/tree-walk.ts` derives the swept set as **every** resource class
declared under `src/api/v2/`, minus an explicit opt-out list that may only shrink.
The inversion is the point: Python's first attempt selected by heuristic (classes
with a `list`, wired to the tree) and silently left 18 of its 90 classes outside
every sweep, which is how the path-id rule came to be broken across 16 of them
with nothing failing.

TypeScript has no `pkgutil`, so the enumeration is triangulated from three
derivations that must agree — the TypeScript AST's `class X extends V2Resource`
declarations, the modules those files export, and the public barrel — and a
fourth check requires the constructed tree to reach every one of them. A class
exported but unreachable, or declared but unexported, fails. Proving that walk is
what surfaced `GroupSync`'s config singleton sitting outside it: the walk skipped
the attribute name `config` as plumbing, and 88 of 89 classes looked like all of
them. It now skips plumbing by type.

The four sweeps:

- **path-id-naming** — every method opens with exactly the ids its own template
  names, in path order, camel-cased with no `Id` suffix; and no parameter names a
  path id under a suffixed spelling. A body field that merely ends in `Id`
  (`Cycles.transfer`'s destination) is outside the rule, which is proved on a
  template rather than trusted. It also carries the opt-out guards: the ratchet,
  the staleness check, and the rule that a class already flat-shaped cannot stay
  opted out.
- **fields-coverage** / **expand-coverage** — a method whose own operation offers
  the option in the golden must make it reachable. Read off the *types*: in Node
  the params object reaches the kernel as one blob, so declaring the property is
  what makes it reachable and validated, and Python's second "is it threaded
  through?" check has nothing to check.
- **loaded-navigation** — a loaded row's navigation properties must be exactly the
  migrated children its resource attaches; each must wrap its own child, hand back
  an owned view rather than the bare resource, and bind ids into leading
  parameters that actually carry them. This one bit immediately: owned views were
  exposing the kernel's `protected` hooks, which `protected` cannot prevent at
  runtime.

Signatures come from the TypeScript AST, not from the emitted JavaScript: type
erasure destroys parameter types, overloads and doc comments, and parameter
*names* are the rule being enforced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Proving the path-id sweep bites is what found this: renaming a primary key on the
*implementation* signature alone left the first overload — and so the hover text
the rule is written about — untouched, and the sweep passed. An overload set can
disagree with itself, so every declaration is now checked.

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

Proving the navigation sweep is what found this. Its "wraps its own child" check
compared method names, and sibling resources routinely have identical method sets
— `owned(this.states, …)` under `labels` builds a well-formed call to the wrong
URL and passed. An owned view now carries its binding under a symbol
(non-enumerable, outside the public type), and the sweep compares identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Artifacts, AuditLogs, PermissionSchemes, Roles and both permission
singletons take their path ids per call. Three Artifacts methods that
hand-rolled `transport.request` now go through the kernel's custom-action
helper, and both `permissions/me/` singletons through the singleton read
rather than `collectionUrl` by hand.

`Roles` carries over the collision Python found: the golden's own `?slug=`
role filter collides with the leading path id, which is the workspace. It
is exposed as `roleSlug` and mapped back onto the `slug` query key, so the
filter stays reachable rather than being suppressed.

Opt-out list: 84 -> 78.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Stickies, Teamspaces, WorkspaceViews, ProjectViews, CustomerProperties,
WorkItemRelationDefinitions and both work-item template resources take
their path ids per call, and now expose `?fields=`/`?expand=` on create
and update wherever the golden declares them — the coverage sweeps read
that off the golden rather than off a list, so the omissions surfaced as
soon as the classes left the opt-out.

`ProjectWorkItemTemplates.use` moves from a hand-rolled request onto the
kernel's custom-action helper; its response is a work item, not a
template row.

Opt-out list: 78 -> 70.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
The band the plain recipe does not cover on its own:

- singletons with no primary key — both `Features` classes, both
  `Permissions` classes' siblings, the group-sync config and `Users.me` —
  now read and write through the kernel's singleton helpers;
- alternate templates — `WorkspaceWorkItems.retrieveByIdentifier`,
  `WorkspaceMembers.remove` and `Invitations.bulk` move their hand-built
  URLs into `extraPaths`, so `urlFor` builds them and the shape sweep
  checks each method against the template it actually uses;
- `WebhookLogs` carries its webhook id in the *collection* path, so it is
  a leading id on `list` as well as `retrieve`;
- `GroupSyncProjectMappings` is workspace-level despite its name — its
  doc comment now says so, because the name invites the opposite guess.

`Assets.create` and `UserAssets.create` deliberately stop offering
`?fields=`: their presigned upload data exists only in that one reply, so
a projection could drop it beyond recovery. Both are named in
`ONE_TIME_RESPONSES` with the reason, which the fields sweep checks is
repeated where a reader of the method will see it.

Opt-out list: 70 -> 55.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`ProjectWorklogs.summary` answers a bare JSON array rather than a `Page`
envelope, so it moves onto the kernel's custom-action helper instead of a
hand-rolled request, with the shape said plainly in its doc comment.

Opt-out list: 55 -> 53.

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

All six — activities, attachments, links, worklogs, relations,
dependencies — take `slug, project, workItem` per call, replacing the
per-class private `pk()` helper that existed only to re-supply the ids a
locator had bound.

`Relations` and `Dependencies` answer one dict-shaped object rather than a
`Page`, so their `list` reads through the kernel's singleton helper, and
both `delete`s are keyed by the *related* work item: the API mints no id
for a relation row, so the pair identifies which one to remove.

Migrating them makes `LoadedWorkItem` owe a navigation property per child,
which the navigation sweep demands the moment a child leaves the opt-out —
so a fetched work item now reaches all seven of its children, not just
comments. `Attachments.create` joins the two asset creates in
`ONE_TIME_RESPONSES`: its presigned upload data is not re-fetchable.

Opt-out list: 53 -> 47.

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

The locator doc comments and `locators.test.ts` both claimed a family
leaves the locator the moment it goes flat. That was true for `states`,
`labels` and `workItems`, which had `Projects` waiting for them; it is not
true of the workspace band, which is migrated but has no flat attachment
point until the tree wiring. Rather than leave a rule the code visibly
breaks, both now say the real one — the locator is a holding pen, and what
it holds already takes its ids per call — with a test that proves it by
passing a slug that differs from the bound one.

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
Two holes in the enforcement sweeps, both of the same shape: a class can be
outside a rule because of how the swept set is derived, not because anybody
decided it should be.

`loaded-navigation.test.ts` put every navigation assertion inside
`describe.each(NAVIGABLE)`, and `NAVIGABLE` is chosen by `prototype instanceof
LoadsNavigableRows`. A family that migrates, attaches its migrated children and
stays on plain `V2Resource` was therefore checked by zero navigation assertions
while every URL test passed — the hole the Python SDK carried for four plans,
found only when `workspaces.retrieve()` turned out to answer a bare row whose 24
children were unreachable. Attaching a migrated child now obliges a resource to
be navigable.

`path-id-naming.test.ts` compared entries against the constructed tree in one
direction only, so a class extending a shared derived base — invisible to
`isResourceHeritage`, which matches the two kernel base identifiers literally —
was outside all four sweeps and outside the barrel check too. The comparison is
now symmetric: a class the tree reaches that no entry declares fails.

Proved by introducing each violation and confirming the sweep names it:
a migrated `States` attaching `labels` while extending `V2Resource`, and a
`StatesProbe extends States` wired at `v2.projects.probe`.

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

`methodsOffering` looks a method's action up in its class's `operations` map and
`continue`s when it finds nothing, so a method with no entry is checked for
neither `fields` nor `expand` — silently, and with nothing anywhere recording
that it was skipped. The existing coverage net could not see it: it asks only
whether each golden operationId is declared somewhere, never whether a given
method name has a key.

The gap is worst where the migration is least regular. A CRUD five-pack is spelt
the same way everywhere; `retrieveByIdentifier`, `summary`, `roleDistribution`
and the membership bridges each need a key written by hand, and those are the
ones that would have escaped both option sweeps.

`operationActionFor` resolves the key a method actually spends — `iterate` pages
`list`, and every `findBy*` is a filtered `list` through `doFindOne`. It is kept
separate from `ACTION_ALIASES` on purpose: a `findBy*` takes the value to match
and no params object, so folding it into the option sweeps would demand a
`fields` the wrapper has nowhere to put.

Proved by renaming `Projects`' `summary` key to `summaryTypo`: the new
assertion named `Projects#Projects.summary()` while the golden->declared check
stayed green, which is exactly the divergence it exists to close.

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

Two kernel defects in the loaded-row machinery, both of which fire in ordinary
downstream use rather than in the tests.

`loadRow` defined navigation properties with `defineProperty`, which overwrites
the spread data property. A navigation property named after a real API field
therefore replaced that field silently while `$loaded.present` went on reporting
it present — verified by pointing `Projects`' navigation at `name` before the
fix: `typeof row.name` was `object` and `present.has("name")` was still `true`.
The collision is real, not hypothetical: Python needed aliases for
`Estimate.points` and `WorkItemProperty.options`, both families task 3 still has
to migrate. `loadRow` now refuses the collision where it happens, and the sweep
builds a row carrying every field the golden says the resource returns — `rowOf`
builds `{ id }` alone, so no assertion could ever have seen a collision.

`assertLeadingParameters` read parameter names out of `Function.toString`. Under
esbuild or terser those are mangled, so it saw `[a, b]` against `[slug,
project]`, called it a definite mismatch, and threw a `TypeError` on every
navigated call — hardest on entirely correct consumer code. It is now gated on a
canary whose own parameter names are mangled by the same pass, so it can tell
"wrong order" from "names are gone" and stands down for the latter. Nothing is
lost: the source-level sweep enforces the same rule against the TypeScript,
where names cannot be mangled, and is the authoritative check.

Proved by deleting the gate line and watching the new regression test throw the
old TypeError, and by pointing a navigation property at a real field and
watching the new sweep name it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`iterate` took the same `ListXParams` as `list`, `fields` and all, and declared
`AsyncGenerator<FullRow>` — so a caller who asked for two fields got a type
promising every field the server had just been told to omit. That is the same
unsoundness used to reject matching the overload set in `Owned<…>`, sitting in
the exemplars and copied into every class that followed them.

`iterate` now mirrors `list`'s overload set across all 32 migrated classes that
declare both, narrowing to `Pick<Row, F | "id">` when `fields` is a literal
tuple and answering the full row otherwise.

The rule is swept rather than left to the 32 edits: `list` and `iterate` are the
same operation — `ACTION_ALIASES` already maps one onto the other — so `iterate`
must narrow exactly where `list` does. That needs no exception list, because it
never asks a class to narrow, only to be consistent with itself, and it holds
for the classes task 3 has yet to migrate without anybody remembering it.

Proved twice: the compile-time test now needs its `@ts-expect-error` on an
iterated row's unrequested field (before the change TypeScript reported the
directive as unused, which is the unsoundness stated as a compiler error), and
reverting `Labels.iterate` to its single declaration made the sweep name
`Labels#Labels`.

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

`generated/constants.ts` was built from a plane-ee checkout sitting on an
unrelated branch at 406 operations, so Node's generated types silently lacked
capabilities the API had shipped on 2026-09-04. Regenerated from the detached
`origin/preview` checkout at 407: `workspaces_retrieve` and its `FIELDS` entry
are now present, and the header records the golden it actually came from.

The refresh also brought the `display_name` query filter, which `origin/preview`
declares on all three property list operations and the stale copy declared on
none. Task 2 left `find_by_display_name` off customer properties for exactly
that reason, so it lands here with the filter on `ListCustomerPropertiesParams`,
mirroring Python's `plane/api/v2/customer_properties.py`. `display_name` is the
label a person sees on screen; `name` is the slug the server derives from it, so
this is the lookup a caller reaching for a property they can see actually wants.

The one thing the refresh flagged that is not fixed here is `workspaces_retrieve`
itself: implementing it means introducing the `v2.workspaces` root, which the
variant-F plan schedules for the tree-wiring task, so guessing its shape now
would pre-empt that design. It is recorded in `UNIMPLEMENTED_OPERATIONS` with
its reason, guarded from both ends and ratcheted — an entry that is not a real
golden id fails, an entry that turns out to be implemented fails as stale, and
the list cannot grow. Proved by adding `states_list` and `not_a_real_operation`
and watching both guards name the right one.

The `expand` and `fields` sweeps flagged nothing new: the operations the refresh
touched are the one nobody implements yet.

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
Prashant-Surya and others added 19 commits September 9, 2026 02:43
…surface

The support layer now goes through the shipped resources instead of the raw
transport. `createProject`/`deleteProject`/`sweepLeftoverProjects` were written
when there was no v2 projects resource to call; there is one now, so every
suite's `beforeAll` doubles as a live assertion that `Projects.create`'s URL and
body shape are right — the defect class the unit sweeps structurally cannot see.
The sweep also pages through `projects.iterate`, which follows whichever
envelope the server sends; the hand-rolled offset loop it replaces would have
silently swept nothing against a cursor-paginating deployment.

`V2Suite` now carries the suite project as a fetched, navigable row
(`projectRow`) and a lazy `workspace()`, because a suite that holds only the two
ids can only ever drive one of the two shapes the SDK ships.

`support/specs.ts` grows both ways in — `flat(client, slug, project)` passing the
path ids per call, `navigated(projectRow)` taking them off a fetched row — so
the behavior suites can run the same assertions over both.

The small suites are split deliberately rather than uniformly: artifacts,
stickies, teamspaces, project features/members/permissions/worklogs and intakes
are driven navigated; roles, invitations, assets, audit logs, permission
schemes, webhook logs and workspace members stay flat. `roles` asserts the two
agree on the same rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`crud.e2e.test.ts` now runs its whole body twice, flat and navigated, over both
resources. That is the point of the file: the two shapes reach the same HTTP but
not through the same code, and only the navigated one goes through `owned()`.
A mis-ordered pair of prepended path ids is two strings in two string
parameters — the type checker cannot see it, the sweeps cannot see it, and the
URL it builds is well-formed and points at the wrong project. A live call is the
only thing that catches it.

The other `SPECS` suites each name one way in and say why, chosen so that every
method on `ResourceOps` is exercised navigated somewhere: `bulk` (the three
`bulk*`), `pagination` (`iterate`, a generator method wrapped like any other),
`find-by-name` (`findByName`, which loses two of its three leading parameters).
`upsert` and `errors` stay flat — neither is sensitive to the way in.

The typed-projection block moves to the flat form because it has to: `Owned`
erases the type parameter through its conditional, so a navigated
`list({ fields })` answers the full row. That is the documented trade-off, and
it now has a live assertion of its own — the projection still happens on the
wire, only the static type widens.

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

Views and pages split along the seam that matters: the project half navigated
off the fetched project row, the workspace half flat. `wiki` and `groupSync`
stay flat by necessity, not preference — both are grouping nodes that consume no
path id of their own, so neither can ever be a navigation property on a row and
each child takes the slug itself. The comments say so at the call site.

`projects.e2e.test.ts` stays flat throughout for the same structural reason: it
exercises `Projects` itself, and the row a fetched project answers reaches its
children, not the collection it came from.

Two uuid-vs-identifier equivalence checks could not stay navigated and moved to
the flat form with a note: a navigated row is already bound to whichever key
`rowId` chose, so it cannot be asked the question.

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

`Owned` drops non-callable members, so a grandchild resource is not reachable
from its grandparent's row: `projectRow.modules.workItems` does not exist,
because the bridge URL needs the module's own id and only the module's row can
supply it. The chain that does work — project row, module row, then the bridge —
is the deepest navigation the SDK offers, and until now nothing exercised it
against a real server. These three files do.

`module-work-items` also asserts the flat call reaches the same bridge, since
these are the URLs with three ids in front and the most room to be
self-consistently wrong.

The cycles/modules/milestones spec table grows the same flat/navigated pair the
shared `SPECS` has; the body runs navigated, and the uuid-vs-project-key check
is flat on both sides with a note saying why it has to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Comments, links, worklogs, attachments, activities, relations and dependencies
are all grandchildren of the project: their URLs carry the work item's id, and
`Owned` drops non-callable members, so `projectRow.workItems.links` does not
exist and never could. The chain that does work is project row, work item row,
then the family — `workItem.links.create({ url })` — and these suites now drive
it that way, which is both the shape the README leads with and the deepest
navigation the SDK offers.

`work-item-relations` also stops reading the relation definitions through the
raw transport: `v2.workspaces.workItemRelationDefinitions` exists on the flat
surface now, and reading it here is one more live check of its URL.

Workspace-level work item routes stay flat (they take the slug alone), as does
the uuid-vs-project-key agreement check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
This file's whole subject is which path template a call lands on: the
workspace-scoped viewset and the project-scoped one differ by governance mode,
and several cases branch between the two inside a single test. Binding a row
would fix the path before the branch and hide the thing under test. The
properties link/unlink pair is flat for a second reason — it is a grandchild, so
a project row cannot reach it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Requests, property values, linked work items, workflow states and transitions,
property options and property contexts are all grandchildren: the URL carries
the intermediate row's id, so the intermediate row is what has to supply it.
Each suite now fetches that row and navigates from it.

The property suites are where the `options` -> `propertyOptions` navigation
rename earns its keep: the row already carries an API field called `options`,
`loadRow` refuses to define a navigation property over real data, and the alias
is what a caller actually types. Nothing exercised it live before this.

Three more raw-transport PATCHes are gone, replaced by the feature and project
resources they were standing in for.

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

`create` was the only row-returning method on any `LoadsNavigableRows` subclass
that returned `doCreate`'s row directly instead of loading it. A *retrieved*
webhook had `.logs`; a *created* one did not — silently, and only for the caller
who created a webhook and then reached for its delivery history. This is the
exact failure `loaded.ts`'s own doc comment warns about: "a method that returns
a plain row instead silently loses navigation."

No sweep could catch it. `loaded-navigation.test.ts` asks whether the resource
is navigable, and `Webhooks` is — through `list`, `iterate`, `retrieve`,
`findByName` and `update`. The hole was one method wide. It surfaced only when
the e2e suite tried to reach `created.logs`, which is the argument for compiling
that suite against the real surface.

Verified as the only instance: a sweep over every `LoadsNavigableRows` subclass
for an implementation that calls a `do*` row helper without `load`/`loadPage`/
`loadIterate` now returns nothing.

Presence needed care. `loadRow` narrows `$loaded.present` to the caller's
`fields`, so passing them straight through would have reported `secret_key`
missing on a projected create while the secret sat in the row. `secret_key` is
absent from `FIELDS.webhooks_create` — it can be neither requested nor dropped —
so it is added to the presence set explicitly, and a test pins that the SDK
still does not claim it when the server did not send one.

Proved by reverting: the three new tests fail by name (`Property 'logs' does not
exist on type 'WebhookCreateResponse'`, `Property '$loaded' does not exist...`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Same split each time, decided by the URL rather than by taste: a collection
whose route takes only the slug is driven flat, and anything whose route carries
an intermediate row's id is driven off that row.

`releaseTags` moved out from under `releases` during the migration and the suite
follows it. A release points at a tag through its own `tag_id` and every tag
route takes the slug alone, so the tag catalog is a sibling of releases, not a
child — nesting it would have implied a path segment that does not exist. The
release *label* catalog is the same shape, while its `add`/`remove` bridge binds
the release and so comes off the row.

Two more raw-transport feature PATCHes replaced by `workspaces.features.update`.

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

`full-scenario` is rewritten navigated, because it is the file that shows what
the surface is for: one `workspaces.retrieve`, one `projects.create`, and from
there nothing repeats an id. `create` answers a row keyed by the project's
readable identifier, so every child call addresses `.../projects/EXYZ/...`
without the scenario ever saying so — and it now asserts that, via `$loaded.ids`.
What stays flat stays flat structurally: `wiki` is a grouping node, and
`workItems.retrieveByIdentifier` deliberately hangs off the workspace so a
readable key alone finds a work item with no project named anywhere.

The two work item type flows stay flat for the reason the type suites do: they
cross the seam governance mode moves.

`tests/e2e/` now contains no `transport.request` outside the 429-retry wrapper,
and no `client.v2.workspace(...)`/`.project(...)` at all — 0 call sites, down
from ~30. The `@deprecated` notes on `Workspace`/`Project`/`V2Namespace.workspace`
claimed that suite as their last consumer; they now say what actually holds them
in place (`locators.test.ts` and `tree-walk.ts`'s triangulation), so deleting
them is a deletion plus one enumeration change rather than a redesign. Left for
its own change.

The export snapshot moves by exactly one line: the `Webhooks` class shape hash,
the intended consequence of routing `create` through `load()`.

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

Two surface removals the cross-SDK parity review asked for, both of them the
same rule: a resource sits at exactly one attribute path, the one its URL names.

**The bound locator chain is deleted, not deprecated.** `V2Namespace.workspace()`,
`Workspace.ts` and `Project.ts` bound nothing — every family they held takes its
ids per call, so `workspace(slug).roles.list(slug)` passed the slug twice — and
the e2e suite stopped calling them in the previous pass (0 call sites). Python
never had them. With them goes `V2Resource`'s `scope` constructor argument, the
pre-flat mechanism that fed them: path ids now reach the kernel only as leading
positional parameters, so there is no second, invisible source that can make a
wrong URL well-formed. `Wiki`, `GroupSync` and `WorkItemTemplates` drop the
scope they were still forwarding.

**`v2.projects` was a second path to a 38-node subtree.** A project's URL is
`/workspaces/{slug}/projects/`, so `v2.workspaces.projects` is where the flat
rule puts it; the root alias contradicted that rule, disagreed with Python, and
would have made documentation generated from the two SDKs disagree too. The
project band root moves with it.

`urlForTemplate` goes as well: an unused protected escape hatch that took a URL
template inline, so a method built on it would have put its real template
outside `extraPaths` — where `tree-walk.ts`'s `templateFor` cannot see it, and
the path-id and filters sweeps would have compared that method's leading
parameters against the class `path` instead. `resource.test.ts` now exercises
the `extraPaths`/`urlFor` route the production classes actually use.

890 unit tests pass (the 10 locator tests went with the locators); tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
The five pagination query parameters had an *exclusion* and no sweep: the
generator's `NON_FILTER_QUERY_PARAMETERS` kept them out of `FILTERS` and handed
them to nothing. That is a hole rather than a decision, and the Python SDK fell
all the way through it — the same five names reserved, none implemented on any
of its 68 list methods, and a workspace audit-log resource that could not be
called at all because the server refuses the offset envelope there.

The generator now emits `PAGINATION` (68 operations), so the question can be
asked, and `tests/unit/v2/pagination-coverage.test.ts` asks it over every
resource class:

- every `offset`/`per_page`/`paginate`/`count` the golden declares is reachable;
- **`paginate` obliges `cursor`** — the golden declares `cursor` nowhere, so this
  one is derived: a method that lets a caller opt into `paginate: "cursor"`
  answers with a `next_cursor`, and with no parameter to send it back that value
  is a dead end. Node had this on all 68 methods and Python did not; 61 params
  types gain `cursor`.
- `iterate` must *not* accept `offset`/`count` — it advances the offset itself
  and unwraps the envelope `count` shapes — so its params type is now
  `Omit<List*Params, "offset" | "count">` on all 200 declarations, checked from
  both ends so a `list` cannot lose them either.

Node already implemented the four declared parameters everywhere, so rule 1
passed on the first run; rules 2 and 3 named 136 and 138 offenders respectively
before the fix. Proved rule 1 bites too by dropping `paginate` from
`ListAuditLogsParams`: it named `AuditLogs.list` and `AuditLogs.iterate` by
operation id. Reverted.

`pagination.test.ts` gains the round trip the parameter exists for: take one
keyset page, read its `next_cursor`, send it back, get the next page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Five naming divergences the cross-SDK parity review found, all of them
user-visible, none of them behavioural.

- **Singleton read verb.** `GroupSync/Config.get` becomes `retrieve`, so every
  singleton read in the SDK is spelled the same way (`features.retrieve`,
  `releases.changelog.retrieve`) and matches Python, which is moving to
  `retrieve` on the same three routes.
- **Path-id words.** `Invitations.retrieve/delete` took `invite`; the resource is
  an invitation, so the parameter is `invitation`. `Workflows/States` took
  `workflowState`; the id names the *state*, not the workflow-state pairing, so
  it is `state`. The rule is "named after the resource, singular, no Id suffix",
  and Python spells both the way the rule says.
- **`LoadedAutomation` -> `LoadedProjectAutomation`**, with the rest of that
  family's names (`ProjectAutomationIds`, `ProjectAutomationNavigation`,
  `PROJECT_AUTOMATION_ID_NAMES`) moving with it. The workspace-scoped twin was
  already `LoadedWorkspaceAutomation*`, so one half of a deliberately-diffable
  pair was unqualified.
- **Eight bare export names get their qualifier.** The seven work-item children
  (`Activities`/`Attachments`/`Comments`/`Dependencies`/`Links`/`Relations`/
  `WorkLogs`) and workspace `Assets` were exported unqualified. None of the eight
  is a *different word* from Python's — they are the same nouns with the
  qualifier dropped — and two of them (`Comments`, `Links`) are names `Releases`
  already claims, which is why that trio was aliased on export in the first
  place. Same treatment now: `Comments as WorkItemComments`,
  `Assets as WorkspaceAssets`, and `WorkLogs as WorkItemWorklogs` — one word,
  matching both the API and this barrel's own `ProjectWorklogs`.

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

**The rule that had no sweep.** "Every row-returning method on a navigable class routes
through `load()`" shipped broken once on this branch — `Webhooks.create` handed back a
plain object and nothing failed, because the navigation sweep asks its question per
*class* and the only test of this rule was scoped to `Projects`: a selection of one. The
fix commit said it had verified the class was the only instance by running exactly the
missing sweep. It ran it and did not commit it. `loader-routing.test.ts` commits it, in
two independently-derived halves: the body must reach a loader if it calls a kernel
helper that answers a row, and the signature must not declare the bare read model. Which
helpers answer a row is read off `kernel/resource.ts`'s own return types, so
`doDelete`/`doBulk*`/`doVoidAction`/`doBridge*` are exempt **by rule** and a new
row-returning helper is in scope the day it is written.

Proved three ways: reintroducing `Webhooks.create`'s exact defect (both halves name it),
breaking only the declared type (Half B alone), and adding a brand-new `doProbe` helper
to the kernel plus a method using it (the derivation picks it up and both halves fire).

**Two more sweeps, both of which found live defects.**

- `extra-paths.test.ts` — only four kernel helpers honour an `extraPaths` override, while
  `templateFor` always prefers it, so a method could satisfy the path-id and filters
  sweeps while building its URL from the other template. Which helpers honour it is
  derived by asking which reach `urlFor`. All 13 declarations were already correct.
- `lookup-coverage.test.ts` — `doFindOne` is `list` with a filter, so a lookup keyed on a
  filter the golden does not declare resolves against the *unfiltered* collection. Found
  `ProjectViews` and `WorkspaceViews` filtering on `?name=` with no `findByName` at all
  (both added), and settled the reported `Workflows.findByName` gap: Python resolves it
  server-side against an operation whose only filter is `search`, so Node adds it
  client-side, the way `Roles`/`Collections`/`WikiPages` already do. The sweep decides
  which mechanism from the golden, in both directions.

**Four weaknesses in the existing sweeps.**

- `ONE_TIME_RESPONSES` was the only exemption list with no ceiling, and its reason-guard
  (`documentation.includes("fields")`) is satisfied by most *correct* doc comments in
  this SDK. It now carries a ceiling of 4 and a named `docPhrase` per entry, checked to
  be long enough, present in that method's own documentation, and present in no other
  swept method's — so boilerplate cannot satisfy it.
- Every exemption and alias list is now keyed `<module>#<Class>`, the rule `tree-walk.ts`
  states and only `bands.test.ts` followed. `Comments` and `Links` are each declared
  twice, so a name-keyed entry silently covered both — and `filters-coverage.test.ts`'s
  `byQualified` Map dropped one of each colliding pair outright.
- `NESTED_BAND_MEMBERS_CEILING = 2` was count-equality sitting at its own size, so it
  admitted a swap. It is a membership subset now.
- `fullRowOf` built its synthetic row from `retrieve`/`list` only, so a navigation
  property colliding with a create-only field passed CI. It enumerates every operation
  now — proved by pointing `Webhooks`' `logs` property at `secret_key`, which only
  `webhooks_regenerate` projects and which the two-operation row therefore never held.

**Grandchildren through an owned view.** Python's `Owned.__getattr__` answered the
sub-resource *unbound*, so `project.estimates.points` meant different things at different
arities. Node's `Owned` is a mapped type and drops non-callable members, so the
expression never type-checked — but it handed a JavaScript consumer `undefined` and then
a `TypeError` one frame from the cause. It now refuses by name, and
`loaded-navigation.test.ts` enumerates that over every (parent, child, grandchild) triple
the tree has, distinguishing "answered undefined" from "answered a V2Resource".

**`$loaded.present` and `id`.** `LoadedMeta`'s doc claimed `id` always counts as present;
the code reports it only when the server sent it. The *contract* was wrong — a set that
reports a field the row does not have is the over-reporting this set exists to avoid — so
the doc is corrected and the behaviour pinned.

933 unit tests pass, tsc clean, lint 165/0.

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

**The README Quick Start did not compile, and the gate that should have caught it was
one fence marker too narrow.** The extractor matched ` ```ts `; the Quick Start was
fenced ` ```typescript `; the floor assertion passed on the twelve blocks it did see. That
one unchecked block named a package that does not exist (`@plane/node-sdk`), declared
`const client` twice, and called `projects.list()` without its required workspace slug —
the first code a new user reads. The block is fixed and the gate now matches every
TypeScript fence spelling, covers `CLAUDE.md` as well, and asserts that *every* fence in
both documents is accounted for as either TypeScript or shell, so a third language cannot
slip past unchecked.

**Prose is checked now too, as far as prose can be.** Four claims in these documents are
facts about this repository rather than prose, so they are compared against it: the module
specifier a sample imports (which `harness()` strips before compiling, so the type-check
never saw `@plane/node-sdk` at all), the `pnpm` scripts the docs tell you to run, the repo
paths they point at, and the batch caps they state. Each was wrong somewhere.

**The bulk cap: two caps, both correct.** A membership bridge takes up to 100 ids
(`BRIDGE_MAX_IDS`, the golden's `maxItems` on 24 schemas); a bulk write takes up to 50
(`BULK_MAX_ITEMS`, on 21). Both documents stated the bridge number as if it were the bulk
one, and the README contradicted itself 95 lines later. Both numbers are documented, the
distinction is spelled out, and the gate refuses any third number.

**The shipped `dist/types.bundle.d.ts` was not valid TypeScript** — 14 × TS2484, in a file
`plane-ee`'s web app deep-imports and feeds to an in-app editor. Thirteen are v1 params
types that lost their bare identifier when a same-named v2 type became reachable, fixed
with the `V2`-alias pattern already in `src/index.ts`; the fourteenth is a v1 class alias
colliding with a v1 model type, fixed by giving the shadowed type its own name
(`WorkItemPropertyValueMap` — the class keeps `WorkItemPropertyValues`, which is what the
root already resolved to). The snapshot guard could not see any of them: it keyed its
comparison map on `scope\texternal`, so where two locals backed one external it kept only
the last — both snapshots held the same 14 duplicate keys, invisible on both sides of
every comparison. It groups by key now, reports duplicates as their own failure, and
`pnpm build` type-checks the bundle.

**166 overload pairs carried JSDoc only on the narrowing overload**, so a plain call
hovered with no description; and for all 66 `list` methods that comment was not a
description but a note about the overload, which is what every completion list showed as
the meaning of `list`. The note is `@remarks` now, the description is on both
declarations, and `field-projection.test.ts` refuses both shapes.

**`Owned` is a mapped type, so a navigated method is a synthesized symbol with no
declaration to hang documentation on** — hovering `project.states.list` cannot show the
narrowing caveat, and nothing in TypeScript short of restating all 520 signatures changes
that. The note now sits on all 82 navigation properties, one hop before the call, and
`loaded-navigation.test.ts` requires it on every one.

Packaging: `ts-jest` moves to devDependencies (every consumer was installing it at
runtime); the 638 declaration/source maps are gone, since `files` publishes `dist` only
and every map referenced a `../src/*.ts` the tarball does not contain — a clean pack drops
from 1280 files / 3.0 MB unpacked to 638 / 2.1 MB. `examples/` compiles under
`tsconfig.jest.json` now and its broken `"../../src"` import is fixed; the publish
workflow runs the unit tests before publishing; `AGENTS.MD` names the real package; the
README's `pnpm lint`/`pnpm format`/`tests/page.test.ts` commands are replaced with ones
that exist.

942 unit tests pass, tsc clean, lint 165/0, format clean, build green.

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

`v2.BRIDGE_MAX_IDS` was documented in three places by the commit that fixed the caps
prose, and exported from none — a reader sizing a bridge call off it read `undefined`.
It is exported now, and the snapshot gains exactly that one name (1700 -> 1701).

The more useful half is the gate, which could not have caught it and could not catch
three other defects it was built for. Each hole is closed and each is proved by
reintroducing the exact defect, watching the check name it, and reverting:

- **Caps were context-blind.** The check held a set `{50, 100}` and skipped any stated
  number in it, so documenting the bulk cap as 100 — lane 3's HIGH finding — passed.
  Stated numbers are now bound to the nearest cap constant named beside them and
  compared against *that* constant's value. Proved both ways: `100` next to
  `BULK_MAX_ITEMS` and `50` next to `BRIDGE_MAX_IDS` each fail by name.
- **The fence census could not see an untagged fence** (` ```(\w+) ` needs a language
  word), so a broken sample inside one was invisible. Fences are now paired by scanning
  lines, and every fence must carry a language the gate accounts for. Proved with a
  bare ``` fence containing a type error.
- **Imports were stripped before compiling**, so a sample could import a name the
  package does not export. Imports are now hoisted, merged and compiled with the
  published specifier rewritten to `./src`. Proved with `ThisExportDoesNotExist`.
- **A `v2.` name in prose was compiled by nothing** — which is how the bug above
  shipped. Every `` `v2.name` `` must now resolve to a module export or a property of
  the client's `v2` namespace. Proved with `v2.raiseForFailuresOnEveryRow`.

`AGENTS.MD` joins the gate, and its eight TypeScript fences were the reason to. They
described a design that was sketched and never built: a `fetch`-based `BaseResource`,
`ProjectApi`/`WorkItemApi`, an `src/Configuration.ts` with `basePath` and no
`validate()`. None of it compiles, and none of it could be corrected into truth — an
agent following it wrote code this repository rejects. The architecture sketches are
replaced by a pointer to the two documents that are kept true (`CLAUDE.md`,
`README.md`), a real directory tree, and two compiled usage samples; the conventions
section it exists for is retained.

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

`tsconfig.jest.json` is the config that proves `src`, `tests/e2e` and `examples`
type-check together, and no gate invoked it: `pnpm build` compiles `tsconfig.json`
(`src/**/*` only), ts-jest reads the file for `compilerOptions` and ignores its
`include`, and no workflow ran a bare `tsc`. So `examples/` was compiled by nothing at
all — re-breaking it was invisible to CI. `pnpm check:types` now runs it, before the
build so a type error is reported as one; proved by restoring the `@makeplane/...`
import in `examples/bootstrap-project.ts` and watching CI's command fail on it.

The rest are one-liners that had gone stale:

- `examples/README.md` still told new examples to import from `'../../src'` — two
  levels up, one more than there is — the specifier that was just corrected everywhere
  else.
- `extra-paths.test.ts` said "13 declarations across 13 classes"; the population is 15.
  The number is gone rather than corrected: the sweep enumerates, so a count in a
  comment is only a second copy of a fact nothing keeps true, and the floor's job is
  just to make an empty derivation red.
- `workflows.e2e.test.ts` still called its local `workflowState` after the path-id
  rename moved that word to `state`; it is `attachedState` now, matching the `attach`
  verb that produces it.

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

**Singleton read verbs.** `GroupSyncConfig` spelled its read `get` where Python spelled
the same endpoint `retrieve`; the fix was a one-line edit and a one-line test change, so
the next divergence would land in silence. `operations-coverage.test.ts` cannot help —
it only asks that a method name match *some* declared `operations` key, which
`operations = { get: … }` plus `get()` satisfies in perfect self-consistency.

`singleton-verbs.test.ts` enumerates instead, and needs no list at either end: which
helpers are singleton helpers, and the default `action` each declares, are read off
`kernel/resource.ts`; what a method must be called is the action its own call sends. So
`Permissions.me`, `UsersMe.me` and the dict-shaped `WorkItemDependencies.list` are not
exceptions — each already agrees with itself — and a method called `get` that sends
`"retrieve"` cannot. Proved by restoring `GroupSyncConfig.get` (both the rule and the
blunter "no `get` anywhere on v2" fire by name) and by renaming `Features.retrieve`.

**Names the SDK says out loud.** The grandchild refusal told the reader to "call
Comments flat with every id" — a symbol the export surface does not contain, since the
class is exported as `WorkItemComments`. The one instruction the message exists to give
sent them nowhere, and two doc comments did the same: `UserAssets.ts` pointed at
`Assets.create`, and `WorkItems/Dependencies.ts` at `{@link Relations}`, which is
neither exported nor in scope in that file.

Eleven classes are aliased this way. `constructor.name` cannot see the alias and does
not survive a minifier, and the barrel cannot be read from the kernel (`../index`
imports it), so each aliased class now declares a `publicName` static and
`exported-names.test.ts` pins every one of them to the barrel in both directions —
an alias with no static fails, a static naming a non-export fails — and requires every
resource in the tree to resolve to a real export name, which is what makes any message
built from `exportedName()` importable by construction. A second sweep refuses an
unqualified spelling in a symbol position (`` `Comments` ``, `{@link Comments}`)
anywhere under `src/api/v2` except the barrel and the declaring file, both derived
rather than listed. Proved by dropping a static, by pointing one at a name the barrel
does not export, and by restoring `{@link Relations}`.

The snapshot keeps its 1701 entries; eleven hashes move, one per new static.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
The first live run surfaced this: `useV2Project` registers its project-delete
`afterAll` when it is called at the top of the describe, and Jest runs afterAll
hooks in declaration order, so the fixture's project deletion ran before this
file's work item deletion. The work item was already gone with its project, and
the delete 404'd, failing the suite after all four tests had passed.

Project deletion cascades to its work items, so the hook was redundant as well
as misordered. Only this suite deleted a project-scoped child in its own hook;
the other ten suites that register an afterAll alongside the fixture clean up
workspace-scoped resources, which survive the project and are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
@Prashant-Surya Prashant-Surya changed the title [SILO-1463] feat: api_v2 support via client.v2 (406 operations) [SILO-1463] feat: v2 surface on variant F — flat path + loaded rows (407 operations) Sep 9, 2026
Prashant-Surya and others added 6 commits September 9, 2026 16:50
Only conflict was the version: this branch had already bumped to 0.3.0 for the
v2 surface, while main remained at 0.2.14. Kept 0.3.0 — main has published
nothing past 0.2.14, and this adds an entire namespace.

The other commit on main, "add parent to work item comment model and create
request" (#57), touches src/models/Comment.ts, which is the v1 model. 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, so the capability does not exist on that surface yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Two CI failures, one that fired and one that was waiting to.

The build failed because the export snapshot was generated before merging main.
That merge brought in `parent` on WorkItemComment and WorkItemCommentCreateRequest
(v1 models, via #57), so both declarations legitimately changed shape while the
snapshot still recorded the old hashes. Regenerated: exactly those two entries
move, count unchanged at 1701, no additions and no removals. The guard was right.

The second had not surfaced yet. `v2-golden-drift` regenerates constants.ts 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 documents as `../plane-ee/apps/api/plane/api_v2/core/schema/openapi`.
An earlier commit here (3aab31d) rewrote that header to `../plane-ee-preview/...`
when the golden was regenerated from a preview checkout, which would have failed
the drift check the moment PLANE_EE_CHECKOUT_TOKEN was configured; the job is
skipped without it, so nothing reported it. 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Four .superpowers/*.md files were committed: internal working notes from this
migration's implementation passes, not documentation anyone consuming or
reviewing this SDK needs. They were tracked here only because .superpowers/ was
missing from .gitignore -- the Python SDK already ignores it, so nothing
equivalent was ever committed there.

Untracked and ignored; the files stay on disk locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
CI's last run failed 8 v1 tests plus the file's own teardown with
`HttpError: Request failed with status code 429`. Nothing in `project.test.ts`
was wrong: v1 goes through `BaseResource` -> the *global* axios instance, which
had no rate-limit handling at all, while every v2 file was already waiting out
its 429s through `createV2Client`'s wrapper. One API key carries the whole run
and the server throttles per key, so the v1 suite was being failed by the quota
the v2 suites spend.

Lifts that policy into `tests/e2e/support/rate-limit.ts` and gives both versions
a way in:

- v2 keeps wrapping `transport.request` (its axios instance is private, and the
  decoded 429 lands on `PlaneApiError`, whose `detail` states the wait).
- v1 installs a global axios response interceptor, which sees the response and
  so reads the real `Retry-After` header DRF sets. Installed from the e2e file
  rather than `tests/helpers/test-utils.ts`, which the nock-driven unit suites
  also import.

The fallback for a 429 that states no wait was a flat 15s; it is now exponential
(2s, 4s, 8s, 16s, 32s), which spans the server's one-minute window. Bounds are
unchanged in spirit and tightened in fact: 6 attempts, 150s of waiting per
request, 60s per individual wait. Past those the 429 propagates and the test
fails, because at that point the run is not being slowed by the limiter, it is
being defeated by it.

Verified against nock: a 429 carrying `Retry-After` is replayed on both paths,
and a 500 (v1) / 402 (v2) is not retried.

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

CI runs this suite against a workspace with no licence for several EE features,
so the server answers `402 payment_required` and 68 tests across 12 files failed
for a reason that has nothing to do with the SDK. That is an environment
limitation, and the Python suite has treated it as one since
`tests/v2/integration/_guard.skip_absent_capability`. This is the Node
counterpart of that guard, and it keeps the property the guard exists for: a
suite that skips silently is worse than one that fails.

`tests/e2e/v2/support/capability.ts` holds it. Three rules:

- **Only a 402 arms a gate.** There is no hand-arm switch; both entry points take
  a thrown error and check `status === 402`. A 400, 403, 404, 409 or 500 is
  rethrown untouched and stays a failure. The status alone decides, because a
  402 has been observed live carrying `code: "server_error"` rather than
  `payment_required` (the artifacts route), and matching on the code would have
  turned that missing licence back into a red suite.
- **Every sit-out is announced**, tagged `server capability absent -- ` and
  naming the flag the server refused on, per test and once per file.
- **A 402 mid-test still tears down.** `capability.it` catches the refusal only
  after the body's own `try/finally` has unwound.

Files gated, each driven by a 402 seen in CI's log rather than by guessing:
releases, customers, customer-properties, automations (two flags, two gates),
work-item-templates, workflows, audit-logs, project-worklogs-summary,
artifacts, the worklogs describe of work-item-subresources, and the custom
property steps of full-scenario and project-work-item-types-flow. Two tests are
deliberately left ungated because they validate client-side and never reach the
network: `customers` "rejects an unknown fields value" and `workflows` "rejects
an unknown order_by".

group-sync and work-item-relation-definitions already swallowed their 402s into
a bare `return`, which is the silent skip the Python guard converts to a
failure; they now announce through the same gate.

Proven by forcing the transport's answer: forcing 402 on `/releases` sits all 9
tests out naming FeatureFlag.RELEASES; forcing 403 or 500 on the same path fails
all 9; forcing 402 on the gated `beforeAll` sits the file out, while 500 there
still fails it; and forcing 402 on a release *retrieve* after a successful
create leaves the workspace's release count unchanged, so the `finally` ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
This test guards with `skipUnlessMode(mode, "project", ...)`, and every workspace
it had run against until now was in workspace mode -- so it returned early and
its assertions had never executed. CI's workspace is in project mode, ran them
for the first time, and the one on the response body was wrong.

`enable` selects the row it answers with `is_epic=False, is_default=True` and
returns that, creating the Epic separately via `_ensure_epic_exists`. So
`enabledAgain.is_epic` is false by construction and can never be true; the old
assertion could not have passed anywhere the test actually ran.

Now pins the real contract on both sides: the response is the default type, and
the Epic is observable through the list -- which is the invariant the test name
already claimed. Strictly more than the old assertions checked, not less.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
@Prashant-Surya Prashant-Surya changed the title [SILO-1463] feat: v2 surface on variant F — flat path + loaded rows (407 operations) [SILO-1463] feat: add new v2 client for v2 apis Sep 16, 2026
@Prashant-Surya
Prashant-Surya merged commit 537b1b0 into main Sep 16, 2026
5 checks passed
@Prashant-Surya
Prashant-Surya deleted the feat/silo-1463-node-sdk-v2 branch September 16, 2026 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants