The problem
The pipeline stage model is implemented three times.
services/api/src/pipeline.ts — declared the source of truth in its own docstring: "the server-side source of truth for pipeline state, consumed by agents (the Product Owner reads it as a tool) and by the web."
apps/web/lib/pipeline.ts — a second implementation of the same algorithm. The stage keys, labels, isMode, stageForMode and placeOpen logic are the same, but the signatures are not: the server takes Date fields and a ReadonlySet<number> and returns a flattened PlacedPipelineIssue; the client takes ISO strings and Proposal[] and returns a generic PlacedIssue<T> wrapping the whole issue. The client also exports pipelineCounts, prsOf and currentRunOf, which the server lacks.
apps/web/lib/story.ts:77-97 — stageEntered(), a third partial reimplementation that replays stage transitions onto the story timeline.
Despite the docstring, the web does not consume the server's classification. Every board surface fetches the raw issue mirror and classifies it locally: stories/page.tsx, projects/[projectId]/page.tsx, stories/[number]/page.tsx, and (org)/projects/page.tsx. GET /v1/projects/:projectId/pipeline exists and is used by the assistant (services/api/src/assistant/tools.ts:34) and described to agents (packages/harness/src/session.ts:78), but not by the UI.
The stage list is additionally hardcoded in prose in two agent-facing places — assistant/tools.ts:32 spells out "Backlog → Planning → Ready → Building → In review → Shipped" in the get_pipeline tool description, and harness/src/session.ts:78 describes the endpoint to agents.
Adding a stage, a CI dimension and a second kind of story (see the rest of this epic) to three implementations plus two prose copies is the most fragile part of that work, and the failure is silent: a stage present on the server and missing on the client renders as a blank column, not an error.
What I propose
Make the server the only classifier, and let the generated SDK types be the shared contract.
The web stops calling GET /v1/projects/:id/issues + classifyPipeline and consumes server-placed stories from the pipeline endpoint. classifyPipeline, placeOpen, prsOf, currentRunOf, pipelineCounts and their supporting types are deleted from apps/web/lib/pipeline.ts, which shrinks to presentation helpers.
This is not only a cleanup — it is a precondition for the rest of the epic. The client cannot compute the Validating stage (it has no CI data) and cannot discover orphan PR stories (the issue mirror does not contain PRs). Any design where the client keeps classifying has to ship those data to it anyway, at which point the second implementation is pure liability.
The type sharing comes free from what already exists. Today the pipeline response declares key: z.string() and kind: z.string() (services/api/src/routes/v1/github.ts:288-291); tightening those to z.enum(...) makes the generated packages/sdk/src/schema.d.ts carry the actual stage union, which apps/web can then import. Divergence becomes a tsc --noEmit failure instead of a blank column — a stronger guarantee than a shared source file, which only proves the file matches, not the deployed API.
stageEntered() stays, because it is a replay over timeline events rather than a placement over current state and can never literally share the classifier. But its return type becomes the generated PipelineStage, so adding a stage makes it a compile error until handled.
A useful side effect: (org)/projects/page.tsx currently runs fetchAllProjectIssues per project, which is up to 20 paginated requests each (apps/web/lib/project-issues.ts:12-13). That becomes one call per project, or one call total with a batched endpoint.
What I considered instead
- Extracting the model to a shared workspace package. The obvious move, but no package is currently depended on by both
services/api and apps/web: @facility/core is used by db/api/mcp/gateway but not the web; @facility/ui and @facility/sdk are web-only; and api → sdk is impossible because it would create a build cycle (the SDK is generated from the API's OpenAPI output). So it means adding a new cross-dependency, or a new package, to share ~30 lines that a z.enum already publishes for free — and it would still leave the client classifying with data it does not have.
- Keeping the duplication and being careful. It is the status quo and it has already drifted: the two implementations no longer share signatures, and the third copy was written independently.
- Doing nothing. Viable only if the rest of this epic is not built.
Blast radius
apps/web/lib/pipeline.ts (rewritten), apps/web/lib/project-issues.ts (likely deleted), apps/web/lib/story.ts, apps/web/lib/api.ts, components/project/pipeline.tsx, components/project/stage-section.tsx, components/issues/{issue-row,issue-list}.tsx, the four pages above, services/api/src/pipeline.ts, services/api/src/routes/v1/github.ts, services/api/src/assistant/tools.ts:32, packages/harness/src/session.ts:78, and the regenerated packages/sdk artefacts.
Part of #65. Findings are against 0f646ac.
The problem
The pipeline stage model is implemented three times.
services/api/src/pipeline.ts— declared the source of truth in its own docstring: "the server-side source of truth for pipeline state, consumed by agents (the Product Owner reads it as a tool) and by the web."apps/web/lib/pipeline.ts— a second implementation of the same algorithm. The stage keys, labels,isMode,stageForModeandplaceOpenlogic are the same, but the signatures are not: the server takesDatefields and aReadonlySet<number>and returns a flattenedPlacedPipelineIssue; the client takes ISO strings andProposal[]and returns a genericPlacedIssue<T>wrapping the whole issue. The client also exportspipelineCounts,prsOfandcurrentRunOf, which the server lacks.apps/web/lib/story.ts:77-97—stageEntered(), a third partial reimplementation that replays stage transitions onto the story timeline.Despite the docstring, the web does not consume the server's classification. Every board surface fetches the raw issue mirror and classifies it locally:
stories/page.tsx,projects/[projectId]/page.tsx,stories/[number]/page.tsx, and(org)/projects/page.tsx.GET /v1/projects/:projectId/pipelineexists and is used by the assistant (services/api/src/assistant/tools.ts:34) and described to agents (packages/harness/src/session.ts:78), but not by the UI.The stage list is additionally hardcoded in prose in two agent-facing places —
assistant/tools.ts:32spells out "Backlog → Planning → Ready → Building → In review → Shipped" in theget_pipelinetool description, andharness/src/session.ts:78describes the endpoint to agents.Adding a stage, a CI dimension and a second kind of story (see the rest of this epic) to three implementations plus two prose copies is the most fragile part of that work, and the failure is silent: a stage present on the server and missing on the client renders as a blank column, not an error.
What I propose
Make the server the only classifier, and let the generated SDK types be the shared contract.
The web stops calling
GET /v1/projects/:id/issues+classifyPipelineand consumes server-placed stories from the pipeline endpoint.classifyPipeline,placeOpen,prsOf,currentRunOf,pipelineCountsand their supporting types are deleted fromapps/web/lib/pipeline.ts, which shrinks to presentation helpers.This is not only a cleanup — it is a precondition for the rest of the epic. The client cannot compute the
Validatingstage (it has no CI data) and cannot discover orphan PR stories (the issue mirror does not contain PRs). Any design where the client keeps classifying has to ship those data to it anyway, at which point the second implementation is pure liability.The type sharing comes free from what already exists. Today the pipeline response declares
key: z.string()andkind: z.string()(services/api/src/routes/v1/github.ts:288-291); tightening those toz.enum(...)makes the generatedpackages/sdk/src/schema.d.tscarry the actual stage union, whichapps/webcan then import. Divergence becomes atsc --noEmitfailure instead of a blank column — a stronger guarantee than a shared source file, which only proves the file matches, not the deployed API.stageEntered()stays, because it is a replay over timeline events rather than a placement over current state and can never literally share the classifier. But its return type becomes the generatedPipelineStage, so adding a stage makes it a compile error until handled.A useful side effect:
(org)/projects/page.tsxcurrently runsfetchAllProjectIssuesper project, which is up to 20 paginated requests each (apps/web/lib/project-issues.ts:12-13). That becomes one call per project, or one call total with a batched endpoint.What I considered instead
services/apiandapps/web:@facility/coreis used bydb/api/mcp/gatewaybut not the web;@facility/uiand@facility/sdkare web-only; andapi → sdkis impossible because it would create a build cycle (the SDK is generated from the API's OpenAPI output). So it means adding a new cross-dependency, or a new package, to share ~30 lines that az.enumalready publishes for free — and it would still leave the client classifying with data it does not have.Blast radius
apps/web/lib/pipeline.ts(rewritten),apps/web/lib/project-issues.ts(likely deleted),apps/web/lib/story.ts,apps/web/lib/api.ts,components/project/pipeline.tsx,components/project/stage-section.tsx,components/issues/{issue-row,issue-list}.tsx, the four pages above,services/api/src/pipeline.ts,services/api/src/routes/v1/github.ts,services/api/src/assistant/tools.ts:32,packages/harness/src/session.ts:78, and the regeneratedpackages/sdkartefacts.Part of #65. Findings are against
0f646ac.