SDLC v1 to v2
SDLC template upgrade from v1 to v2.
Upgrade your organization's SDLC workflows from the legacy flat-dispatcher shape (v1) to the W2 group-based shape (v2) using the platform migration script. Both shapes execute correctly today; the v1 shape is deprecated and will be removed after the deprecation window closes.
Background
The platform supports two canonical SDLC workflow shapes simultaneously:
| Shape | File | Status |
|---|---|---|
| v1 (legacy dispatcher) | sdlc-default.yaml | Deprecated. Kept until every tenant migrates. |
| v2 (W2 groups) | sdlc-default-w2.yaml | Canonical. All new projects use this. |
The runtime auto-detects the shape via detectSdlcShape() on every load and records a WorkflowSdlcShape audit event. You can see your current distribution from the Admin dashboard or via SQL.
Why v2
The v1 YAML embeds all routing logic in a flat ~35-node graph with two structural patterns that the platform architecture corpus rejects:
- An opaque
Detect Work Typeswitch whose case set is inferred at runtime - the graph reader cannot see the routing table (violates theswitch-must-enumeratelint rule). Parent? → Coordinatorforks per work type, which violated Principle 2 (decomposition is session-internal, not a workflow path). The*-coordinationwork types were deprecated in a prior release.
The v2 rewrite replaces both with a 10-node top-level graph and per-work-type sub-workflow groups. Guards are collapsed into a single guards_group. The post-completion routing moves into await_and_route_group. The Classify Work Type switch now explicitly enumerates its cases in the YAML.
flowchart TD
A([Start]) --> B[guards]
B -->|passed| C{Classify Work Type}
C -->|research| D[research_group]
C -->|refinement| E[refinement_group]
C -->|development| F[development_group]
C -->|qa| G[qa_group]
C -->|acceptance| H[acceptance_group]
C -->|ad-hoc| I[ad_hoc_group]
D & E & F --> J[await_and_route_group]
J -->|qa| G
J -->|acceptance| H
J -->|done| K([End])
G --> K
H --> K
I --> K
B -->|rejected| KThe top-level graph has 10 nodes; all routing complexity lives inside the named groups, which are reusable across other workflows.
How shape detection works
The migration script and the runtime loader both call detectSdlcShape(definition) from src/lib/workflow/sdlc-shape-detector.ts. It returns:
{
shape: 'v1' | 'v2' | 'mixed' | 'unknown',
rationale: string,
signals: {
hasGroups: boolean,
hasOpaqueDetectNode: boolean,
hasIsParentFork: boolean,
hasCoordinationWorkType: boolean,
topLevelStepCount: number,
groupIds: string[],
}
}| Shape | Detection signals |
|---|---|
v1 | Detect Work Type switch node OR linear.issue.is_parent fork OR *-coordination work-type reference. No spec.groups. |
v2 | spec.groups[] populated. No v1 signals present. |
mixed | Both classes of signal - partial migration in flight. Runtime falls back to the legacy executor (safer default). Script declines to rewrite. |
unknown | Neither class of signal - workflow is not an SDLC shape at all. Script skips it silently. |
The runtime calls recordSdlcVersionObservation() on every runInstance call and writes a WorkflowSdlcShape audit event containing { shape, workflowId, workflowName, signals, observedAt }. Those events power the shape census query below.
Detect your current shape
Before running the migration, check your tenant's current shape distribution:
-- Most-recent shape per (workspace_id, workflow_id) pair.
WITH latest_obs AS (
SELECT DISTINCT ON (workspace_id, entity_id)
workspace_id,
entity_id AS workflow_id,
payload->>'shape' AS shape,
occurred_at
FROM audit_events
WHERE entity_type = 'WorkflowSdlcShape'
ORDER BY workspace_id, entity_id, occurred_at DESC
)
SELECT shape, COUNT(*) AS workflows
FROM latest_obs
GROUP BY shape
ORDER BY workflows DESC;Expected healthy output after migration: v2: N, v1: 0, mixed: 0.
Run the migration
Always run --dry-run first. The script never silently rewrites ambiguous workflows - those are flagged for human review.
Dry run
Prints the shape census and per-row diagnostics without writing to the database.
pnpm tsx scripts/migrate-sdlc-v1-to-v2.ts --dry-runReview the output. Any row marked kind: 'ambiguous' or kind: 'unknown-case' requires manual intervention before or after the automated migration.
Apply
Rewrites every unambiguous legacy SDLC workflow in the workflows table.
pnpm tsx scripts/migrate-sdlc-v1-to-v2.ts --applySingle-workflow migration
To migrate one specific workflow without touching others:
pnpm tsx scripts/migrate-sdlc-v1-to-v2.ts --apply --workflow wf_abc123File-mode (no DB)
To test the transform against a YAML file on disk before applying to the database:
pnpm tsx scripts/migrate-sdlc-v1-to-v2.ts \
--file path/to/legacy-sdlc.yaml \
--out path/to/migrated-sdlc.yaml \
--applyVerify
After applying, re-run the shape census SQL above. When it shows v1: 0, mixed: 0, the migration is complete.
What the script rewrites automatically
The following mappings are applied without operator review:
| Legacy structure | v2 replacement |
|---|---|
Guard chain project_allowed → is_terminal → is_child → under_cap → failure_backoff (5 conditions + 5 reject annotations) | Single guards_group sub-workflow node |
Detect Work Type opaque switch with runtime-inferred cases | Classify Work Type typed switch with explicit cases: [research, refinement, development, qa, acceptance, ad-hoc] |
Per-work-type Parent? → ack-coord → coordinator-dispatch (yes) / ack → dispatch (no) fork | Single per-work-type group: ack → dispatch (the -coordination path is dropped) |
Legacy inflight case | Maps to development_group (-coordination variants collapsed) |
default handle | Maps to ad_hoc_group |
await_completion → status_switch → dispatch_(qa|acceptance|refinement) chain | Single await_and_route_group sub-workflow |
What requires human review
The script declines to rewrite (emits migrated: false) when:
- The dispatcher switch has custom cases outside
{development, inflight, qa, acceptance, refinement, research, default}. These are non-standard and must be mapped by hand. - The workflow is
mixed- a partial migration already in flight. Fix manually. - The workflow has no detectable dispatcher (custom branching shape not recognized as an SDLC).
For each flagged workflow, the output includes a rationale string and a signals object explaining the detection decision.
YAML diff reference
Expression syntax: steps.* → nodes.*
The v2 grammar canonicalizes inter-node references on nodes.*. The steps.* alias still works during the deprecation window but triggers a lint warning in strict mode.
# v1 - legacy alias (still accepted)
issueId: "{{ steps.classify.output.issueId }}"
# v2 - canonical
issueId: "{{ nodes.classify.output.issueId }}"Dispatcher → typed switch + groups
- id: detect_work_type
type: condition
name: Detect Work Type
config:
action: agent.work_type.detect
mode: switch
# cases inferred at runtime - NOT visible to graph reader
- id: is_parent_dev
type: condition
name: "Parent? (Dev)"
config:
action: linear.issue.is_parent
branches:
yes: ack_coord_dev
no: ack_development
- id: ack_coord_dev
type: action
config:
workType: coordination
- id: launch_dev_coordinator
type: action
config:
workType: coordination # *-coordination work type (deprecated)- id: classify
type: condition
name: Classify Work Type
config:
action: agent.work_type.classify
mode: switch
cases: [research, refinement, development, qa, acceptance, ad-hoc]
branches:
research: research_group
refinement: refinement_group
development: development_group
qa: qa_group
acceptance: acceptance_group
ad-hoc: ad_hoc_group
- id: development_group
type: sub-workflow
name: Development
config:
workflowRef: development_group
# spec.groups[development_group] encapsulates ack + dispatch.
# No is_parent fork - coordinator agents spawn sub-agents internally.Guard chain → group
- id: project_allowed
type: condition
branches: { yes: is_terminal, no: end_project_denied }
- id: is_terminal
type: condition
branches: { yes: end_terminal, no: is_child }
- id: is_child
type: condition
branches: { yes: end_child, no: under_cap }
- id: under_cap
type: condition
branches: { yes: failure_backoff, no: end_over_cap }
- id: failure_backoff
type: condition
branches: { yes: detect_work_type, no: end_backoff }
# ... five end-* annotation nodes- id: guards
type: sub-workflow
name: Guards
config:
workflowRef: guards_group
# spec.groups[guards_group] holds the same five conditions and five
# rejection annotations, collapsed into one composable unit with a
# single `passed` output handle.Telemetry
Every workflow load fires recordSdlcVersionObservation(), which writes a payload.type === 'sdlc.shape.observed' audit event containing { shape, workflowId, workflowName, signals, observedAt }. These events power the shape census query above and surface in the platform Admin dashboard.
For in-process aggregation in admin endpoints, use summariseSdlcShapeCounts(records) from src/lib/workflow/sdlc-version-telemetry.ts. It accepts an array of the raw audit event rows and returns { v1: number, v2: number, mixed: number, unknown: number } - the same shape the Admin dashboard uses to render the migration progress indicator.
Cleanup window
When the shape census reaches v1: 0, mixed: 0:
- Clear
LEGACY_DEPRECATION_ALLOWLISTinscripts/lint-workflows.ts(currently contains onlysdlc-default.yaml). - Delete
src/lib/workflow/templates/sdlc-default.yamland its loader. - Remove the
'mixed'and'v1'branches fromshouldUseLegacyExecutor(). - Close the cleanup tracking item.
Until that point, the legacy YAML stays on disk, the runtime executes it unchanged, and the migration script is the only supported upgrade path.
Related
- Default SDLC template - the v2 target template, its work-type pathway, and subscription model
- SDLC work types -
research → backlog-writer → development → qa → acceptancepathway - Workflow validation -
canvasToDefinitionV2and the canvas lint rules - Workflow versioning - snapshots, rollback, and the draft-always model