Skip to content

API Stability & Deprecation Policy

This page states what integrators can rely on when building against the TruePPM REST API, how the API changes between releases, and how deprecations are announced and retired. It applies to the versioned REST surface under /api/v1/ and the WebSocket channels documented alongside it.

The stable, integrator-facing surface is:

The following are explicitly not part of the stable surface and may change at any time without notice:

  • The Django admin (/admin/) and any internal management endpoints.
  • Undocumented query parameters, response fields, or endpoints not present in the published OpenAPI schema.
  • The exact wording of human-readable detail messages (the machine-readable status code and error code are stable; the prose is not).
  • Ordering of list results where no explicit ordering is documented.
  • Internal header names beyond the documented ones.

Every change to the API falls into one of three classes.

ClassExamplesCompatibility
AdditiveA new endpoint, a new optional request field, a new response field, a new value on an open enum (see Open and exhaustive enums), a new WebSocket event typeBackward-compatible. Ships in any release.
BehavioralA default value change, a validation tightening, a new required-only-on-new-resource constraintAnnounced in the changelog. Ships in a minor release; avoided within a series where possible.
BreakingRemoving or renaming an endpoint or field, changing a field type, changing an existing status code, making an existing optional field required, removing an enum value, adding a value to an exhaustive enumRequires a deprecation window (below) and, at GA, a new API version.

Clients must tolerate additive change. A conforming client ignores response fields it does not recognize and does not break when a new value appears on an open enum or a new event type appears. Building a client that rejects unknown fields will cause it to break on an additive change that this policy considers backward-compatible.

Every enum in the OpenAPI schema carries a vendor extension saying which kind it is:

  • "x-trueppm-enum": "open" is the default. A later release may add a value. On a response field, your client must accept a value it does not recognize. Treat it as “something newer than I know about” rather than as an error. On a request field, the server you are talking to accepts exactly the listed values and answers 400 to anything else. A later release may accept more.
  • "x-trueppm-enum": "exhaustive" marks a closed set. Adding a value to it is a Breaking change. Only a few enums are exhaustive: the structural BlankEnum and NullEnum, the four dependency types (DepTypeEnum), the weekdays (DigestWeekdayEnum), and the discriminator tags that say which variant of a oneOf response you received.

The enum list stays in the schema beside the marker, so generated clients keep their typed known values. Many generators build decoders that reject an unlisted value by default, so configure yours to keep unknown values on open enums. In openapi-generator, that is the enumUnknownDefaultCase option.

Role ordinals are not an enum. Every field that carries a project or program role is published as the integer RoleOrdinal, in the range 1–400. That covers role on project and program memberships (read and write) and on workspace-group project grants, default_member_role, my_role, and max_project_role on /auth/me/. Workspace roles use a separate scale and are not RoleOrdinal: workspace_role on /auth/me/, role_value on workspace members and invites, the role you write to a workspace member or invite, and an SSO provider’s default_role. On workspace member and invite reads, role is a string label, not an ordinal. The five built-in roles are 1, 100, 200, 300 and 400. The ranges between them (2–99, 101–199, 201–299 and 301–399) are reserved for Enterprise custom roles, so an Enterprise installation can return one. To decide what a role may do, compare it with >= against the built-in ordinal at the bottom of its range. Show an ordinal you do not recognize by the range it falls in. A Community edition server accepts only the five built-in ordinals on write.

