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.
What the v1 surface covers
Section titled “What the v1 surface covers”The stable, integrator-facing surface is:
- Every path under
/api/v1/documented in the API reference and present in the published OpenAPI schema. - The request and response field names, types, and semantics described in that schema.
- HTTP status codes and error-body shape,
including every machine-readable
codeenumerated in Errors and status codes. - Authentication flows (JWT, project-scoped API tokens) and the rate-limiting contract.
- The WebSocket channels and event envelopes in the real-time documentation.
- The Idempotency-Key protocol.
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
detailmessages (the machine-readable status code and errorcodeare stable; the prose is not). - Ordering of list results where no explicit ordering is documented.
- Internal header names beyond the documented ones.
Change classes
Section titled “Change classes”Every change to the API falls into one of three classes.
| Class | Examples | Compatibility |
|---|---|---|
| Additive | A 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 type | Backward-compatible. Ships in any release. |
| Behavioral | A default value change, a validation tightening, a new required-only-on-new-resource constraint | Announced in the changelog. Ships in a minor release; avoided within a series where possible. |
| Breaking | Removing 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 enum | Requires 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.
Open and exhaustive enums
Section titled “Open and exhaustive enums”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 answers400to 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 structuralBlankEnumandNullEnum, the four dependency types (DepTypeEnum), the weekdays (DigestWeekdayEnum), and the discriminator tags that say which variant of aoneOfresponse 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.
Deprecation window & notice
Section titled “Deprecation window & notice”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:
-
Announcement. The deprecation will be called out in the changelog under a
ChangedorDeprecatedheading, and the affected endpoint or field will be markeddeprecatedin the OpenAPI schema. -
Runtime signal. The plan is for responses from a deprecated endpoint to carry a
Deprecationheader (and, where a replacement exists, aLinkheader 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. -
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/answered401for a bad or missing signature in 0.3; in 0.4 it answers the same404it 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: the401was 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 on401from that endpoint must treat404as the same condition.Bypassed a second time, in 0.4 (#3137). The
schedule_in_deliverfield is removed outright fromGET /api/v1/auth/me/andPATCH /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 itdeprecatedfor 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
PATCHcarryingschedule_in_deliveris refused with a400keyed 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/, andGET /api/v1/projects/{id}/workshop/current/), theWorkshopSessionandWorkshopParticipantschemas, and theworkshop_started/workshop_endedWebSocket 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, andstartis ADMIN-gated and answers409while 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 theDeprecationheader 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 inscripts/schema-removal-allowlist.txtso 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/, andGET /api/v1/tasks/{id}/scope/, together with theBoardLane,BoardLanesandTaskScopeRollupcomponent 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 listGET /api/v1/projects/{project_id}/teams/, which is what our own web client has always used. The twoteams/{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 whatboard-configcarries:board-config’slanesare 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_rollupis 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 inscripts/schema-removal-allowlist.txtso 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
400to403, 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:DELETEandPATCHon/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}/andDELETE /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
400was an accident of how the guard raised, not a decision about what it meant. The check beside each one already answered403for the same class of refusal, so a client branching on status had two status classes for one kind of answer, and a400told 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 stablecodethey had always named in source but never put on the wire (see Errors and status codes); the two membership refusals keep a baredetail. The last-Owner guard on the two membership deletes is a refusal on the roster’s state and stays a400.A client that branched on
400from these operations should treat403as 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 answering201when it adds a skill to the catalog, having answered200on 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
200on the create path was never the published contract. The endpoint’s own docstring and the OpenAPI schema both declared201for a create, and only201— the200came from a created/existing flag re-derived after the save from aserver_version=0probe that the model’s save method never leaves behind, so the201the 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 its200, which the schema now publishes alongside the201, 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
200to mean “already existed” was reading a bug and should branch on201for “created” instead.Bypassed a seventh time, in 0.4 (#3599).
GET /api/v1/projects/{id}/resource-allocation/andGET /api/v1/programs/{id}/resource-contention/stop returningemailon 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[].emailfrom 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).
userstops being accepted in the request body ofPATCH /api/v1/projects/{id}/members/{mid}/andPATCH /api/v1/programs/{id}/members/{mid}/; a body carrying it is answered400where it previously reassigned the membership row and answered200. Removing a field and changing an existing status code are both Breaking by the table above. The twoPatchedProject/ProgramMembershipWriteRequestcomponent 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 unboundeduserlet 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’sjoined_ataccess evidence. This is deliberately the inverse of thegroup_by=projectdecision below, which kept a useless enum value accepted for its window rather than start answering400: a value with no effect can wait out a deprecation window, and an open disclosure door cannot.A client that PATCHed
userwas 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(androle_titleon 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 theProjectHistorySummaryViewclass and itsVALID_WINDOWSconstant (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 ownurls.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 inscripts/schema-removal-allowlist.txtso the schema gate treats it as reviewed rather than accidental.TaskHistoryListViewandProjectHistoryListView’s shared_MAX_HISTORY_ROWScap, 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/andPOST /api/v1/admin/failed-tasks/{id}/dismiss/— present in the publishedv0.3.0-alpha.3schema — 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
retryre-enqueued a parked task through a rawsend_taskside channel with no shipped caller ever exercising it in anger;requeueroutes the re-enqueue through the durable outbox-composing workflow backend (ADR-0210 §1) instead, so a broker outage cannot silently drop it. The olddismisshad no audit trail beyond the status flip;dropretains 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 offIsAdminUserby #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:
retryanddismissare gone from the router entirely, so a caller still using either gets a plain404with noDeprecationheader and no signpost to the new name — this is exactly the gap this entry exists to close. A client callingretryshould callrequeueinstead: an empty body behaves the same (immediate re-enqueue), and the response gains aworkflow_idfield; an optionalbackoff_seconds(0–86400, default0) is new. A client callingdismissshould calldropinstead: an empty body behaves the same, and an optionalnote(up to 1000 characters) is new.Bypassed an eleventh time, in 0.4 (#4290).
created_bychanges type on four response schemas between betas, with no deprecation window. OnShareLink,ShareLinkCreateResponse(/api/v1/projects/{id}/share-links/) andExternalStakeholder(/api/v1/programs/{id}/external-stakeholders/) it carried the creator’s display name; onBoardSavedView(/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 whatcreated_bymeans on every other schema, and isnullonce that user is deleted. The display name moves to a newcreated_by_namefield on the three schemas that had it, following the existing*_nameconvention (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.0the same change would need a full window. Before this change a client could not joincreated_byacross resources, and a display name never parses as an id. A client that renderedcreated_byon a share link or external stakeholder should readcreated_by_nameinstead. A client that compared a board view’screated_byto a user id should compare it as a number./auth/me/still returnsidas a decimal string, so convert one side before you compare.Bypassed a twelfth time, in 0.4 (#4593). The
RoleEnumcomponent is removed and replaced byRoleOrdinal, 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.
-
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.
Current deprecations
Section titled “Current deprecations”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.)
group_by=project on the resource heatmap
Section titled “group_by=project on the resource heatmap”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.
| Announced | 0.4 |
| Behavior during the window | accepted, served as none, echoed as none |
| Earliest removal | 0.6 |
| Migration | send 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.
| Announced | 0.4 (removed with no window; security-critical exception) |
| Migration | use 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.
| Announced | 0.4 (changed with no window; security-critical exception) |
| Migration | treat 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.
| Announced | 0.4 |
| Migration | regenerate clients; send the retro fields the endpoint documents |
RoleEnum replaced by RoleOrdinal
Section titled “RoleEnum replaced by RoleOrdinal”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.
| Announced | 0.4 |
| Behavior during the window | none — the component is replaced in the same release |
| Wire values | unchanged; the Community edition still sends and accepts only 1, 100, 200, 300, 400 |
| Migration | type 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 |
Versioning approach
Section titled “Versioning approach”- 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 nov2is planned. - TruePPM follows semantic versioning for the product as a whole; the API-path version tracks the compatibility of the API surface specifically.
Why this policy ships now
Section titled “Why this policy ships now”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.