@msn-control/liftoff 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/templates.js CHANGED
@@ -1,20 +1,38 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { addGenAiExtensionArtifacts } from './genai-templates.js';
3
+ import { addStandardStackArtifacts, renderStandardDockerfile, renderStandardEnv } from './standard-templates.js';
2
4
  import { liftoffVersion } from './version.js';
3
5
  const contentHash = (content) => `sha256:${createHash('sha256').update(content, 'utf8').digest('hex')}`;
4
6
  const pyModule = (value) => value.replace(/-/g, '_');
5
7
  const titleCase = (value) => value.replace(/(^|[-_\s])([a-z])/g, (_match, prefix, letter) => `${prefix ? ' ' : ''}${letter.toUpperCase()}`).trim();
6
- const hasFunctionWorker = (plan) => plan.provider.id === 'azure' && plan.pattern.worker;
7
- const functionWorkerName = (plan) => `${plan.pattern.id}-worker`;
8
+ const sourceString = (value) => JSON.stringify(value);
9
+ const scriptSourceString = (value) => sourceString(value).replaceAll('<', '\\u003c');
10
+ const escapeHtml = (value) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
11
+ const genAiPattern = (plan) => {
12
+ if (plan.projectType.id !== 'genai' || !plan.pattern) {
13
+ throw new Error('GenAI template rendering requires a GenAI pattern.');
14
+ }
15
+ return plan.pattern;
16
+ };
17
+ const hasFunctionWorker = (plan) => plan.projectType.id === 'genai' && plan.provider.id === 'azure' && genAiPattern(plan).worker;
18
+ const functionWorkerName = (plan) => `${genAiPattern(plan).id}-worker`;
8
19
  export function buildArtifacts(plan) {
9
20
  const artifacts = [];
10
21
  const add = (logicalName, category, pathParts, content) => {
11
22
  artifacts.push({ logicalName, category, pathParts, content: ensureTrailingNewline(content) });
12
23
  };
13
24
  addBaseArtifacts(add, plan);
14
- addBackendArtifacts(add, plan);
15
- addDatabaseArtifacts(add, plan);
16
- addPatternArtifacts(add, plan);
17
- addFunctionArtifacts(add, plan);
25
+ if (plan.projectType.id === 'genai') {
26
+ addGenAiExtensionArtifacts(add, plan, {
27
+ backend: addBackendArtifacts,
28
+ database: addDatabaseArtifacts,
29
+ pattern: addPatternArtifacts,
30
+ functions: addFunctionArtifacts
31
+ });
32
+ }
33
+ else {
34
+ addStandardStackArtifacts(add, plan);
35
+ }
18
36
  addEnvironmentArtifacts(add, plan);
19
37
  addDockerArtifacts(add, plan);
20
38
  addInfrastructureArtifacts(add, plan);
@@ -38,14 +56,18 @@ export function buildManifest(plan, artifacts) {
38
56
  liftoffVersion,
39
57
  project: {
40
58
  name: plan.projectName,
41
- pattern: plan.pattern.id,
59
+ projectType: plan.projectType.id,
60
+ apiStack: plan.apiStack.id,
61
+ ...(plan.pattern ? { pattern: plan.pattern.id } : {}),
42
62
  cloud: plan.provider.id,
43
63
  region: plan.region.slug,
44
64
  frontend: plan.includeFrontend,
45
65
  specWorkflow: plan.specWorkflow.id,
46
66
  environments: plan.environments.map((environment) => environment.id)
47
67
  },
48
- artifacts: artifacts.map((artifact) => ({
68
+ artifacts: artifacts
69
+ .filter((artifact) => artifact.category !== 'seed') // seed content is written once, never tracked
70
+ .map((artifact) => ({
49
71
  logicalName: artifact.logicalName,
50
72
  category: artifact.category,
51
73
  pathParts: artifact.pathParts,
@@ -58,7 +80,9 @@ function addBaseArtifacts(add, plan) {
58
80
  add('root-gitignore', 'project', ['.gitignore'], renderGeneratedGitignore());
59
81
  add('liftoff-config', 'project', ['liftoff.config.json'], JSON.stringify({
60
82
  projectName: plan.projectName,
61
- pattern: plan.pattern.id,
83
+ projectType: plan.projectType.id,
84
+ apiStack: plan.apiStack.id,
85
+ ...(plan.pattern ? { pattern: plan.pattern.id } : {}),
62
86
  cloud: plan.provider.id,
63
87
  region: plan.region.slug,
64
88
  includeFrontend: plan.includeFrontend,
@@ -66,10 +90,10 @@ function addBaseArtifacts(add, plan) {
66
90
  specWorkflow: plan.specWorkflow.id
67
91
  }, null, 2));
68
92
  add('env-example', 'configuration', ['.env.example'], renderEnvExample(plan));
69
- add('backend-dockerfile', 'runtime', ['Dockerfile'], renderBackendDockerfile());
93
+ add('backend-dockerfile', 'runtime', ['Dockerfile'], plan.projectType.id === 'genai' ? renderBackendDockerfile() : renderStandardDockerfile(plan));
70
94
  }
71
95
  function addBackendArtifacts(add, plan) {
72
- const routeModule = pyModule(plan.pattern.id);
96
+ const routeModule = pyModule(genAiPattern(plan).id);
73
97
  add('backend-pyproject', 'backend', ['backend', 'pyproject.toml'], renderBackendPyproject(plan));
74
98
  add('backend-package', 'backend', ['backend', '__init__.py'], '');
75
99
  add('backend-api-package', 'backend', ['backend', 'apis', '__init__.py'], '');
@@ -95,20 +119,21 @@ function addDatabaseArtifacts(add, plan) {
95
119
  add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderDatabaseSchema(plan));
96
120
  }
97
121
  function addPatternArtifacts(add, plan) {
98
- const routeModule = pyModule(plan.pattern.id);
122
+ const pattern = genAiPattern(plan);
123
+ const routeModule = pyModule(pattern.id);
99
124
  add('pattern-agent', 'pattern', ['backend', 'orchestration', 'agents', `${routeModule}_agent.py`], renderPatternAgent(plan));
100
- add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${plan.pattern.id}.md`], renderPromptTemplate(plan));
125
+ add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${pattern.id}.md`], renderPromptTemplate(plan));
101
126
  add('pattern-agent-package', 'pattern', ['backend', 'orchestration', 'agents', '__init__.py'], '');
102
127
  add('pattern-prompt-readme', 'pattern', ['backend', 'orchestration', 'prompts', 'README.md'], renderPromptReadme());
103
- if (plan.pattern.id === 'rag') {
128
+ if (pattern.id === 'rag') {
104
129
  add('rag-vector-store', 'pattern', ['backend', 'orchestration', 'retrieval', 'vector_store.py'], renderVectorStore());
105
130
  add('rag-retrieval-package', 'pattern', ['backend', 'orchestration', 'retrieval', '__init__.py'], '');
106
131
  }
107
- if (plan.pattern.worker) {
132
+ if (pattern.worker) {
108
133
  add('pattern-worker', 'pattern', ['backend', 'workers', `${routeModule}_worker.py`], renderPatternWorker(plan));
109
134
  add('backend-workers-package', 'pattern', ['backend', 'workers', '__init__.py'], '');
110
135
  }
111
- if (plan.pattern.id === 'fine-tuned') {
136
+ if (pattern.id === 'fine-tuned') {
112
137
  add('fine-tuned-eval-dataset', 'pattern', ['backend', 'evaluation', 'datasets', 'sample.jsonl'], '{"input":"Example request","expected":"Expected response placeholder"}');
113
138
  }
114
139
  }
@@ -130,7 +155,7 @@ function addFunctionArtifacts(add, plan) {
130
155
  }
131
156
  function addEnvironmentArtifacts(add, plan) {
132
157
  for (const environment of plan.environments) {
133
- add(`environment-${environment.id}-backend`, 'environment', ['environments', environment.id, 'backend.env'], renderBackendEnv(plan, environment.id));
158
+ add(`environment-${environment.id}-backend`, 'environment', ['environments', environment.id, 'backend.env'], plan.projectType.id === 'genai' ? renderBackendEnv(plan, environment.id) : renderStandardEnv(plan, environment.id));
134
159
  if (hasFunctionWorker(plan)) {
135
160
  add(`environment-${environment.id}-functions`, 'environment', ['environments', environment.id, 'functions.env'], renderFunctionsEnv(plan, environment.id));
136
161
  }
@@ -157,10 +182,10 @@ function addGovernanceArtifacts(add, plan) {
157
182
  if (plan.specWorkflow.id === 'openspec') {
158
183
  const changeName = `bootstrap-${plan.safeProjectName}`;
159
184
  add('openspec-config', 'governance', ['openspec', 'config.yaml'], renderOpenSpecConfig(plan));
160
- add('openspec-seed-change-metadata', 'governance', ['openspec', 'changes', changeName, '.openspec.yaml'], 'schema: spec-driven');
161
- add('openspec-seed-proposal', 'governance', ['openspec', 'changes', changeName, 'proposal.md'], renderSeedProposal(plan));
162
- add('openspec-seed-design', 'governance', ['openspec', 'changes', changeName, 'design.md'], renderSeedDesign(plan));
163
- add('openspec-seed-tasks', 'governance', ['openspec', 'changes', changeName, 'tasks.md'], renderSeedTasks());
185
+ add('openspec-seed-change-metadata', 'seed', ['openspec', 'changes', changeName, '.openspec.yaml'], 'schema: spec-driven');
186
+ add('openspec-seed-proposal', 'seed', ['openspec', 'changes', changeName, 'proposal.md'], renderSeedProposal(plan));
187
+ add('openspec-seed-design', 'seed', ['openspec', 'changes', changeName, 'design.md'], renderSeedDesign(plan));
188
+ add('openspec-seed-tasks', 'seed', ['openspec', 'changes', changeName, 'tasks.md'], renderSeedTasks());
164
189
  add('openspec-spec-placeholder', 'governance', ['openspec', 'specs', '.gitkeep'], '');
165
190
  }
166
191
  else {
@@ -181,6 +206,48 @@ function addFrontendArtifacts(add, plan) {
181
206
  add('frontend-dockerfile', 'frontend', ['frontend', 'Dockerfile'], renderFrontendDockerfile());
182
207
  }
183
208
  function renderRootReadme(plan) {
209
+ if (plan.projectType.id === 'standard') {
210
+ return `# ${plan.projectName}
211
+
212
+ Generated by Mission Control Liftoff.
213
+
214
+ ## Stack
215
+
216
+ - Project type: Standard application
217
+ - API: ${plan.apiStack.label}
218
+ - Database tooling: ${plan.apiStack.databaseTooling}
219
+ - API reference: Scalar with OpenAPI
220
+ - Cloud: ${plan.provider.label} (${plan.region.slug})
221
+ - Infrastructure: OpenTofu
222
+ - Database: PostgreSQL
223
+ - Cache and local messaging: Redis
224
+ - Local development: Docker Compose
225
+ ${plan.includeFrontend ? '- Frontend: Vue 3 with Tailwind\n' : ''}
226
+ ## Local Development
227
+
228
+ \`\`\`bash
229
+ docker compose up --build
230
+ \`\`\`
231
+
232
+ The backend API is available on port 8000. Health and readiness endpoints are available at \`/health\` and \`/ready\`; Scalar is exposed at \`/scalar\`.
233
+
234
+ ## Infrastructure
235
+
236
+ \`\`\`bash
237
+ cd infrastructure/opentofu/azure
238
+ tofu init
239
+ tofu plan -var-file=environments/dev.tfvars
240
+ tofu apply -var-file=environments/dev.tfvars
241
+ \`\`\`
242
+
243
+ The first apply uses a public bootstrap image. Follow \`infrastructure/opentofu/azure/README.md\` to build the generated backend in ACR and apply its image.
244
+
245
+ ## Spec-Driven Workflow
246
+
247
+ Selected workflow: ${plan.specWorkflow.label}.
248
+ `;
249
+ }
250
+ const pattern = genAiPattern(plan);
184
251
  const functionsStackLine = hasFunctionWorker(plan) ? `- Azure Functions worker: Python v2 Service Bus trigger under \`functions/${functionWorkerName(plan)}\`
185
252
  ` : '';
186
253
  const functionsSection = hasFunctionWorker(plan) ? `
@@ -195,10 +262,10 @@ Generated by Mission Control Liftoff.
195
262
  ## Stack
196
263
 
197
264
  - Backend: FastAPI, PydanticAI, Pydantic settings, Scalar
198
- - Pattern: ${plan.pattern.label}
265
+ - Pattern: ${pattern.label}
199
266
  - Cloud: ${plan.provider.label} (${plan.region.slug})
200
267
  - Infrastructure: OpenTofu
201
- - Database: PostgreSQL with Alembic migrations${plan.pattern.id === 'rag' ? ' and pgvector retrieval' : ''}
268
+ - Database: PostgreSQL with Alembic migrations${pattern.id === 'rag' ? ' and pgvector retrieval' : ''}
202
269
  - Cache and local messaging: Redis
203
270
  - Observability: Langfuse
204
271
  - Local development: Docker Compose
@@ -222,6 +289,8 @@ tofu plan -var-file=environments/dev.tfvars
222
289
  tofu apply -var-file=environments/dev.tfvars
223
290
  \`\`\`
224
291
 
292
+ The first apply uses a public bootstrap image. Follow \`infrastructure/opentofu/azure/README.md\` to build the generated backend in ACR and apply its image.
293
+
225
294
  ## Spec-Driven Workflow
226
295
 
227
296
  Selected workflow: ${plan.specWorkflow.label}.
@@ -242,9 +311,13 @@ migration/legacy/
242
311
  `;
243
312
  }
244
313
  function renderEnvExample(plan) {
314
+ if (plan.projectType.id === 'standard') {
315
+ return renderStandardEnv(plan);
316
+ }
317
+ const pattern = genAiPattern(plan);
245
318
  return `APP_ENV=dev
246
319
  APP_NAME=${plan.safeProjectName}
247
- GENAI_PATTERN=${plan.pattern.id}
320
+ GENAI_PATTERN=${pattern.id}
248
321
  CLOUD_PROVIDER=${plan.provider.id}
249
322
  AZURE_REGION=${plan.region.slug}
250
323
  DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
@@ -287,6 +360,7 @@ dependencies = [
287
360
  "scalar-fastapi>=1.0",
288
361
  "sqlalchemy[asyncio]>=2.0",
289
362
  "asyncpg>=0.29",
363
+ "psycopg[binary]>=3.2",
290
364
  "alembic>=1.13",
291
365
  "redis>=5.0",
292
366
  "langfuse>=2.39",
@@ -302,6 +376,9 @@ test = ["pytest>=8.2", "httpx>=0.27"]
302
376
  requires = ["setuptools>=70"]
303
377
  build-backend = "setuptools.build_meta"
304
378
 
379
+ [tool.setuptools]
380
+ packages = []
381
+
305
382
  [tool.pytest.ini_options]
306
383
  pythonpath = [".."]
307
384
  testpaths = ["tests"]
@@ -350,16 +427,17 @@ def ready():
350
427
  `;
351
428
  }
352
429
  function renderPatternRoutes(plan) {
353
- const moduleName = pyModule(plan.pattern.id);
430
+ const pattern = genAiPattern(plan);
431
+ const moduleName = pyModule(pattern.id);
354
432
  const agentName = `${moduleName}_agent`;
355
- const prefix = plan.pattern.routePrefix;
356
- if (plan.pattern.id === 'streaming') {
433
+ const prefix = pattern.routePrefix;
434
+ if (pattern.id === 'streaming') {
357
435
  return `from fastapi import APIRouter
358
436
  from fastapi.responses import StreamingResponse
359
437
 
360
438
  from backend.orchestration.agents.${agentName} import stream_response
361
439
 
362
- router = APIRouter(prefix="${prefix}", tags=["${plan.pattern.id}"])
440
+ router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
363
441
 
364
442
 
365
443
  @router.get("")
@@ -367,7 +445,7 @@ def stream(prompt: str):
367
445
  return StreamingResponse(stream_response(prompt), media_type="text/event-stream")
368
446
  `;
369
447
  }
370
- if (plan.pattern.id === 'rag') {
448
+ if (pattern.id === 'rag') {
371
449
  return `from fastapi import APIRouter
372
450
  from pydantic import BaseModel
373
451
 
@@ -394,13 +472,13 @@ async def ingest(request: IngestionRequest):
394
472
  return await enqueue_ingestion(request.source_uri)
395
473
  `;
396
474
  }
397
- const bodyClass = `${titleCase(plan.pattern.id).replace(/\s/g, '')}Request`;
475
+ const bodyClass = `${titleCase(pattern.id).replace(/\s/g, '')}Request`;
398
476
  return `from fastapi import APIRouter
399
477
  from pydantic import BaseModel
400
478
 
401
479
  from backend.orchestration.agents.${agentName} import run_${moduleName}
402
480
 
403
- router = APIRouter(prefix="${prefix}", tags=["${plan.pattern.id}"])
481
+ router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
404
482
 
405
483
 
406
484
  class ${bodyClass}(BaseModel):
@@ -426,6 +504,7 @@ async def get_current_user() -> CurrentUser:
426
504
  `;
427
505
  }
428
506
  function renderSettings(plan) {
507
+ const pattern = genAiPattern(plan);
429
508
  return `from functools import lru_cache
430
509
  from pydantic_settings import BaseSettings, SettingsConfigDict
431
510
 
@@ -433,9 +512,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
433
512
  class Settings(BaseSettings):
434
513
  model_config = SettingsConfigDict(env_file=".env", extra="ignore")
435
514
 
436
- app_name: str = "${plan.projectName}"
515
+ app_name: str = ${sourceString(plan.projectName)}
437
516
  app_env: str = "dev"
438
- genai_pattern: str = "${plan.pattern.id}"
517
+ genai_pattern: str = "${pattern.id}"
439
518
  cloud_provider: str = "${plan.provider.id}"
440
519
  azure_region: str = "${plan.region.slug}"
441
520
  database_url: str
@@ -451,6 +530,7 @@ def get_settings() -> Settings:
451
530
  `;
452
531
  }
453
532
  function renderModelConfig(plan) {
533
+ const pattern = genAiPattern(plan);
454
534
  return `from pydantic import BaseModel, Field
455
535
 
456
536
 
@@ -458,7 +538,7 @@ class ModelConfig(BaseModel):
458
538
  provider: str = Field(default="azure-openai")
459
539
  deployment_name: str = Field(default="gpt-4.1")
460
540
  embedding_deployment_name: str = Field(default="text-embedding-3-large")
461
- pattern: str = Field(default="${plan.pattern.id}")
541
+ pattern: str = Field(default="${pattern.id}")
462
542
  `;
463
543
  }
464
544
  function renderMessagingBoundary() {
@@ -513,25 +593,35 @@ def test_health():
513
593
  }
514
594
  function renderAlembicIni() {
515
595
  return `[alembic]
516
- script_location = migrations
596
+ script_location = %(here)s/migrations
517
597
  sqlalchemy.url = driver://user:pass@localhost/dbname
518
598
  `;
519
599
  }
520
600
  function renderAlembicEnv() {
521
- return `from alembic import context
601
+ return `import os
602
+
603
+ from alembic import context
604
+ from sqlalchemy import create_engine
522
605
 
523
606
 
524
607
  def run_migrations_online():
525
- context.configure(url=context.get_x_argument(as_dictionary=True).get("database_url"))
526
- with context.begin_transaction():
527
- context.run_migrations()
608
+ database_url = os.environ.get("DATABASE_URL")
609
+ if not database_url:
610
+ raise RuntimeError("DATABASE_URL is required to run migrations")
611
+ database_url = database_url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
612
+ database_url = database_url.replace("postgresql://", "postgresql+psycopg://", 1)
613
+ connectable = create_engine(database_url)
614
+ with connectable.connect() as connection:
615
+ context.configure(connection=connection, target_metadata=None)
616
+ with context.begin_transaction():
617
+ context.run_migrations()
528
618
 
529
619
 
530
620
  run_migrations_online()
531
621
  `;
532
622
  }
533
623
  function renderInitialMigration(plan) {
534
- const vectorExtension = plan.pattern.id === 'rag' ? ' op.execute("CREATE EXTENSION IF NOT EXISTS vector")\n' : '';
624
+ const vectorExtension = genAiPattern(plan).id === 'rag' ? ' op.execute("CREATE EXTENSION IF NOT EXISTS vector")\n' : '';
535
625
  return `from alembic import op
536
626
  import sqlalchemy as sa
537
627
 
@@ -562,11 +652,12 @@ function renderDatabaseSchema(plan) {
562
652
  payload JSONB NOT NULL,
563
653
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
564
654
  );
565
- ${plan.pattern.id === 'rag' ? '\nCREATE EXTENSION IF NOT EXISTS vector;\n' : ''}`;
655
+ ${genAiPattern(plan).id === 'rag' ? '\nCREATE EXTENSION IF NOT EXISTS vector;\n' : ''}`;
566
656
  }
567
657
  function renderPatternAgent(plan) {
568
- const moduleName = pyModule(plan.pattern.id);
569
- if (plan.pattern.id === 'rag') {
658
+ const pattern = genAiPattern(plan);
659
+ const moduleName = pyModule(pattern.id);
660
+ if (pattern.id === 'rag') {
570
661
  return `from backend.orchestration.tools.messaging import build_message_publisher
571
662
 
572
663
 
@@ -584,7 +675,7 @@ async def enqueue_ingestion(source_uri: str) -> dict:
584
675
  return {"status": "queued", "source_uri": source_uri}
585
676
  `;
586
677
  }
587
- if (plan.pattern.id === 'streaming') {
678
+ if (pattern.id === 'streaming') {
588
679
  return `async def stream_response(prompt: str):
589
680
  yield f"data: Starting response for {prompt}\\n\\n"
590
681
  yield "data: Replace this placeholder with PydanticAI streaming orchestration.\\n\\n"
@@ -592,15 +683,16 @@ async def enqueue_ingestion(source_uri: str) -> dict:
592
683
  }
593
684
  return `async def run_${moduleName}(input_text: str) -> dict:
594
685
  return {
595
- "result": "Replace this placeholder with ${plan.pattern.label} PydanticAI orchestration.",
686
+ "result": "Replace this placeholder with ${pattern.label} PydanticAI orchestration.",
596
687
  "input": input_text,
597
688
  }
598
689
  `;
599
690
  }
600
691
  function renderPromptTemplate(plan) {
601
- return `# ${plan.pattern.label} Prompt
692
+ const pattern = genAiPattern(plan);
693
+ return `# ${pattern.label} Prompt
602
694
 
603
- You are implementing a ${plan.pattern.label} generated by Mission Control Liftoff.
695
+ You are implementing a ${pattern.label} generated by Mission Control Liftoff.
604
696
 
605
697
  Use PydanticAI orchestration and return outputs that match the API contract.
606
698
  `;
@@ -626,8 +718,9 @@ class PgVectorStore:
626
718
  `;
627
719
  }
628
720
  function renderPatternWorker(plan) {
721
+ const pattern = genAiPattern(plan);
629
722
  return `async def run_worker() -> None:
630
- # Consume ${plan.pattern.label} jobs from the configured messaging boundary.
723
+ # Consume ${pattern.label} jobs from the configured messaging boundary.
631
724
  return None
632
725
  `;
633
726
  }
@@ -641,9 +734,10 @@ Keep reusable GenAI orchestration, model configuration, prompt handling, and dom
641
734
  }
642
735
  function renderFunctionWorkerReadme(plan) {
643
736
  const workerName = functionWorkerName(plan);
737
+ const pattern = genAiPattern(plan);
644
738
  return `# ${workerName}
645
739
 
646
- Azure Functions worker scaffold for ${plan.pattern.label}.
740
+ Azure Functions worker scaffold for ${pattern.label}.
647
741
 
648
742
  This Function app uses the Python v2 decorator programming model and a Service Bus queue trigger. The trigger adapter should stay thin: decode the message, validate the envelope, and call shared code from \`backend/orchestration\` after that shared code is packaged with the Function app.
649
743
 
@@ -670,6 +764,7 @@ function renderFunctionHostJson() {
670
764
  }, null, 2);
671
765
  }
672
766
  function renderFunctionLocalSettings(plan) {
767
+ const pattern = genAiPattern(plan);
673
768
  return JSON.stringify({
674
769
  IsEncrypted: false,
675
770
  Values: {
@@ -677,7 +772,7 @@ function renderFunctionLocalSettings(plan) {
677
772
  FUNCTIONS_WORKER_RUNTIME: 'python',
678
773
  SERVICEBUS_QUEUE_NAME: 'events',
679
774
  ServiceBusConnection__fullyQualifiedNamespace: '<service-bus-namespace>.servicebus.windows.net',
680
- GENAI_PATTERN: plan.pattern.id,
775
+ GENAI_PATTERN: pattern.id,
681
776
  SHARED_ORCHESTRATION_ROOT: '../../backend'
682
777
  }
683
778
  }, null, 2);
@@ -688,7 +783,8 @@ pytest>=8.2
688
783
  `;
689
784
  }
690
785
  function renderFunctionApp(plan) {
691
- const moduleName = pyModule(plan.pattern.id);
786
+ const pattern = genAiPattern(plan);
787
+ const moduleName = pyModule(pattern.id);
692
788
  return `import json
693
789
  import logging
694
790
 
@@ -715,7 +811,7 @@ def decode_message_payload(body: str) -> dict:
715
811
  )
716
812
  def process_${moduleName}_work(message: func.ServiceBusMessage) -> None:
717
813
  payload = decode_message_payload(message.get_body().decode("utf-8"))
718
- logging.info("Received ${plan.pattern.id} worker message with keys: %s", sorted(payload.keys()))
814
+ logging.info("Received ${pattern.id} worker message with keys: %s", sorted(payload.keys()))
719
815
  # Keep this adapter thin; call backend.orchestration code from packaged shared modules.
720
816
  `;
721
817
  }
@@ -749,10 +845,11 @@ local.settings.json
749
845
  `;
750
846
  }
751
847
  function renderBackendEnv(plan, environment) {
848
+ const pattern = genAiPattern(plan);
752
849
  const transport = environment === 'dev' ? 'redis-streams' : 'azure-service-bus';
753
850
  return `APP_ENV=${environment}
754
851
  APP_NAME=${plan.safeProjectName}
755
- GENAI_PATTERN=${plan.pattern.id}
852
+ GENAI_PATTERN=${pattern.id}
756
853
  CLOUD_PROVIDER=${plan.provider.id}
757
854
  AZURE_REGION=${plan.region.slug}
758
855
  DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
@@ -763,9 +860,10 @@ LANGFUSE_HOST=
763
860
  `;
764
861
  }
765
862
  function renderFunctionsEnv(plan, environment) {
863
+ const pattern = genAiPattern(plan);
766
864
  return `APP_ENV=${environment}
767
865
  APP_NAME=${plan.safeProjectName}
768
- GENAI_PATTERN=${plan.pattern.id}
866
+ GENAI_PATTERN=${pattern.id}
769
867
  FUNCTIONS_WORKER_RUNTIME=python
770
868
  SERVICEBUS_QUEUE_NAME=events
771
869
  ServiceBusConnection__fullyQualifiedNamespace=<service-bus-namespace>.servicebus.windows.net
@@ -774,6 +872,7 @@ SHARED_ORCHESTRATION_ROOT=../../backend
774
872
  `;
775
873
  }
776
874
  function renderDockerCompose(plan) {
875
+ const localEnvironment = plan.environments.find((environment) => environment.id === 'dev') ?? plan.environments[0];
777
876
  const frontendService = plan.includeFrontend ? `
778
877
  frontend:
779
878
  build:
@@ -783,13 +882,17 @@ function renderDockerCompose(plan) {
783
882
  depends_on:
784
883
  - backend
785
884
  ` : '';
885
+ const postgresImage = plan.projectType.id === 'genai' ? 'pgvector/pgvector:pg16' : 'postgres:16-alpine';
786
886
  return `services:
787
887
  backend:
788
888
  build:
789
889
  context: .
790
890
  dockerfile: Dockerfile
791
891
  env_file:
792
- - ./environments/dev/backend.env
892
+ - ./environments/${localEnvironment.id}/backend.env
893
+ environment:
894
+ MESSAGING_TRANSPORT: redis-streams
895
+ BLOB_ENDPOINT: http://azurite:10000/devstoreaccount1
793
896
  ports:
794
897
  - "8000:8000"
795
898
  depends_on:
@@ -799,7 +902,7 @@ function renderDockerCompose(plan) {
799
902
  - mailpit
800
903
  ${frontendService}
801
904
  postgres:
802
- image: pgvector/pgvector:pg16
905
+ image: ${postgresImage}
803
906
  environment:
804
907
  POSTGRES_USER: postgres
805
908
  POSTGRES_PASSWORD: postgres
@@ -823,7 +926,7 @@ ${frontendService}
823
926
  ports:
824
927
  - "8025:8025"
825
928
 
826
- langfuse:
929
+ ${plan.projectType.id === 'genai' ? ` langfuse:
827
930
  image: langfuse/langfuse:2
828
931
  profiles:
829
932
  - observability
@@ -833,6 +936,7 @@ ${frontendService}
833
936
  SALT: local-development-placeholder
834
937
  ports:
835
938
  - "3000:3000"
939
+ ` : ''}
836
940
  `;
837
941
  }
838
942
  function renderTofuVersions() {
@@ -854,6 +958,13 @@ function renderTofuProviders() {
854
958
  `;
855
959
  }
856
960
  function renderTofuVariables(plan) {
961
+ const frontendVariables = plan.includeFrontend ? `
962
+ variable "frontend_image" {
963
+ type = string
964
+ default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
965
+ description = "Frontend image. Replace the bootstrap image with the generated frontend image after pushing it to ACR."
966
+ }
967
+ ` : '';
857
968
  const functionVariables = hasFunctionWorker(plan) ? `
858
969
  variable "function_worker_queue_name" {
859
970
  type = string
@@ -883,6 +994,18 @@ variable "resource_suffix" {
883
994
  description = "Globally unique suffix for Azure resource names."
884
995
  }
885
996
 
997
+ variable "backend_image" {
998
+ type = string
999
+ default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
1000
+ description = "Backend image. Replace the bootstrap image with the generated backend image after pushing it to ACR."
1001
+ }
1002
+
1003
+ variable "backend_target_port" {
1004
+ type = number
1005
+ default = 80
1006
+ description = "Backend ingress port. Set to 8000 when switching from the bootstrap image to the generated backend."
1007
+ }
1008
+ ${frontendVariables}
886
1009
  variable "postgres_admin_password" {
887
1010
  type = string
888
1011
  sensitive = true
@@ -898,6 +1021,18 @@ ${functionVariables}
898
1021
  `;
899
1022
  }
900
1023
  function renderTofuMain(plan) {
1024
+ const functionPattern = hasFunctionWorker(plan) ? genAiPattern(plan) : undefined;
1025
+ const projectIdentityEnv = plan.projectType.id === 'genai' ? `
1026
+ env {
1027
+ name = "GENAI_PATTERN"
1028
+ value = "${genAiPattern(plan).id}"
1029
+ }
1030
+ ` : `
1031
+ env {
1032
+ name = "API_STACK"
1033
+ value = "${plan.apiStack.id}"
1034
+ }
1035
+ `;
901
1036
  const frontendContainer = plan.includeFrontend ? `
902
1037
  resource "azurerm_container_app" "frontend" {
903
1038
  name = "ca-${plan.safeProjectName}-frontend-\${var.environment}"
@@ -905,10 +1040,20 @@ resource "azurerm_container_app" "frontend" {
905
1040
  resource_group_name = azurerm_resource_group.main.name
906
1041
  revision_mode = "Single"
907
1042
 
1043
+ identity {
1044
+ type = "UserAssigned"
1045
+ identity_ids = [azurerm_user_assigned_identity.app.id]
1046
+ }
1047
+
1048
+ registry {
1049
+ server = azurerm_container_registry.main.login_server
1050
+ identity = azurerm_user_assigned_identity.app.id
1051
+ }
1052
+
908
1053
  template {
909
1054
  container {
910
1055
  name = "frontend"
911
- image = "\${azurerm_container_registry.main.login_server}/${plan.safeProjectName}-frontend:latest"
1056
+ image = var.frontend_image
912
1057
  cpu = 0.25
913
1058
  memory = "0.5Gi"
914
1059
  }
@@ -922,6 +1067,8 @@ resource "azurerm_container_app" "frontend" {
922
1067
  latest_revision = true
923
1068
  }
924
1069
  }
1070
+
1071
+ depends_on = [azurerm_role_assignment.acr_pull]
925
1072
  }
926
1073
  ` : '';
927
1074
  const functionWorker = hasFunctionWorker(plan) ? `
@@ -955,7 +1102,7 @@ resource "azurerm_linux_function_app" "worker" {
955
1102
  app_settings = {
956
1103
  APP_ENV = var.environment
957
1104
  APP_NAME = "${plan.safeProjectName}"
958
- GENAI_PATTERN = "${plan.pattern.id}"
1105
+ GENAI_PATTERN = "${functionPattern?.id}"
959
1106
  FUNCTIONS_WORKER_RUNTIME = "python"
960
1107
  SERVICEBUS_QUEUE_NAME = var.function_worker_queue_name
961
1108
  ServiceBusConnection__fullyQualifiedNamespace = "\${azurerm_servicebus_namespace.main.name}.servicebus.windows.net"
@@ -999,6 +1146,12 @@ resource "azurerm_user_assigned_identity" "app" {
999
1146
  location = azurerm_resource_group.main.location
1000
1147
  }
1001
1148
 
1149
+ resource "azurerm_role_assignment" "acr_pull" {
1150
+ scope = azurerm_container_registry.main.id
1151
+ role_definition_name = "AcrPull"
1152
+ principal_id = azurerm_user_assigned_identity.app.principal_id
1153
+ }
1154
+
1002
1155
  resource "azurerm_container_app_environment" "main" {
1003
1156
  name = "cae-\${local.name_prefix}"
1004
1157
  resource_group_name = azurerm_resource_group.main.name
@@ -1016,23 +1169,85 @@ resource "azurerm_container_app" "backend" {
1016
1169
  identity_ids = [azurerm_user_assigned_identity.app.id]
1017
1170
  }
1018
1171
 
1172
+ registry {
1173
+ server = azurerm_container_registry.main.login_server
1174
+ identity = azurerm_user_assigned_identity.app.id
1175
+ }
1176
+
1177
+ secret {
1178
+ name = "database-url"
1179
+ value = "postgresql://liftoffadmin:\${urlencode(var.postgres_admin_password)}@\${azurerm_postgresql_flexible_server.main.fqdn}:5432/postgres?sslmode=require"
1180
+ }
1181
+
1182
+ secret {
1183
+ name = "redis-url"
1184
+ value = "rediss://:\${urlencode(azurerm_redis_cache.main.primary_access_key)}@\${azurerm_redis_cache.main.hostname}:\${azurerm_redis_cache.main.ssl_port}/0"
1185
+ }
1186
+
1019
1187
  template {
1020
1188
  container {
1021
1189
  name = "backend"
1022
- image = "\${azurerm_container_registry.main.login_server}/${plan.safeProjectName}-backend:latest"
1190
+ image = var.backend_image
1023
1191
  cpu = 0.5
1024
1192
  memory = "1Gi"
1193
+
1194
+ env {
1195
+ name = "APP_ENV"
1196
+ value = var.environment
1197
+ }
1198
+
1199
+ env {
1200
+ name = "APP_NAME"
1201
+ value = "${plan.safeProjectName}"
1202
+ }
1203
+
1204
+ env {
1205
+ name = "PROJECT_TYPE"
1206
+ value = "${plan.projectType.id}"
1207
+ }
1208
+ ${projectIdentityEnv}
1209
+ env {
1210
+ name = "CLOUD_PROVIDER"
1211
+ value = "azure"
1212
+ }
1213
+
1214
+ env {
1215
+ name = "AZURE_REGION"
1216
+ value = var.location
1217
+ }
1218
+
1219
+ env {
1220
+ name = "DATABASE_URL"
1221
+ secret_name = "database-url"
1222
+ }
1223
+
1224
+ env {
1225
+ name = "REDIS_URL"
1226
+ secret_name = "redis-url"
1227
+ }
1228
+
1229
+ env {
1230
+ name = "MESSAGING_TRANSPORT"
1231
+ value = "azure-service-bus"
1232
+ }
1233
+
1234
+ env {
1235
+ name = "BLOB_ENDPOINT"
1236
+ value = azurerm_storage_account.main.primary_blob_endpoint
1237
+ }
1025
1238
  }
1026
1239
  }
1027
1240
 
1028
1241
  ingress {
1029
1242
  external_enabled = true
1030
- target_port = 8000
1243
+ target_port = var.backend_target_port
1031
1244
  traffic_weight {
1032
1245
  percentage = 100
1033
1246
  latest_revision = true
1034
1247
  }
1035
1248
  }
1249
+
1250
+ depends_on = [azurerm_role_assignment.acr_pull]
1036
1251
  }
1037
1252
  ${frontendContainer}
1038
1253
  resource "azurerm_postgresql_flexible_server" "main" {
@@ -1046,6 +1261,14 @@ resource "azurerm_postgresql_flexible_server" "main" {
1046
1261
  sku_name = "B_Standard_B1ms"
1047
1262
  }
1048
1263
 
1264
+ resource "azurerm_postgresql_flexible_server_firewall_rule" "azure_services" {
1265
+ count = var.enable_private_networking ? 0 : 1
1266
+ name = "AllowAzureServices"
1267
+ server_id = azurerm_postgresql_flexible_server.main.id
1268
+ start_ip_address = "0.0.0.0"
1269
+ end_ip_address = "0.0.0.0"
1270
+ }
1271
+
1049
1272
  resource "azurerm_redis_cache" "main" {
1050
1273
  name = "redis-\${local.name_prefix}-\${var.resource_suffix}"
1051
1274
  location = azurerm_resource_group.main.location
@@ -1119,6 +1342,10 @@ ${plan.includeFrontend ? `output "frontend_url" {
1119
1342
  ` : ''}${functionOutputs}output "container_registry" {
1120
1343
  value = azurerm_container_registry.main.login_server
1121
1344
  }
1345
+
1346
+ output "container_registry_name" {
1347
+ value = azurerm_container_registry.main.name
1348
+ }
1122
1349
  `;
1123
1350
  }
1124
1351
  function renderTofuLocalState() {
@@ -1149,30 +1376,98 @@ This project includes an Azure Functions worker under \`functions/${functionWork
1149
1376
 
1150
1377
  Azure is the complete V1 provider for this Liftoff project.
1151
1378
 
1379
+ ## Bootstrap Infrastructure
1380
+
1381
+ The first apply uses a public bootstrap image so Azure Container Apps can start before the new ACR contains application images.
1382
+
1152
1383
  \`\`\`bash
1153
1384
  tofu init
1154
1385
  tofu plan -var-file=environments/${env}.tfvars
1155
1386
  tofu apply -var-file=environments/${env}.tfvars
1156
- tofu output
1387
+ \`\`\`
1388
+
1389
+ Build the generated backend in ACR, then replace the bootstrap image:
1390
+
1391
+ \`\`\`bash
1392
+ ACR_NAME="$(tofu output -raw container_registry_name)"
1393
+ az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-backend:latest ../../..
1394
+ ${plan.includeFrontend ? `az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-frontend:latest ../../../frontend
1395
+ ` : ''}\`\`\`
1396
+
1397
+ Persist the deployed images in \`environments/${env}.tfvars\` so future applies do not restore the bootstrap image:
1398
+
1399
+ \`\`\`hcl
1400
+ backend_image = "<login-server>/${plan.safeProjectName}-backend:latest"
1401
+ backend_target_port = 8000
1402
+ ${plan.includeFrontend ? `frontend_image = "<login-server>/${plan.safeProjectName}-frontend:latest"
1403
+ ` : ''}\`\`\`
1404
+
1405
+ \`\`\`bash
1406
+ tofu apply -var-file=environments/${env}.tfvars
1157
1407
  \`\`\`
1158
1408
 
1159
1409
  Local OpenTofu state is generated by default. Use \`backend.remote.example.tf\` as the starting point for team remote state.
1410
+ The default PostgreSQL firewall permits Azure-hosted services. Replace it with private networking before production; set \`enable_private_networking=true\` only when the required VNet, delegated subnet, and private DNS resources are added.
1160
1411
  ${functionSection}
1161
1412
  `;
1162
1413
  }
1163
1414
  function renderTofuTfvars(plan, environment) {
1164
- const functionValues = hasFunctionWorker(plan) ? `function_worker_queue_name = "events"
1165
- functions_python_version = "3.12"
1166
- ` : '';
1167
- return `environment = "${environment}"
1168
- location = "${plan.region.slug}"
1169
- resource_suffix = "${plan.safeProjectName.replace(/-/g, '')}${environment}"
1170
- enable_private_networking = ${environment === 'prod' ? 'true' : 'false'}
1171
- ${functionValues}
1172
- `;
1415
+ const values = [
1416
+ ['environment', JSON.stringify(environment)],
1417
+ ['location', JSON.stringify(plan.region.slug)],
1418
+ ['resource_suffix', JSON.stringify(`${plan.safeProjectName.replace(/-/g, '')}${environment}`)],
1419
+ ['backend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')],
1420
+ ['backend_target_port', '80'],
1421
+ ['enable_private_networking', 'false']
1422
+ ];
1423
+ if (plan.includeFrontend) {
1424
+ values.push(['frontend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')]);
1425
+ }
1426
+ if (hasFunctionWorker(plan)) {
1427
+ values.push(['function_worker_queue_name', JSON.stringify('events')], ['functions_python_version', JSON.stringify('3.12')]);
1428
+ }
1429
+ const width = Math.max(...values.map(([key]) => key.length));
1430
+ return values.map(([key, value]) => `${key.padEnd(width)} = ${value}`).join('\n');
1173
1431
  }
1174
1432
  function renderOpenSpecConfig(plan) {
1175
1433
  const frontendRule = plan.includeFrontend ? '\n - Keep frontend code under frontend.' : '';
1434
+ if (plan.projectType.id === 'standard') {
1435
+ const backendRule = plan.apiStack.id === 'python-fastapi'
1436
+ ? 'Keep backend API code under backend/apis.'
1437
+ : plan.apiStack.id === 'node-fastify'
1438
+ ? 'Keep backend API code under backend/src.'
1439
+ : 'Keep the Go entrypoint under backend/cmd/api and reusable code under backend/internal.';
1440
+ return `schema: spec-driven
1441
+
1442
+ context: |
1443
+ Project generated by Mission Control Liftoff.
1444
+ Project type: Standard application.
1445
+ API stack: ${plan.apiStack.label}.
1446
+ Database tooling: ${plan.apiStack.databaseTooling}.
1447
+ API developer portal: Scalar.
1448
+ Infrastructure: OpenTofu.
1449
+ Primary cloud: Azure (${plan.region.slug}).
1450
+ Local development: Docker Compose.
1451
+ Database: PostgreSQL.
1452
+ Cache and local messaging: Redis.
1453
+ Environments: ${plan.environments.map((environment) => environment.id).join(', ')}.
1454
+
1455
+ rules:
1456
+ specs:
1457
+ - Requirements must describe observable product behavior.
1458
+ - Cloud behavior must identify environment differences for generated environments.
1459
+ design:
1460
+ - Use ${plan.apiStack.framework} for backend APIs.
1461
+ - Use ${plan.apiStack.databaseTooling} for database access and migrations.
1462
+ - Use OpenTofu for infrastructure changes.${frontendRule}
1463
+ - ${backendRule}
1464
+ - Keep database artifacts under database.
1465
+ tasks:
1466
+ - Include local Docker Compose verification.
1467
+ - Include OpenTofu validation for generated infrastructure.
1468
+ `;
1469
+ }
1470
+ const pattern = genAiPattern(plan);
1176
1471
  const functionsContext = hasFunctionWorker(plan) ? `
1177
1472
  Azure Functions worker: functions/${functionWorkerName(plan)}.` : '';
1178
1473
  const functionsRule = hasFunctionWorker(plan) ? `
@@ -1182,7 +1477,7 @@ function renderOpenSpecConfig(plan) {
1182
1477
 
1183
1478
  context: |
1184
1479
  Project generated by Mission Control Liftoff.
1185
- GenAI pattern: ${plan.pattern.label}
1480
+ GenAI pattern: ${pattern.label}
1186
1481
  Application framework: FastAPI + PydanticAI.
1187
1482
  API developer portal: Scalar.
1188
1483
  Infrastructure: OpenTofu.
@@ -1211,10 +1506,36 @@ ${functionsRule}
1211
1506
  `;
1212
1507
  }
1213
1508
  function renderSeedProposal(plan) {
1509
+ if (plan.projectType.id === 'standard') {
1510
+ return `## Why
1511
+
1512
+ Bootstrap the generated ${plan.apiStack.label} standard application baseline created by Mission Control Liftoff.
1513
+
1514
+ ## What Changes
1515
+
1516
+ - Establish the approved backend, infrastructure, local development, and governance baseline.
1517
+ - Capture follow-up product requirements through spec-driven changes.
1518
+
1519
+ ## Capabilities
1520
+
1521
+ ### New Capabilities
1522
+
1523
+ - \`${plan.apiStack.id}-application-baseline\`: Generated standard application baseline for this Liftoff project.
1524
+
1525
+ ### Modified Capabilities
1526
+
1527
+ - None.
1528
+
1529
+ ## Impact
1530
+
1531
+ - Generated ${plan.apiStack.label} backend, OpenTofu infrastructure, Docker Compose local development, and governance files.
1532
+ `;
1533
+ }
1534
+ const pattern = genAiPattern(plan);
1214
1535
  const functionsChange = hasFunctionWorker(plan) ? '\n- Establish Azure Functions worker trigger adapters for event-driven processing.' : '';
1215
1536
  return `## Why
1216
1537
 
1217
- Bootstrap the generated ${plan.pattern.label} application baseline created by Mission Control Liftoff.
1538
+ Bootstrap the generated ${pattern.label} application baseline created by Mission Control Liftoff.
1218
1539
 
1219
1540
  ## What Changes
1220
1541
 
@@ -1226,7 +1547,7 @@ ${functionsChange}
1226
1547
 
1227
1548
  ### New Capabilities
1228
1549
 
1229
- - \`${plan.pattern.id}-application-baseline\`: Generated application baseline for this Liftoff project.
1550
+ - \`${pattern.id}-application-baseline\`: Generated application baseline for this Liftoff project.
1230
1551
 
1231
1552
  ### Modified Capabilities
1232
1553
 
@@ -1238,10 +1559,38 @@ ${functionsChange}
1238
1559
  `;
1239
1560
  }
1240
1561
  function renderSeedDesign(plan) {
1562
+ if (plan.projectType.id === 'standard') {
1563
+ return `## Context
1564
+
1565
+ This standard project was generated with Liftoff using ${plan.apiStack.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
1566
+
1567
+ ## Goals / Non-Goals
1568
+
1569
+ **Goals:**
1570
+
1571
+ - Keep the generated baseline aligned to the approved Mission Control stack.
1572
+
1573
+ **Non-Goals:**
1574
+
1575
+ - Define domain-specific product behavior in the bootstrap change.
1576
+
1577
+ ## Decisions
1578
+
1579
+ - Use ${plan.apiStack.framework} for backend APIs.
1580
+ - Use ${plan.apiStack.databaseTooling} for PostgreSQL integration.
1581
+ - Use OpenTofu for Azure infrastructure.
1582
+ - Use Docker Compose for local development.
1583
+
1584
+ ## Risks / Trade-offs
1585
+
1586
+ - The baseline contains placeholders that product-specific changes should replace.
1587
+ `;
1588
+ }
1589
+ const pattern = genAiPattern(plan);
1241
1590
  const functionsDecision = hasFunctionWorker(plan) ? '\n- Keep Azure Functions trigger adapters under functions/' + functionWorkerName(plan) + ' and shared GenAI logic under backend/orchestration.' : '';
1242
1591
  return `## Context
1243
1592
 
1244
- This project was generated with Liftoff using ${plan.pattern.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
1593
+ This project was generated with Liftoff using ${pattern.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
1245
1594
 
1246
1595
  ## Goals / Non-Goals
1247
1596
 
@@ -1274,6 +1623,28 @@ function renderSeedTasks() {
1274
1623
  `;
1275
1624
  }
1276
1625
  function renderSpecKitConstitution(plan) {
1626
+ if (plan.projectType.id === 'standard') {
1627
+ const backendLayout = plan.apiStack.id === 'python-fastapi'
1628
+ ? 'backend/apis'
1629
+ : plan.apiStack.id === 'node-fastify' ? 'backend/src' : 'backend/cmd/api and backend/internal';
1630
+ return `# Mission Control Liftoff Constitution
1631
+
1632
+ ## Principle 1: Approved Application Stack
1633
+ Generated backend services MUST use ${plan.apiStack.framework}, ${plan.apiStack.databaseTooling}, and Scalar for API documentation.
1634
+
1635
+ ## Principle 2: Standard Project Layout
1636
+ Backend APIs live under ${backendLayout}. Database artifacts live under database.${plan.includeFrontend ? ' Frontend code lives under frontend.' : ''}
1637
+
1638
+ ## Principle 3: Infrastructure As Code
1639
+ Cloud infrastructure MUST be defined with OpenTofu. Azure is the supported V1 provider.
1640
+
1641
+ ## Principle 4: Local Development Parity
1642
+ Projects MUST include Docker Compose for local development with PostgreSQL, Redis, local blob storage, and local messaging behavior.
1643
+
1644
+ ## Principle 5: Observability And Operations
1645
+ Services MUST use structured logging and environment-specific configuration for ${plan.environments.map((environment) => environment.id).join(', ')}.
1646
+ `;
1647
+ }
1277
1648
  const functionsLayout = hasFunctionWorker(plan) ? ` Azure Functions trigger adapters live under functions/${functionWorkerName(plan)} and call shared orchestration from backend/orchestration.` : '';
1278
1649
  return `# Mission Control Liftoff Constitution
1279
1650
 
@@ -1335,7 +1706,7 @@ function renderFrontendPackage(plan) {
1335
1706
  }, null, 2);
1336
1707
  }
1337
1708
  function renderFrontendIndex(plan) {
1338
- return `<div id="app"></div><script type="module" src="/src/main.ts"></script><title>${plan.projectName}</title>`;
1709
+ return `<div id="app"></div><script type="module" src="/src/main.ts"></script><title>${escapeHtml(plan.projectName)}</title>`;
1339
1710
  }
1340
1711
  function renderFrontendMain() {
1341
1712
  return `import { createApp } from 'vue';
@@ -1346,10 +1717,11 @@ createApp(App).mount('#app');
1346
1717
  `;
1347
1718
  }
1348
1719
  function renderFrontendApp(plan) {
1720
+ const descriptor = plan.projectType.id === 'genai' ? `${genAiPattern(plan).label} starter` : `${plan.apiStack.label} starter`;
1349
1721
  return `<script setup lang="ts">
1350
- const title = '${plan.projectName}';
1351
- const starter = '${plan.frontendStarter}';
1352
- const pattern = '${plan.pattern.label}';
1722
+ const title = ${scriptSourceString(plan.projectName)};
1723
+ const starter = ${scriptSourceString(plan.frontendStarter)};
1724
+ const descriptor = ${scriptSourceString(descriptor)};
1353
1725
  </script>
1354
1726
 
1355
1727
  <template>
@@ -1358,7 +1730,7 @@ const pattern = '${plan.pattern.label}';
1358
1730
  <header>
1359
1731
  <p class="text-sm font-semibold uppercase tracking-wide text-emerald-700">Mission Control Liftoff</p>
1360
1732
  <h1 class="mt-2 text-3xl font-bold">{{ title }}</h1>
1361
- <p class="mt-2 text-slate-600">{{ pattern }} starter</p>
1733
+ <p class="mt-2 text-slate-600">{{ descriptor }}</p>
1362
1734
  </header>
1363
1735
  <section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
1364
1736
  <h2 class="text-xl font-semibold">{{ starter }}</h2>