One stable element is scheduled for deprecation, and the Breaking changes taken without a window since the last release are listed with it — see Current deprecations below. The window mechanism has not yet been exercised through to a removal; the Breaking changes taken so far all bypassed it deliberately, and each is recorded inline in step 3 or under Current deprecations. When a breaking change to a stable element becomes necessary, the intent is for it to go through a deprecation window rather than being removed outright:

  1. Announcement. The deprecation will be called out in the changelog under a Changed or Deprecated heading, and the affected endpoint or field will be marked deprecated in the OpenAPI schema.

  2. Runtime signal. The plan is for responses from a deprecated endpoint to carry a Deprecation header (and, where a replacement exists, a Link header pointing at it), so a client can detect reliance on a deprecated surface without reading release notes. This header does not exist in the codebase today; it will be built ahead of the first deprecation, not asserted as already wired.

  3. Window. Once a deprecation is announced, the intent is for the deprecated element to keep working for at least one full minor release (roughly 10–12 weeks at the current release cadence) before it may be removed — and never less. Security-critical removals would be the only exception, and would be documented as such.

    Invoked once, in 0.4 (#2881). POST /api/v1/integrations/projects/{id}/git-webhook/ answered 401 for a bad or missing signature in 0.3; in 0.4 it answers the same 404 it returns for “no automation configured”. Changing an existing status code is a Breaking change by the table above, and it shipped with no deprecation window because the window would have been self-defeating: the 401 was the vulnerability. It told any caller holding a project ID — which every project Viewer has — that the project has automation enabled with a secret set. A client branching on 401 from that endpoint must treat 404 as the same condition.

    Bypassed a second time, in 0.4 (#3137). The schedule_in_deliver field is removed outright from GET /api/v1/auth/me/ and PATCH /api/v1/auth/me/profile/ with no deprecation window. Removing a field is Breaking by the table above, so this is a policy exception and is recorded as one rather than as routine. The reasoning (ADR-0942 §3): the field was display-only — no rollup, report, export, schedule computation or webhook read it — so the deprecation window would have preserved nothing but the field’s own ability to round-trip a value that changed nothing. Marking it deprecated for a release would have meant publishing, for another 10–12 weeks, a documented and writable setting that the product no longer honors.

    Unlike an ordinary removal, a stale write does not fail silently: a PATCH carrying schedule_in_deliver is refused with a 400 keyed to the field name, so an integration still sending it finds out at the moment it sends it rather than inferring it from a response body. The whole request is rejected, so sibling fields in the same body are not applied.

    Bypassed a third time, in 0.4 (#3301). Board Workshop Mode is removed outright, taking four paths (POST /api/v1/projects/{id}/workshop/start/, .../workshop/end/, .../workshop/force-end/, and GET /api/v1/projects/{id}/workshop/current/), the WorkshopSession and WorkshopParticipant schemas, and the workshop_started / workshop_ended WebSocket event types with it. Removing an endpoint is Breaking by the table above, so this is a policy exception and is recorded as one rather than as routine. The reasoning: the feature was single-user by construction. The web client’s workshop state was component-local and set only in the start mutation’s success handler, no join endpoint was ever built, and start is ADMIN-gated and answers 409 while a session is active — so no second participant could reach an active session by any route, and the presence apparatus behind these endpoints was unreachable. A deprecation window exists to let a client migrate to a replacement; there is no replacement here, and the Deprecation header described in step 2 does not exist to send.

    As with the removal above, a stale call does not fail silently: the removed paths answer 404, so an integration still calling one finds out at the moment it calls rather than inferring it from a response body. The changelog fragment names every removed path, schema, and event type, and the removal is declared in scripts/schema-removal-allowlist.txt so the schema gate treats it as reviewed rather than accidental.

    Bypassed a fourth time, in 0.4 (#3370). Three read endpoints are removed outright: GET /api/v1/teams/{id}/, GET /api/v1/projects/{id}/board/lanes/, and GET /api/v1/tasks/{id}/scope/, together with the BoardLane, BoardLanes and TaskScopeRollup component schemas. Removing an endpoint is Breaking by the table above, so this is a policy exception and is recorded as one rather than as routine.

    What the three have in common is that nothing calls them: a repository-wide search across the web client, its Playwright suite, the mobile app, the MCP server, the docs tree and the three closed-source client repos finds no caller of any of them. A deprecation window exists to give a client time to migrate, and there is no client to give time to. What each one leaves behind differs, and is worth stating exactly:

    • teams/{id}/ — looking a team up by its id alone is gone. Team attributes (name, short_id, is_default, member_count) are still served by the project-scoped list GET /api/v1/projects/{project_id}/teams/, which is what our own web client has always used. The two teams/{id}/members/ routes are unaffected, but they return the team as a bare id, not as an object.
    • board/lanes/ — there is no server-side replacement. The WBS swimlane grouping it returned is not what board-config carries: board-config’s lanes are the named lanes within a status column, a different concept with a different shape. The swimlane grouping is a client-side derivation again, and re-exposing it would be a fresh API-design decision rather than restoring this route. The route has never appeared in a tagged release at all — it was built during the 0.4 cycle and is removed before that line reaches beta — so no client can be holding it.
    • tasks/{id}/scope/ — the rollup behind it stays. services.compute_scope_rollup is ADR-0108 §3 engine logic, is unchanged, and is now covered as a unit rather than through the route. What is withdrawn is only the claim that the scope delta is reachable over the API; a client that needs it has no endpoint today.

    As with the removals above, a stale call does not fail silently: the removed paths answer 404, so an integration still calling one finds out at the moment it calls rather than inferring it from a response body. The changelog fragment names every removed path and schema, and the removal is declared in scripts/schema-removal-allowlist.txt so the schema gate treats it as reviewed rather than accidental.

    Bypassed a fifth time, in 0.4 (#3365). Eight object-ownership refusals change status from 400 to 403, with no deprecation window. Changing an existing status code is Breaking by the table above, so this is a policy exception and is recorded as one rather than as routine. The operations: DELETE and PATCH on /api/v1/projects/{project_pk}/tasks/{task_pk}/comments/{id}/ and .../notes/{id}/, DELETE .../attachments/{id}/, DELETE .../comments/{comment_pk}/reactions/{id}/, and — for the peer-role guard only — DELETE /api/v1/projects/{project_pk}/members/{id}/ and DELETE /api/v1/programs/{program_pk}/members/{id}/.

    The reasoning: each of these refusals answers “you may not do this” — the caller is not the author, uploader, or reaction owner, or holds a role no higher than the member they are removing — and the 400 was an accident of how the guard raised, not a decision about what it meant. The check beside each one already answered 403 for the same class of refusal, so a client branching on status had two status classes for one kind of answer, and a 400 told it to fix a request that was never at fault. A deprecation window would have meant publishing, for another release, a status that instructs clients to retry a request that can never succeed. The six task-collaboration refusals also now carry the stable code they had always named in source but never put on the wire (see Errors and status codes); the two membership refusals keep a bare detail. The last-Owner guard on the two membership deletes is a refusal on the roster’s state and stays a 400.

    A client that branched on 400 from these operations should treat 403 as the same condition. Nothing else about the operations changes: success responses, paths and request bodies are as before.

    Bypassed a sixth time, in 0.4 (#3573). POST /api/v1/skills/ starts answering 201 when it adds a skill to the catalog, having answered 200 on every request since 0.1. Changing an existing status code is Breaking by the table above, so this is recorded as a policy exception rather than as routine.

    The reasoning: the 200 on the create path was never the published contract. The endpoint’s own docstring and the OpenAPI schema both declared 201 for a create, and only 201 — the 200 came from a created/existing flag re-derived after the save from a server_version=0 probe that the model’s save method never leaves behind, so the 201 the schema promised was unreachable. A deprecation window would have meant publishing, for another release, a status the schema never promised and which no generated client was built against. The de-dup path keeps its 200, which the schema now publishes alongside the 201, and the response body is unchanged on both paths.

    A client that treats any 2xx from this endpoint as success is unaffected. One that branched on 200 to mean “already existed” was reading a bug and should branch on 201 for “created” instead.

    Bypassed a seventh time, in 0.4 (#3599). GET /api/v1/projects/{id}/resource-allocation/ and GET /api/v1/programs/{id}/resource-contention/ stop returning email on each resource row. Removing a response field is Breaking by the table above, so this is recorded as a policy exception rather than as routine.

    The reasoning: this is the security carve-out named in step 3’s opening — the field was the last bypass of the org-wide address-harvest control. Both endpoints build their rows by hand and so never reached the resource serializer that enforces that control, and both are gated on Resource Manager+ against the caller’s own project or program, which any account can grant itself by creating a project. A deprecation window on a field in that position would mean publishing a known disclosure for another release.

    A client reading resources[].email from either endpoint should read the resource catalog (GET /api/v1/resources/) instead, where the field is gated. Nothing else about either response changes.

    Bypassed an eighth time, in 0.4 (#3641). user stops being accepted in the request body of PATCH /api/v1/projects/{id}/members/{mid}/ and PATCH /api/v1/programs/{id}/members/{mid}/; a body carrying it is answered 400 where it previously reassigned the membership row and answered 200. Removing a field and changing an existing status code are both Breaking by the table above. The two PatchedProject/ProgramMembershipWriteRequest component schemas are also renamed to …UpdateRequest, which renames the generated model classes in an SDK built from this schema.

    The reasoning: the field was an address-disclosure door. POST /api/v1/projects/ is ungated and makes its caller an Owner, and a membership write answers with the target account’s email address — so an unbounded user let any signed-in account read every address on the installation, one request per account. Reassigning a live row was the same disclosure a second way, and it silently rewrote who held access while keeping the row’s joined_at access evidence. This is deliberately the inverse of the group_by=project decision below, which kept a useless enum value accepted for its window rather than start answering 400: a value with no effect can wait out a deprecation window, and an open disclosure door cannot.

    A client that PATCHed user was reassigning a membership. Remove the member and add the other account instead — two requests, and the second is subject to the same target bound as any other add. role (and role_title on the program route) are unchanged, and the response body is unchanged on both routes.

    Bypassed a ninth time, in 0.4 (#3372). GET /api/v1/projects/{id}/history/summary/ is removed outright, together with the ProjectHistorySummaryView class and its VALID_WINDOWS constant (confirmed unshared — no other view read it). Removing an endpoint is Breaking by the table above, so this is recorded as a policy exception rather than as routine.

    The reasoning: the endpoint shipped 2026-03-25 in 0.1 — the oldest and longest-lived orphan found on the API surface — with a docstring claiming “the UI should call this when the user hits the refresh button”. Nothing ever did; the web client reads /changelog/ and per-task/per-project history instead. Being the oldest orphan made it, in principle, the endpoint most likely to have picked up an unknown external integrator in three-plus releases, which is exactly why a real deprecation window looked more defensible here than for any younger orphan in this list. A repository-wide search — web client (source and Playwright), mobile app, MCP server, docs tree, and this app’s own urls.py — found no caller of it anywhere, including no external one visible from inside the monorepo. A deprecation window exists to give a real caller time to migrate; it has nothing to protect when the search that would find that caller comes back empty, and the age that made this endpoint worth a harder look did not change that answer.

    As with the removals above, a stale call does not fail silently: the removed path answers 404, so an integration still calling it finds out at the moment it calls rather than inferring it from a response body. The changelog fragment names the removed path, and the removal is declared in scripts/schema-removal-allowlist.txt so the schema gate treats it as reviewed rather than accidental. TaskHistoryListView and ProjectHistoryListView’s shared _MAX_HISTORY_ROWS cap, and the unified project changelog (ADR-0201), are unaffected — see ADR-0011’s dated Superseded note.

    Bypassed a tenth time, in 0.4 (#695). POST /api/v1/admin/failed-tasks/{id}/retry/ and POST /api/v1/admin/failed-tasks/{id}/dismiss/ — present in the published v0.3.0-alpha.3 schema — are renamed to .../requeue/ and .../drop/. Renaming an endpoint is Breaking by the table above, so this is recorded as a policy exception rather than as routine; it was not caught and disclosed until #3880, well after the 0.4 cycle that shipped it.

    The reasoning: the rename accompanied a rewrite of what each action does, not just what it is called. The old retry re-enqueued a parked task through a raw send_task side channel with no shipped caller ever exercising it in anger; requeue routes the re-enqueue through the durable outbox-composing workflow backend (ADR-0210 §1) instead, so a broker outage cannot silently drop it. The old dismiss had no audit trail beyond the status flip; drop retains the row with an optional operator note (resolution_note/resolved_by/resolved_at) so the audit survives (ADR-0084, “no silent discards”). Both are workspace-operator-only (IsWorkspaceOperator, superuser-gated — moved off IsAdminUser by #4009) System Health operator actions with no documented external integration and no caller in the web client’s Playwright suite, the mobile app, or the MCP server referencing the old names.

    Unlike a pure removal, the old paths do not 404 into silence any more clearly than a rename normally would: retry and dismiss are gone from the router entirely, so a caller still using either gets a plain 404 with no Deprecation header and no signpost to the new name — this is exactly the gap this entry exists to close. A client calling retry should call requeue instead: an empty body behaves the same (immediate re-enqueue), and the response gains a workflow_id field; an optional backoff_seconds (0–86400, default 0) is new. A client calling dismiss should call drop instead: an empty body behaves the same, and an optional note (up to 1000 characters) is new.

    Bypassed an eleventh time, in 0.4 (#4290). created_by changes type on four response schemas between betas, with no deprecation window. On ShareLink, ShareLinkCreateResponse (/api/v1/projects/{id}/share-links/) and ExternalStakeholder (/api/v1/programs/{id}/external-stakeholders/) it carried the creator’s display name; on BoardSavedView (/api/v1/projects/{id}/board-views/) it carried the creator’s user id as a string. All four now carry the creator’s integer user id, which is what created_by means on every other schema, and is null once that user is deleted. The display name moves to a new created_by_name field on the three schemas that had it, following the existing *_name convention (owner_name, actor_name, assignee_name).

    Changing a field’s type is Breaking by the table above. The exception is available because all four surfaces are new in 0.4 and have only appeared in beta tags. After 0.4.0 the same change would need a full window. Before this change a client could not join created_by across resources, and a display name never parses as an id. A client that rendered created_by on a share link or external stakeholder should read created_by_name instead. A client that compared a board view’s created_by to a user id should compare it as a number. /auth/me/ still returns id as a decimal string, so convert one side before you compare.

    Bypassed a twelfth time, in 0.4 (#4593). The RoleEnum component is removed and replaced by RoleOrdinal, a banded integer. It is recorded under Current deprecations below.

    These exceptions are available because TruePPM is pre-1.0 alpha and the v1 surface is not yet under a GA compatibility promise. They should not be read as a precedent for removals after GA.

  4. Removal. The element would be removed only after the window elapses, in a minor release before GA or in the next major version at and after GA.

This list is enforced, not just maintained. CI diffs the committed OpenAPI schema against the schema at the latest v* release tag (prereleases included) and fails the merge request on any Breaking change in the table above: a removed endpoint, method, response status code, property, parameter or enum value, a changed type, or an optional field made required. A removed enum value is flagged in both the request and the response direction; an enum value addition is Additive and never trips it. A response property that became optional and a request body that became required are Breaking too. A Breaking change can merge only with an allowlist entry that cites one of the headings below, which is why each accepted break has a row here. (scripts/check-schema-breaking.py; not diffed: oneOf/anyOf branches, format, nullable and constraint changes.)

GET /api/v1/projects/{id}/resources/heatmap/ accepts group_by values of role, project, and none. project will be deprecated in 0.4 and has never had any effect. The endpoint is scoped to a single project, so grouping its rows by project is a single group by construction; the cross-project heat map is an Enterprise feature. A request using it is served exactly as none.

Because project is a documented enum value in the published schema, removing it outright is a Breaking change by the table above, so it stays accepted for the window rather than starting to answer 400.

What changes in 0.4 is that the silence ends: the response gains a group_by field naming the grouping actually applied, so a caller asking for project reads back "group_by": "none" instead of having no way to tell. That field is an additive change and is safe for every existing client.

Announced0.4
Behavior during the windowaccepted, served as none, echoed as none
Earliest removal0.6
Migrationsend none; read the response’s group_by field rather than assuming the request value was honored

Mention-group member email removed (#4578)

Section titled “Mention-group member email removed (#4578)”

members[] on the user-defined and program-scoped @mention group payloads no longer carries email (the member object is now id and username). This is a Breaking removal, taken without a window because it is a security fix: a program Viewer could read the email of a project member on a project they hold no membership on, crossing the program/project visibility boundary. Resolve and render an @mention from username.

Announced0.4 (removed with no window; security-critical exception)
Migrationuse username; email is not available on these payloads

Transfer target refusals unified to 400 (#4575)

Section titled “Transfer target refusals unified to 400 (#4575)”

POST /api/v1/projects/{id}/transfer/ no longer answers 404 for a target user who does not resolve; it answers the same 400 it returns for every other unusable target, so the status code cannot be used to tell a real user id from an unknown one. The POST /api/v1/programs/{id}/transfer-sponsorship/ already answered 400 for these and is unchanged on the wire. Changing an existing status code is Breaking; this is a security-critical exception.

Announced0.4 (changed with no window; security-critical exception)
Migrationtreat 400 as “target unusable”; do not branch on 404 from these endpoints

Sprint retro request schema corrected (#4591)

Section titled “Sprint retro request schema corrected (#4591)”

The published request body of POST and PATCH /api/v1/sprints/{id}/retro/ was declared as the sprint write schema (name, start_date, goal, …), which this endpoint does not read (only notes, team_visibility and action_items are retro fields). It is now declared as the retro write schema, so a client generated from the OpenAPI schema loses those sprint properties from its retro request type. No request that worked before is refused. This is a schema-declaration correction, recorded here because the schema diff reports it as removed properties. It is a declaration correction, not a change taken without a window.

Announced0.4
Migrationregenerate clients; send the retro fields the endpoint documents

This is a pre-GA break with no deprecation window, not a scheduled deprecation. It is listed here so the break has one place to point to.

The schema published role (project and program memberships) and default_member_role (projects) through a closed RoleEnum of [1, 100, 200, 300, 400]. That list ruled out the role ordinals reserved for Enterprise custom roles, so an Enterprise installation broke the published contract by design. In 0.4 the component is removed. Those fields, and the project-role fields that were already bare integers (my_role, max_project_role, the membership write role, and the workspace-group project grant role), now reference RoleOrdinal: an integer from 1 to 400, described in Open and exhaustive enums above.

Removing a component is Breaking by the table above, because an SDK generated from the schema loses its RoleEnum type. No window applies, for two reasons. A RoleEnum kept during a window would go on declaring the reserved ordinals invalid, which is the defect itself. And no value the server sends changes: every role that was valid before is still valid.

Announced0.4
Behavior during the windownone — the component is replaced in the same release
Wire valuesunchanged; the Community edition still sends and accepts only 1, 100, 200, 300, 400
Migrationtype role fields as an integer, not as a five-member enum. Compare with >= against the built-in ordinals, and accept any integer from 1 to 400
  • The API is versioned in the URL path (/api/v1/). This is the version an integrator pins against.
  • Within v1, changes follow the change-class rules above: additive changes ship freely; breaking changes go through the deprecation window.
  • A new URL version (/api/v2/) is introduced only for breaking changes that cannot be made additively once the surface is frozen. Before that point, while TruePPM is pre-1.0, the deprecation window is the mechanism and no v2 is planned.
  • TruePPM follows semantic versioning for the product as a whole; the API-path version tracks the compatibility of the API surface specifically.

Two upcoming surfaces make an early, written contract worthwhile:

  • The read-only MCP server shipped in 0.4 exposes the live schedule to MCP clients. Integrators wiring an agent to it need to know which fields and computed values they can depend on across releases.
  • The MCP write surface arriving in 0.6 will let automation create and update work. A write client that pins against a moving target is fragile, so the deprecation policy above is committed to in writing before that surface lands — ahead of the runtime Deprecation-header mechanism itself, which will be built out before it is first needed.

Publishing the policy at beta — rather than waiting for the 0.9 freeze — means early integrators build against a known contract from the start. The 0.9 freeze will make the v1 surface permanent; this policy is the promise we hold in the interim.