Rensei docs

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:

ShapeFileStatus
v1 (legacy dispatcher)sdlc-default.yamlDeprecated. Kept until every tenant migrates.
v2 (W2 groups)sdlc-default-w2.yamlCanonical. 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:

  1. An opaque Detect Work Type switch whose case set is inferred at runtime - the graph reader cannot see the routing table (violates the switch-must-enumerate lint rule).
  2. Parent? → Coordinator forks per work type, which violated Principle 2 (decomposition is session-internal, not a workflow path). The *-coordination work 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| K

The 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[],
  }
}
ShapeDetection signals
v1Detect Work Type switch node OR linear.issue.is_parent fork OR *-coordination work-type reference. No spec.groups.
v2spec.groups[] populated. No v1 signals present.
mixedBoth classes of signal - partial migration in flight. Runtime falls back to the legacy executor (safer default). Script declines to rewrite.
unknownNeither 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-run

Review 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 --apply

Single-workflow migration

To migrate one specific workflow without touching others:

pnpm tsx scripts/migrate-sdlc-v1-to-v2.ts --apply --workflow wf_abc123

File-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 \
  --apply

Verify

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 structurev2 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 casesClassify 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) forkSingle per-work-type group: ack → dispatch (the -coordination path is dropped)
Legacy inflight caseMaps to development_group (-coordination variants collapsed)
default handleMaps to ad_hoc_group
await_completion → status_switch → dispatch_(qa|acceptance|refinement) chainSingle 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:

  1. Clear LEGACY_DEPRECATION_ALLOWLIST in scripts/lint-workflows.ts (currently contains only sdlc-default.yaml).
  2. Delete src/lib/workflow/templates/sdlc-default.yaml and its loader.
  3. Remove the 'mixed' and 'v1' branches from shouldUseLegacyExecutor().
  4. 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.

On this page