Design Spec: Synthing Phase 1
1. Overview
Synthing is a full render pipeline that consumes variables (and later secrets) from multiple sources, then generates output through pluggable targets. It is tool-agnostic and not tied to any specific adapter, runtime, or deployment platform.
Synthing extends patterns from kubricate. Kubricate v2 will eventually merge into Synthing.
Phase 1 Packages
| Package |
Responsibility |
@synthing/core |
Interfaces + types only: BaseConnector, BaseGenerator, error types, shared type definitions |
@synthing/toolkit |
Pure utility functions: coercion helpers. Zero dependencies. |
synthing (unscoped) |
CLI frontend (cli/) + engine (engine/): VariableManager, $var, resolution, GeneratorContext, pipeline runner, YamlGenerator, defineConfig(), CLI commands, file writer |
@synthing/plugin-env |
EnvConnector (domain-agnostic, serves both variables and secrets). Peer dep on @synthing/core. |
Package Architecture
@synthing/core ← no internal deps (interfaces only, rarely changes)
@synthing/toolkit ← no internal deps (pure utilities)
@synthing/plugin-env ← peer dep: @synthing/core
synthing ← depends on: @synthing/core, @synthing/toolkit
Internal directory structure of synthing package:
packages/synthing/src/
cli/ → arg parsing, config file loading, defineConfig(), file writer
engine/ → VariableManager, $var, resolution, GeneratorContext, pipeline runner, YamlGenerator
Rules:
engine/ never imports from cli/ (enables future extraction to @synthing/engine)
cli/ imports from engine/
engine/ has no filesystem I/O, no arg parsing, no process.argv
@synthing/core has minimal surface area to prevent breaking changes across the ecosystem
- Plugins peer-depend on
@synthing/core only (not on synthing)
- Users install plugins themselves (
synthing does not bundle plugins)
2. Variable System
2.1 Deferred References (Branded String)
$var("key") returns a VariableRef<T> — a branded string type. At runtime it is a plain string containing a tag ("$${{key}}"). At compile time TypeScript tracks the target type via the brand.
// Type definition
type VariableRef<T> = string & { __varRef: T; __key: string };
const { $var, $spread } = variableManager.createRef();
// At compile time: VariableRef<number>
// At runtime: "$${{port}}"
const portRef = $var("port", { default: 3000 });
Why branded string:
- Assignable to
string — works in any context expecting strings (kubricate templates, YAML serialization, JSON.stringify)
- Compile-time type safety —
VariableRef<number> vs VariableRef<string> prevents mixing up variable types within synthing's own APIs
- No class overhead — no
instanceof, no toJSON(), no prototype chain
- One resolution mechanism — both generator and text pipelines scan for
$${{...}} tag patterns
- Runtime type validation — happens at resolution time via schema-based coercion, not at creation time
References are immutable. Each call to $var("key", { default: value }) returns a new VariableRef. Multiple call sites can have different defaults for the same key.
Recommended convention: const { $var, $spread } = variableManager.createRef(). The $ prefix is a visual marker meaning "this is a reference." Users can rename via destructuring (e.g., const { $var: v, $spread: s } = ...).
2.2 $var() API Shape
Single function signature. Type is inferred from the registry, not from method factories.
// Type inferred from registry (port was declared as "number")
$var("port") // VariableRef<number> (runtime: "$${{port}}")
$var("port", { default: 3000 }) // VariableRef<number> (runtime: "$${{port}}")
$var("app_name") // VariableRef<string> (runtime: "$${{app_name}}")
There are no .number() / .string() factories and no .withDefault() method chaining.
2.3 Variable Keys
- Flat strings, not hierarchical
- Regex:
^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$
- Dots are cosmetic grouping only, not structural (no nesting semantics)
- Validated at
.addVariable() time (fail fast)
- No reserved prefixes
2.4 Resolution
Resolution is always async, even for sync sources like environment variables. This avoids breaking the API when async sources are added later.
The engine scans for $${{key}} tag patterns in strings, resolves each key via connectors, and coerces to the target type defined in the schema.
// Internal engine API (users never call directly)
async resolve(tag: string): Promise<unknown> // single tag resolution
async resolveAll(input: unknown): Promise<unknown> // deep-walk and resolve all tags
- Both generator and text pipelines use the same resolution mechanism — tag pattern matching
- Resolution is internal to the runtime. Users never call these directly.
2.5 Resolution Precedence
Explicit priority list, first match wins. Connectors are tried in order of registration.
For a given key, the value precedence is:
- Connector value (first connector that returns a value wins)
- Call-site default (
$var("key", { default: value }))
- Schema default (from
.addVariable("key", { default: value }))
- In strict mode: throw
ResolutionError. In loose mode: return undefined.
2.6 Type Coercion
Shared coercion utilities (from @synthing/toolkit) — toolkit exports coerceFromString() helpers. Connectors and the engine may use them. The engine performs the final type-check to ensure basic type matching (a variable declared as number returns a number or errors).
// @synthing/toolkit exports
coerceFromString(value: string, targetType: "number"): number
coerceFromString(value: string, targetType: "boolean"): boolean
coerceFromString(value: string, targetType: "string"): string
coerceFromString(value: string, targetType: "object"): object | unknown[]
For object type: JSON.parse() the string, reject if result is a primitive (string, number, boolean). Only objects and arrays are accepted.
2.7 Schema Validation
Variables can optionally provide a schema for runtime validation using the Standard Schema interface. When provided, validation is enforced at resolve time.
import { z } from "zod";
vm.addVariable("port", {
type: "number",
schema: z.number().min(1).max(65535),
})
vm.addVariable("extra_env", {
type: "object",
schema: z.array(z.object({
name: z.string(),
value: z.string(),
})),
})
schema is optional on all variable types
- Must implement Standard Schema interface (
~standard)
- Resolution pipeline: raw string → coerce by
type → validate by schema (if provided)
type handles coercion (how to parse), schema handles validation (is the value correct)
- If
type and schema contradict (e.g., type: "object" + z.string()), coercion succeeds but schema validation fails with a clear error
- No schema = basic type check only (string/number/boolean/object coercion)
- If schema also implements Standard JSON Schema (
~standard.jsonSchema), toJSON() exports the JSON Schema for UI consumers
- If no Standard JSON Schema support,
toJSON() falls back to basic type metadata
2.8 Strict vs Loose Mode
Scoped narrowly to missing-value behavior only:
- Strict: missing value with no default throws
ResolutionError
- Loose: missing value with no default returns
undefined
Strict mode is the recommended default for production use.
2.9 Error Handling
resolve() throws typed ResolutionError. For batch resolveAll(), multiple failures can be attached to one error object.
class ResolutionError extends Error {
key: string;
reason: "missing" | "type_mismatch" | "connector_error";
// For resolveAll(), multiple failures
failures?: Array<{ key: string; reason: string }>;
}
2.10 Secret Marker
Phase 1 uses a simple marker, not a full secret system.
.addVariable("db_password", { type: "string", secret: true })
Variables marked secret: true are:
- Redacted in logs and error messages
- Redacted in
toJSON() output
There is no SecretProvider, no unwrap ceremony, no SecretManager in Phase 1.
3. VariableManager
A single class that declares variables AND composes connectors. Follows kubricate's SecretManager pattern. There is no separate VariableRegistry.
3.1 Builder Pattern
Uses generic accumulation for type safety. <K extends string> infers literal types — no as const needed.
const variableManager = new VariableManager()
.addVariable("app_name", { type: "string" })
.addVariable("port", { type: "number", default: 3000 })
.addVariable("db_password", { type: "string", secret: true })
.addConnector("env", new EnvConnector({ prefix: "APP_" }));
const { $var, $spread } = variableManager.createRef();
.addVariable(key, schema) — registers a variable with its type and metadata. Key is validated against the key regex immediately.
.addConnector(name, connector) — registers a connector. Order of registration determines priority.
.createRef() — returns { $var, $spread } bound to this manager's variable declarations. $spread only accepts keys declared with type: "object".
3.2 Schema Export
variableManager.toJSON() — serializes variable metadata (keys, types, defaults, descriptions, secret flag). Does NOT include connector configuration.
- If a variable has a
schema that implements Standard JSON Schema (~standard.jsonSchema), the JSON Schema is included in the export for UI consumers.
- CLI command:
synthing variable export-schema — outputs the same JSON to stdout.
3.3 Keys Declared in Schema, Connectors are Pure Value Readers
Users declare keys via .addVariable(). Connectors do not contribute or discover keys — they only read values for keys they are asked about.
4. Connectors
4.1 BaseConnector (in @synthing/core)
BaseConnector is domain-agnostic. The same connector class serves both variables and secrets. It lives in @synthing/core as a pure abstract interface.
Naming: BaseConnector, not BaseVariableConnector or BaseVariableResolver. Aligns with kubricate's BaseConnector term.
4.2 Two-Phase Interface
Matches kubricate's BaseConnector interface:
abstract class BaseConnector {
/** Load/prepare values for the given keys (async, called once) */
abstract load(keys: string[]): Promise<void>;
/** Get a single value (sync, called per-key after load) */
abstract get(key: string): unknown | undefined;
}
load(keys[]) — called once with all keys this connector might need. Allows batch fetching.
get(key) — called per-key after load. Returns the raw value or undefined.
4.3 EnvConnector (in @synthing/plugin-env)
Package: @synthing/plugin-env (not variables-resolver-env). Shared plugin that serves both variable and secret domains.
Class name: EnvConnector (not VariableEnvConnector). Package scope provides context.
import { EnvConnector } from "@synthing/plugin-env";
new EnvConnector({ prefix: "APP_" })
// Reads APP_PORT, APP_DB_PASSWORD from process.env
Users can separate variable vs secret env vars via prefix convention (e.g., VAR_, SECRET_).
5. Generator System
5.1 BaseGenerator (in @synthing/core)
Abstract class with a format property and two methods. Lives in @synthing/core as a pure abstract interface.
abstract class BaseGenerator {
/** Format this generator produces — used by engine for resolution */
abstract readonly format: string;
/** Produce content (objects, not strings) */
abstract render(ctx: GeneratorContext): Promise<GeneratorOutput>;
/** Serialize objects into file content */
abstract serialize(content: unknown): SerializedOutput[];
}
render() returns objects (GeneratorOutput.content is unknown), NOT serialized strings.
serialize() turns objects into SerializedOutput[] (filename + string content).
- This matches kubricate's pattern: Stack returns objects, Renderer serializes.
5.2 GeneratorContext (in synthing engine)
Provided to render(). Lives in the engine layer (inside synthing package), not in @synthing/core.
interface GeneratorContext {
outputDir: string;
logger: Logger;
}
Generators do not resolve variables — they return objects with $${{tag}} strings. The engine resolves all tags after serialize() using the structural resolver (same resolver as text pipeline).
5.3 Constructor Shape
Options object with filename and create field. $var is from createRef(), not passed into create:
const { $var } = vm.createRef();
const deployment = new YamlGenerator({
filename: "deployment.yaml",
create: () => ({
apiVersion: "apps/v1",
kind: "Deployment",
metadata: { name: $var("app_name") },
spec: {
replicas: $var("replicas", { default: 1 }),
},
}),
});
create() can return a single object or an array of objects. Array produces multi-document YAML (separated by ---):
const resources = new YamlGenerator({
filename: "resources.yaml",
create: () => [
{ apiVersion: "apps/v1", kind: "Deployment", metadata: { name: $var("app_name") } },
{ apiVersion: "v1", kind: "Service", metadata: { name: $var("app_name") } },
],
});
Output:
apiVersion: apps/v1
kind: Deployment
metadata:
name: "$${{app_name}}"
---
apiVersion: v1
kind: Service
metadata:
name: "$${{app_name}}"
---
5.4 Format is Generator-Level
Each generator handles its own serialization. The pipeline never knows about format. YamlGenerator always outputs YAML. A future JsonGenerator would output JSON.
5.5 YamlGenerator (Built-in)
Ships inside the synthing package (engine layer). Renders objects to YAML files using its serialize() method.
- Single object → single YAML document
- Array of objects → multi-document YAML with
--- separators (matches kubricate's pattern)
- Filename provided in constructor
Future format-specific generators (TOML, JSON5, HCL) could be separate plugin packages.
6. Pipeline and Config
6.1 defineConfig()
Follows kubricate's pattern. Top-level keys are domain concerns.
import { defineConfig } from "synthing";
export default defineConfig({
variable: {
variableSpec: variableManager,
strictMode: true,
},
pipelines: [
{
type: "generator",
generators: [deployment, service],
writer: { type: "file", dir: "output" },
},
],
});
6.2 Pipelines
pipelines is an array. Each pipeline is a complete flow: input -> resolve -> render -> serialize -> write.
Pipelines use a discriminated union on type:
"generator" — Phase 1. Uses generators to produce output.
"text" — Phase 1. Reads files with $${{var.xxx}} tags, replaces with resolved values. Language-agnostic.
6.3 Generator Pipeline
interface GeneratorPipeline {
type: "generator";
generators: BaseGenerator[]; // "generators", not "configs"
writer: Writer;
}
6.4 Text Pipeline
interface TextPipeline {
type: "text";
input: string; // directory or glob pattern to read
format: "yaml" | "json" | "text" | TextFormat; // explicit format declaration
writer: Writer;
}
// Custom format handler interface
interface TextFormat {
parse(content: string): unknown;
stringify(data: unknown): string;
}
Reads files from input, resolves $${{var_key}} tags via VariableManager, and writes output via the writer. The format field determines how resolution works.
Tag Format
Fixed format: $${{variable_key}} — not configurable in Phase 1. The $$ prefix avoids shell substitution conflicts. Matches the key registered via .addVariable().
Format Handling
The format field is explicit — no auto-detection from file extension.
Built-in formats:
| Format |
Resolution |
Typed values |
Spread support |
"yaml" |
Structural (parse → resolve → re-serialize) |
Yes |
Yes |
"json" |
Structural (parse → resolve → re-serialize) |
Yes |
Yes |
"text" |
Plain string replacement |
No (all strings) |
No |
Custom format handler:
import * as toml from "toml";
{
type: "text",
input: "./toml-templates/",
format: {
parse: (content) => toml.parse(content),
stringify: (data) => toml.stringify(data),
},
writer: { type: "file", dir: "output/" },
}
Custom handlers get structural resolution (typed values + spread) — same engine as "yaml" and "json".
Plain text mode ("text"):
- Finds
$${{key}} in file content
- Replaces with string value
- No type awareness — everything becomes string
- No spread support
Structural mode ("yaml", "json", or custom handler):
- Parses file via
format.parse() (or built-in parser)
- Walks tree, finds
$${{key}} string values
- Replaces with typed values (numbers stay numbers, objects stay objects)
- Re-serializes via
format.stringify() (or built-in serializer)
- Supports spread via
__synthing_spread marker (see section 6.4.1)
Format consistency with generator pipeline
Both pipelines use the same structural resolver. The format is always explicit:
| Pipeline |
Format declared by |
| Generator |
Generator class (BaseGenerator.format property) |
| Text |
Pipeline config (format field) |
6.4.1 Spread Syntax ($spread)
For merging arrays/objects from variables into existing structures. Only works in structural mode.
Helper function (from createRef()):
const { $var, $spread } = vm.createRef();
$spread("extra_env")
// returns { key: "__synthing_spread", value: "$${{...extra_env}}" }
// Only accepts keys declared with type: "object" — compile error for string/number/boolean keys
Array spread — insert items into parent array:
env: [
{ name: "NODE_ENV", value: "production" },
{ [$spread("extra_env").key]: $spread("extra_env").value, name: "", value: "" },
]
Serializes to YAML:
env:
- name: NODE_ENV
value: production
- __synthing_spread: "$${{...extra_env}}"
name: ""
value: ""
Pipeline detects __synthing_spread key → resolves $${{...extra_env}} → replaces entire object with expanded array items:
env:
- name: NODE_ENV
value: production
- name: DB_HOST
value: db.prod.example.com
- name: DB_PORT
value: "5432"
Object spread — merge keys into parent object:
labels: {
app: $var("app_name"),
[$spread("extra_labels").key]: $spread("extra_labels").value,
}
Serializes to YAML:
labels:
app: "$${{app_name}}"
__synthing_spread: "$${{...extra_labels}}"
Pipeline detects __synthing_spread key → resolves → merges keys into parent, removes marker:
labels:
app: myapp
version: 1.0.0
team: backend
Pipeline detection rules:
- Array: object with
__synthing_spread key → resolve value, splice expanded items in place of the marker object
- Object: key named
__synthing_spread → resolve value, merge into parent, remove marker key
__synthing_spread in plain text mode → error
Flow
- Glob input files
- For each file, use the pipeline's
format setting:
- Plain text (
"text"): scan for $${{key}} patterns, replace with string values
- Structural (
"yaml", "json", or custom handler): parse file, deep-walk tree:
a. Find __synthing_spread markers → resolve and expand
b. Find $${{key}} string values → resolve with typed replacement
c. Re-serialize to file format
- Write output files via writer
6.5 Writer
Per-pipeline writer. Discriminated union on type.
Phase 1: { type: "file", dir: "output" } only.
Future: { type: "api", ... }, { type: "stdout" }, etc.
type Writer =
| { type: "file"; dir: string }
// Future:
// | { type: "api"; endpoint: string }
// | { type: "stdout" }
6.6 Resolution is Internal
Users never call resolveAll() directly. The CLI reads defineConfig(), runs pipelines, and resolution happens internally during render() (generator) or tag replacement (text).
7. CLI
7.1 synthing generate
Main command. Reads defineConfig(), executes all pipelines, writes output.
Generator pipeline flow:
- Load config file
- For each pipeline:
a. Call load(keys) on all connectors
b. For each generator, call render(ctx) — generator calls ctx.resolve() as needed
c. Call serialize(content) on each generator's output
d. Write serialized files via the pipeline's writer
Text pipeline flow:
- Load config file
- For each pipeline:
a. Call load(keys) on all connectors
b. Glob input files from input path
c. Use pipeline's format setting (explicit, not auto-detected)
d. Resolve tags — "text": string replacement; structural ("yaml", "json", custom): parse, walk, replace typed values, handle __synthing_spread, re-serialize
e. Write output files via the pipeline's writer
7.2 synthing variable export-schema
Outputs the variable schema as JSON. Equivalent to calling variableManager.toJSON() and writing to stdout.
8. Simple YamlGenerator Example
A complete standalone example using only synthing — no kubricate.
// synthing.config.ts
import { VariableManager, YamlGenerator, defineConfig } from "synthing";
import { EnvConnector } from "@synthing/plugin-env";
// 1. Define variables
const vm = new VariableManager()
.addVariable("app_name", { type: "string" })
.addVariable("port", { type: "number", default: 3000 })
.addVariable("replicas", { type: "number", default: 1 })
.addConnector("env", new EnvConnector({ prefix: "APP_" }));
const { $var } = vm.createRef();
// 2. Define generator
const deployment = new YamlGenerator({
filename: "deployment.yaml",
create: () => ({
apiVersion: "apps/v1",
kind: "Deployment",
metadata: { name: $var("app_name") },
spec: {
replicas: $var("replicas"),
template: {
spec: {
containers: [{
name: $var("app_name"),
image: "nginx:latest",
ports: [{ containerPort: $var("port") }],
}],
},
},
},
}),
});
// 3. Export config
export default defineConfig({
variable: { variableSpec: vm, strictMode: true },
pipelines: [
{ type: "generator", generators: [deployment], writer: { type: "file", dir: "output/" } },
],
});
APP_APP_NAME=myapp APP_PORT=8080 APP_REPLICAS=3 synthing generate
Output (output/deployment.yaml):
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 3
template:
spec:
containers:
- name: myapp
image: nginx:latest
ports:
- containerPort: 8080
9. Kubricate Integration
8.1 Goal
Synthing must support the current version of kubricate (ref/kubricate/) with zero changes to kubricate's library code. Users who choose to integrate synthing with kubricate handle type adjustments in their own template definitions.
8.2 Integration Workflows
Both integration approaches are supported. Users choose based on their needs.
Workflow A: In-Process
Single synthing.config.ts, single CLI command. Kubricate's Stack.build() runs inside synthing's generator pipeline. Best for users who only need variable resolution.
synthing generate → calls stack.build() in-process → engine resolves $${{tags}} → writes YAML
Workflow B: Two CLIs
Separate CLIs, text files as boundary. Best for users who want full kubricate features (metadata injection, output modes, filtering).
kubricate generate → YAML with $${{tags}} → synthing generate → final YAML
Trade-offs:
|
Workflow A (In-Process) |
Workflow B (Two CLIs) |
| CLI commands |
synthing generate |
kubricate generate + synthing generate |
| Kubricate metadata injection |
No (skipped) |
Yes |
| Kubricate output modes |
No (synthing handles output) |
Yes |
| Intermediate files |
No |
Yes |
| Variable resolution |
Yes |
Yes |
| Spread support |
Yes |
Yes |
Remark: Investigation of kubernetes-models internals (filterUndefinedValues in @kubernetes-models/base) shows that tag strings and custom keys like __synthing_spread are preserved through the full pipeline (new Deployment(config) → .toJSON() → structuredClone()). Both workflows work.
Future: Two kubricate PRs would enable Workflow A with full kubricate features:
- PR 1: Programmatic generate API — see
_report/kubricate-pr-proposal-1-programmatic-api.md
- PR 2:
@kubricate/plugin-synthing package — see _report/kubricate-pr-proposal-2-synthing-generator.md
8.3 How $var() Works in Kubricate Context
$var() returns a VariableRef<T> (see section 2.1) — a branded string. At runtime it is "$${{key}}", which is a plain string. It passes any string type check and serializes naturally:
$var("app_name") // compile time: VariableRef<string>, runtime: "$${{app_name}}"
$var("port") // compile time: VariableRef<number>, runtime: "$${{port}}"
// In kubricate template (expects string):
{ name: $var("app_name") } // just a string — no conversion needed
Users write their own kubricate templates with string types for synthing-managed fields:
// User's own template — fields that use $var are typed as string
interface IMyAppStack {
name: string;
imageName: string;
port: string; // string, not number — user's choice for synthing integration
}
8.4 Workflow A: In-Process Example
One config file, one CLI command. Kubricate Stack runs inside synthing's generator pipeline.
// synthing.config.ts
import { z } from "zod";
import { VariableManager, YamlGenerator, defineConfig } from "synthing";
import { EnvConnector } from "@synthing/plugin-env";
import { Stack, defineStackTemplate } from "kubricate";
// --- Variables ---
const vm = new VariableManager()
.addVariable("app_name", { type: "string" })
.addVariable("port", { type: "number", default: 3000 })
.addVariable("extra_env", {
type: "object",
schema: z.array(z.object({ name: z.string(), value: z.string() })),
})
.addVariable("extra_labels", {
type: "object",
schema: z.record(z.string()),
})
.addConnector("env", new EnvConnector({ prefix: "APP_" }));
const { $var, $spread } = vm.createRef();
// --- Kubricate Stack ---
const myTemplate = defineStackTemplate((input: {
name: string;
imageName: string;
port: string;
env: ({ name: string; value: string } & Record<string, string>)[];
labels: Record<string, string>;
}) => ({
deployment: {
apiVersion: "apps/v1",
kind: "Deployment",
metadata: { name: input.name, labels: input.labels },
spec: {
containers: [{
name: input.name,
image: input.imageName,
ports: [{ containerPort: input.port }],
env: input.env,
}],
},
},
}));
const stack = Stack.fromTemplate(myTemplate, {
name: $var("app_name"),
imageName: "nginx:latest",
port: $var("port"),
env: [
{ name: "NODE_ENV", value: "production" },
{ [$spread("extra_env").key]: $spread("extra_env").value, name: "", value: "" },
],
labels: {
app: $var("app_name"),
[$spread("extra_labels").key]: $spread("extra_labels").value,
},
});
// --- Wrap Stack in YamlGenerator ---
// stack.build() returns Record<string, unknown> (map of resourceId → resource)
// Object.values() extracts resources as array → multi-document YAML
const kubricateGen = new YamlGenerator({
filename: "app.yaml",
create: () => Object.values(stack.build()),
});
// --- Export ---
export default defineConfig({
variable: { variableSpec: vm, strictMode: true },
pipelines: [
{ type: "generator", generators: [kubricateGen], writer: { type: "file", dir: "output/" } },
],
});
APP_APP_NAME=myapp APP_PORT=8080 \
APP_EXTRA_ENV='[{"name":"DB_HOST","value":"db.prod.example.com"}]' \
APP_EXTRA_LABELS='{"version":"1.0.0","team":"backend"}' \
synthing generate
Flow: stack.build() returns objects with $${{tags}} and __synthing_spread markers → generator engine deep-walks, resolves tags, expands spreads → YamlGenerator.serialize() → writes final YAML.
Output:
# output/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
labels:
app: myapp
version: 1.0.0
team: backend
spec:
containers:
- name: myapp
image: nginx:latest
ports:
- containerPort: 8080
env:
- name: NODE_ENV
value: production
- name: DB_HOST
value: db.prod.example.com
8.5 Workflow B: Two CLIs Example
For users who want full kubricate features (metadata injection, output modes, filtering), use two separate CLIs with text pipeline.
synthing.config.ts:
export default defineConfig({
variable: { variableSpec: vm, strictMode: true },
pipelines: [
{ type: "text", input: "./kubricate-output/", format: "yaml", writer: { type: "file", dir: "final-output/" } },
],
});
kubricate.config.ts:
import { $var, $spread } from "./variables";
// Same stack definition as Workflow A...
export default defineConfig({
stacks: { app: stack },
generate: { outputDir: "./kubricate-output" },
});
kubricate generate # outputs YAML with $${{tags}}
synthing generate # reads, resolves, writes final
8.6 What Changes for Whom
|
Changes? |
What |
| Kubricate library code |
No |
Nothing |
| Kubricate built-in templates |
No |
Nothing |
| User's template types |
Yes |
Fields using $var become string |
| User's kubricate config |
Yes |
Uses $var() which returns "$${{key}}" strings |
10. Supported Variable Types
Phase 1 types:
| Type |
TS Type |
Coercion from string |
"string" |
string |
identity |
"number" |
number |
Number(), error if NaN |
"boolean" |
boolean |
"true"/"1" -> true, "false"/"0" -> false, error otherwise |
"object" |
object | unknown[] |
JSON.parse(), error if result is primitive (not object/array) |
Note: "number" uses Number() (strict) instead of parseFloat() (lenient). Number("123abc") → NaN → error, while parseFloat("123abc") → 123 (silently accepts trailing garbage).
11. Phase 1 Deliverables
VariableManager with builder pattern (.addVariable(), .addConnector(), .createRef())
$var() branded string deferred references (VariableRef<T>)
$spread() helper for array/object spread in structural mode
- Variable types:
string, number, boolean, object
- Optional
schema field on all types (Standard Schema interface for runtime validation)
BaseConnector (two-phase: load() + get())
EnvConnector
BaseGenerator with render() + serialize()
YamlGenerator (built-in)
GeneratorContext with outputDir, logger
defineConfig() with variable + pipelines domains
- Pipeline type:
"generator" and "text"
"text" pipeline with two modes:
- Plain text: string replacement for all file types
- Structural: parse/replace/re-serialize for YAML and JSON (typed values, spread support)
$${{key}} fixed tag format, $${{...key}} spread tag, __synthing_spread marker
- Writer type:
"file" only
synthing generate CLI command
synthing variable export-schema CLI command
toJSON() on VariableManager for UI export (with Standard JSON Schema when available)
- Strict/loose mode (missing-value behavior only)
- Type coercion utilities (
coerceFromString) in @synthing/toolkit
- Secret marker (
secret: true -> redacted in logs/errors/toJSON)
12. Acceptance Criteria
All must pass for milestone-2 to be considered complete.
Build & Quality
pnpm build — all packages produce dist/
pnpm check-types — zero TypeScript errors
pnpm lint:check — zero lint errors
pnpm test — zero failures
Packages Exist and Export
@synthing/core — exports BaseConnector, BaseGenerator
@synthing/toolkit — exports coerceFromString()
synthing — exports VariableManager, YamlGenerator, defineConfig, $spread helper
@synthing/plugin-env — exports EnvConnector
Features Work End-to-End
- Generator pipeline:
YamlGenerator with $var() → synthing generate → resolved YAML output
- Text pipeline (plain): read file with
$${{tags}} → resolve → write
- Text pipeline (structural YAML): read YAML with
$${{tags}} → typed resolution → write
- Text pipeline (structural JSON): same for JSON
$spread() works — array and object spread in structural mode
synthing export-schema — outputs variable metadata JSON
- Variable types:
string, number, boolean, object all coerce correctly
- Strict mode: missing value with no default → throws
ResolutionError
- Secret marker:
secret: true → redacted in logs and toJSON()
Kubricate Integration Proven
- Workflow A (in-process):
Stack.build() inside YamlGenerator → tags resolved → correct YAML output
- Workflow B (two CLIs):
kubricate generate → files with tags → synthing generate → resolved output
13. Explicitly NOT in Phase 1
@synthing/secrets / SecretManager
KubricateGenerator — not needed in any phase. YamlGenerator wrapping stack.build() covers this use case
"api" / "stdout" writers
- Secret provider / unwrap ceremony
- Multi-registry
- Configurable tag format (fixed
$${{key}} in Phase 1)
- Nested/dynamic tag resolution (tag-inside-tag)
- Full template engine features (conditionals, loops, filters, partials)
- Non-TypeScript config support (JSON/YAML config files, schema file for non-TS users)
- Auto-detect format from file extension (format is always explicit)
14. Design Decision Index
For traceability, each major decision is numbered. These numbers correspond to the grilling session that produced this spec.
- Deferred reference (
$var returns branded string VariableRef<T>, runtime value is "$${{key}}" tag)
- Always-async resolution
- Typed refs from registry via
.createRef() with generic accumulation
- Schema validation enforced at resolve time when
schema is provided (Standard Schema interface)
- Type guarantee via shared coercion
- Strict/loose scoped to missing-value behavior only
- Explicit priority list, first match wins
- Shared coercion utilities in toolkit (moved from core)
- Typed
ResolutionError exceptions
- Both-layer defaults (call-site and schema)
- Ship both
resolve() and resolveAll()
- User declares keys in schema; connectors are pure readers
- Both
toJSON() and CLI export
- Flat string keys with dot-cosmetic grouping
- Single
$var() function with options object
- Immutable refs (each call returns new tag string)
- Secret marker via
{ secret: true }
$ prefix convention for ref variable
BaseConnector naming (not BaseVariableResolver)
- Domain-agnostic connectors
- Two-phase connector (
load then get)
@synthing/plugin-env package naming
- No class prefix (
EnvConnector, not VariableEnvConnector)
- Single
VariableManager class (no separate registry)
- Builder pattern with generic accumulation
toJSON() serializes variable metadata only
BaseGenerator with render() + serialize()
- Generator does NOT resolve — engine resolves after serialize() via structural resolver
GeneratorContext simplified to outputDir + logger only
GeneratorOutput.content is unknown (objects, not strings)
- Constructor with
create field
- Format is generator-level, not pipeline-level
defineConfig() follows kubricate's pattern
pipelines array with discriminated union on type
- Both
"generator" and "text" pipeline types in Phase 1
generators field name (not configs)
- Per-pipeline writer with discriminated union
- Resolution is internal (user never calls it directly)
- CLI package is unscoped (
synthing, not @synthing/cli) — follows kubricate pattern
@synthing/variables merged into synthing package as engine/ directory
- Core is interfaces + types only — minimal surface area to prevent breaking changes
@synthing/toolkit for pure utilities (coercion) — standalone, zero deps
- Internal
cli/ + engine/ separation inside synthing for future extractability
engine/ never imports from cli/ (one-way dependency)
- Plugins peer-depend on
@synthing/core only
- Users install plugins themselves (CLI does not bundle plugins)
- GeneratorContext lives in engine layer, not core
- YamlGenerator ships in engine layer; future format generators can be separate plugins
"text" pipeline pulled into Phase 1 for kubricate integration
VariableRef<T> is a branded string — runtime value is "$${{key}}", compile-time tracks target type
- Kubricate integration via text pipeline — zero kubricate library changes
- Tag format
$${{variable_key}} is the contract between kubricate and synthing
- Users who integrate synthing with kubricate change their own template types to
string
- No
KubricateGenerator needed — text pipeline replaces it
- Generator and text pipelines share the same resolution mechanism (tag pattern matching)
- Runtime type validation via schema-based coercion at resolution time (e.g.
Number("123abc") → NaN → error)
object variable type — accepts objects and arrays, coerced from JSON string
$${{key}} fixed tag format — not configurable in Phase 1
- No nested tags, no full template engine — generator pipeline handles complex logic
- Structural mode for YAML/JSON — parse, typed replacement, re-serialize
- Plain text mode for all other formats — simple string replacement
$spread() helper returns { key: "__synthing_spread", value: "$${{...key}}" }
__synthing_spread marker key for spread in structural mode
- Array spread: marker object replaced with expanded items
- Object spread: marker key removed, resolved keys merged into parent
- Standard Schema (
~standard) for optional runtime validation on all variable types
- Standard JSON Schema (
~standard.jsonSchema) for toJSON() export when available
Number() (strict) over parseFloat() (lenient) for number coercion
createRef() returns { $var, $spread } — both bound to the VariableManager instance
$spread only accepts keys with type: "object" (compile-time enforced)
- Kubricate in-process (Workflow A) and two-CLI (Workflow B) are both supported — user's choice
- Workflow A: single CLI, variable resolution only. Workflow B: full kubricate features (metadata, output modes)
- kubernetes-models preserves tag strings and
__synthing_spread through filterUndefinedValues + toJSON()
- Both pipelines use
variableSpec in-process — no schema file needed in Phase 1
- Non-TypeScript config support (schema file, JSON/YAML config) is future, not Phase 1
synthing export-schema exports JSON for UI consumers, separate from synthing generate
- Resolution pipeline order: raw string → coerce by type → validate by schema (if provided)
- Generator filename provided in constructor (not in defineConfig)
- YamlGenerator supports single object or array of objects (multi-document YAML with
---)
- Generator pipeline: create() → serialize() → structural resolve → write (no deep-walk, same resolver as text pipeline)
- GeneratorContext simplified: only
outputDir + logger (no resolve(), no strictMode)
- Call-site defaults:
$var("key", { default }) registers default in VariableManager as side effect
- Duplicate call-site defaults for same key → throw error (fail fast)
stack.build() returns map — user calls Object.values() to get array for YamlGenerator
BaseGenerator.format property declares output format ("yaml", "json", "text")
- Text pipeline
format field is explicit — no auto-detection from file extension
- Text pipeline
format accepts string shorthand or custom TextFormat handler (parse + stringify)
- Custom
TextFormat handlers get full structural resolution (typed values + spread)
"text" format is plain string replacement only (no types, no spread)
- Format handling is consistent: generator declares via class property, text pipeline declares via config
BaseGenerator.format is string (not literal union) — extensible, engine does map lookup
- CLI uses
yargs (same as kubricate)
- Config loading uses
unconfig (same as kubricate)
Design Spec: Synthing Phase 1
1. Overview
Synthing is a full render pipeline that consumes variables (and later secrets) from multiple sources, then generates output through pluggable targets. It is tool-agnostic and not tied to any specific adapter, runtime, or deployment platform.
Synthing extends patterns from kubricate. Kubricate v2 will eventually merge into Synthing.
Phase 1 Packages
@synthing/coreBaseConnector,BaseGenerator, error types, shared type definitions@synthing/toolkitsynthing(unscoped)cli/) + engine (engine/):VariableManager,$var, resolution,GeneratorContext, pipeline runner,YamlGenerator,defineConfig(), CLI commands, file writer@synthing/plugin-envEnvConnector(domain-agnostic, serves both variables and secrets). Peer dep on@synthing/core.Package Architecture
Internal directory structure of
synthingpackage:Rules:
engine/never imports fromcli/(enables future extraction to@synthing/engine)cli/imports fromengine/engine/has no filesystem I/O, no arg parsing, noprocess.argv@synthing/corehas minimal surface area to prevent breaking changes across the ecosystem@synthing/coreonly (not onsynthing)synthingdoes not bundle plugins)2. Variable System
2.1 Deferred References (Branded String)
$var("key")returns aVariableRef<T>— a branded string type. At runtime it is a plain string containing a tag ("$${{key}}"). At compile time TypeScript tracks the target type via the brand.Why branded string:
string— works in any context expecting strings (kubricate templates, YAML serialization, JSON.stringify)VariableRef<number>vsVariableRef<string>prevents mixing up variable types within synthing's own APIsinstanceof, notoJSON(), no prototype chain$${{...}}tag patternsReferences are immutable. Each call to
$var("key", { default: value })returns a newVariableRef. Multiple call sites can have different defaults for the same key.Recommended convention:
const { $var, $spread } = variableManager.createRef(). The$prefix is a visual marker meaning "this is a reference." Users can rename via destructuring (e.g.,const { $var: v, $spread: s } = ...).2.2
$var()API ShapeSingle function signature. Type is inferred from the registry, not from method factories.
There are no
.number()/.string()factories and no.withDefault()method chaining.2.3 Variable Keys
^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$.addVariable()time (fail fast)2.4 Resolution
Resolution is always async, even for sync sources like environment variables. This avoids breaking the API when async sources are added later.
The engine scans for
$${{key}}tag patterns in strings, resolves each key via connectors, and coerces to the target type defined in the schema.2.5 Resolution Precedence
Explicit priority list, first match wins. Connectors are tried in order of registration.
For a given key, the value precedence is:
$var("key", { default: value })).addVariable("key", { default: value }))ResolutionError. In loose mode: returnundefined.2.6 Type Coercion
Shared coercion utilities (from
@synthing/toolkit) — toolkit exportscoerceFromString()helpers. Connectors and the engine may use them. The engine performs the final type-check to ensure basic type matching (a variable declared asnumberreturns a number or errors).For
objecttype:JSON.parse()the string, reject if result is a primitive (string, number, boolean). Only objects and arrays are accepted.2.7 Schema Validation
Variables can optionally provide a
schemafor runtime validation using the Standard Schema interface. When provided, validation is enforced at resolve time.schemais optional on all variable types~standard)type→ validate byschema(if provided)typehandles coercion (how to parse),schemahandles validation (is the value correct)typeandschemacontradict (e.g.,type: "object"+z.string()), coercion succeeds but schema validation fails with a clear error~standard.jsonSchema),toJSON()exports the JSON Schema for UI consumerstoJSON()falls back to basic type metadata2.8 Strict vs Loose Mode
Scoped narrowly to missing-value behavior only:
ResolutionErrorundefinedStrict mode is the recommended default for production use.
2.9 Error Handling
resolve()throws typedResolutionError. For batchresolveAll(), multiple failures can be attached to one error object.2.10 Secret Marker
Phase 1 uses a simple marker, not a full secret system.
Variables marked
secret: trueare:toJSON()outputThere is no
SecretProvider, no unwrap ceremony, noSecretManagerin Phase 1.3. VariableManager
A single class that declares variables AND composes connectors. Follows kubricate's
SecretManagerpattern. There is no separateVariableRegistry.3.1 Builder Pattern
Uses generic accumulation for type safety.
<K extends string>infers literal types — noas constneeded..addVariable(key, schema)— registers a variable with its type and metadata. Key is validated against the key regex immediately..addConnector(name, connector)— registers a connector. Order of registration determines priority..createRef()— returns{ $var, $spread }bound to this manager's variable declarations.$spreadonly accepts keys declared withtype: "object".3.2 Schema Export
variableManager.toJSON()— serializes variable metadata (keys, types, defaults, descriptions, secret flag). Does NOT include connector configuration.schemathat implements Standard JSON Schema (~standard.jsonSchema), the JSON Schema is included in the export for UI consumers.synthing variable export-schema— outputs the same JSON to stdout.3.3 Keys Declared in Schema, Connectors are Pure Value Readers
Users declare keys via
.addVariable(). Connectors do not contribute or discover keys — they only read values for keys they are asked about.4. Connectors
4.1 BaseConnector (in
@synthing/core)BaseConnectoris domain-agnostic. The same connector class serves both variables and secrets. It lives in@synthing/coreas a pure abstract interface.Naming:
BaseConnector, notBaseVariableConnectororBaseVariableResolver. Aligns with kubricate'sBaseConnectorterm.4.2 Two-Phase Interface
Matches kubricate's
BaseConnectorinterface:load(keys[])— called once with all keys this connector might need. Allows batch fetching.get(key)— called per-key after load. Returns the raw value orundefined.4.3 EnvConnector (in
@synthing/plugin-env)Package:
@synthing/plugin-env(notvariables-resolver-env). Shared plugin that serves both variable and secret domains.Class name:
EnvConnector(notVariableEnvConnector). Package scope provides context.Users can separate variable vs secret env vars via prefix convention (e.g.,
VAR_,SECRET_).5. Generator System
5.1 BaseGenerator (in
@synthing/core)Abstract class with a
formatproperty and two methods. Lives in@synthing/coreas a pure abstract interface.render()returns objects (GeneratorOutput.contentisunknown), NOT serialized strings.serialize()turns objects intoSerializedOutput[](filename + string content).5.2 GeneratorContext (in
synthingengine)Provided to
render(). Lives in the engine layer (insidesynthingpackage), not in@synthing/core.Generators do not resolve variables — they return objects with
$${{tag}}strings. The engine resolves all tags afterserialize()using the structural resolver (same resolver as text pipeline).5.3 Constructor Shape
Options object with
filenameandcreatefield.$varis fromcreateRef(), not passed intocreate:create()can return a single object or an array of objects. Array produces multi-document YAML (separated by---):Output:
5.4 Format is Generator-Level
Each generator handles its own serialization. The pipeline never knows about format.
YamlGeneratoralways outputs YAML. A futureJsonGeneratorwould output JSON.5.5 YamlGenerator (Built-in)
Ships inside the
synthingpackage (engine layer). Renders objects to YAML files using itsserialize()method.---separators (matches kubricate's pattern)Future format-specific generators (TOML, JSON5, HCL) could be separate plugin packages.
6. Pipeline and Config
6.1
defineConfig()Follows kubricate's pattern. Top-level keys are domain concerns.
6.2 Pipelines
pipelinesis an array. Each pipeline is a complete flow: input -> resolve -> render -> serialize -> write.Pipelines use a discriminated union on
type:"generator"— Phase 1. Uses generators to produce output."text"— Phase 1. Reads files with$${{var.xxx}}tags, replaces with resolved values. Language-agnostic.6.3 Generator Pipeline
6.4 Text Pipeline
Reads files from
input, resolves$${{var_key}}tags viaVariableManager, and writes output via the writer. Theformatfield determines how resolution works.Tag Format
Fixed format:
$${{variable_key}}— not configurable in Phase 1. The$$prefix avoids shell substitution conflicts. Matches the key registered via.addVariable().Format Handling
The
formatfield is explicit — no auto-detection from file extension.Built-in formats:
"yaml""json""text"Custom format handler:
Custom handlers get structural resolution (typed values + spread) — same engine as
"yaml"and"json".Plain text mode (
"text"):$${{key}}in file contentStructural mode (
"yaml","json", or custom handler):format.parse()(or built-in parser)$${{key}}string valuesformat.stringify()(or built-in serializer)__synthing_spreadmarker (see section 6.4.1)Format consistency with generator pipeline
Both pipelines use the same structural resolver. The format is always explicit:
BaseGenerator.formatproperty)formatfield)6.4.1 Spread Syntax (
$spread)For merging arrays/objects from variables into existing structures. Only works in structural mode.
Helper function (from
createRef()):Array spread — insert items into parent array:
Serializes to YAML:
Pipeline detects
__synthing_spreadkey → resolves$${{...extra_env}}→ replaces entire object with expanded array items:Object spread — merge keys into parent object:
Serializes to YAML:
Pipeline detects
__synthing_spreadkey → resolves → merges keys into parent, removes marker:Pipeline detection rules:
__synthing_spreadkey → resolve value, splice expanded items in place of the marker object__synthing_spread→ resolve value, merge into parent, remove marker key__synthing_spreadin plain text mode → errorFlow
formatsetting:"text"): scan for$${{key}}patterns, replace with string values"yaml","json", or custom handler): parse file, deep-walk tree:a. Find
__synthing_spreadmarkers → resolve and expandb. Find
$${{key}}string values → resolve with typed replacementc. Re-serialize to file format
6.5 Writer
Per-pipeline writer. Discriminated union on
type.Phase 1:
{ type: "file", dir: "output" }only.Future:
{ type: "api", ... },{ type: "stdout" }, etc.6.6 Resolution is Internal
Users never call
resolveAll()directly. The CLI readsdefineConfig(), runs pipelines, and resolution happens internally duringrender()(generator) or tag replacement (text).7. CLI
7.1
synthing generateMain command. Reads
defineConfig(), executes all pipelines, writes output.Generator pipeline flow:
a. Call
load(keys)on all connectorsb. For each generator, call
render(ctx)— generator callsctx.resolve()as neededc. Call
serialize(content)on each generator's outputd. Write serialized files via the pipeline's writer
Text pipeline flow:
a. Call
load(keys)on all connectorsb. Glob input files from
inputpathc. Use pipeline's
formatsetting (explicit, not auto-detected)d. Resolve tags —
"text": string replacement; structural ("yaml","json", custom): parse, walk, replace typed values, handle__synthing_spread, re-serializee. Write output files via the pipeline's writer
7.2
synthing variable export-schemaOutputs the variable schema as JSON. Equivalent to calling
variableManager.toJSON()and writing to stdout.8. Simple YamlGenerator Example
A complete standalone example using only synthing — no kubricate.
Output (
output/deployment.yaml):9. Kubricate Integration
8.1 Goal
Synthing must support the current version of kubricate (
ref/kubricate/) with zero changes to kubricate's library code. Users who choose to integrate synthing with kubricate handle type adjustments in their own template definitions.8.2 Integration Workflows
Both integration approaches are supported. Users choose based on their needs.
Workflow A: In-Process
Single
synthing.config.ts, single CLI command. Kubricate'sStack.build()runs inside synthing's generator pipeline. Best for users who only need variable resolution.Workflow B: Two CLIs
Separate CLIs, text files as boundary. Best for users who want full kubricate features (metadata injection, output modes, filtering).
Trade-offs:
synthing generatekubricate generate+synthing generate8.3 How
$var()Works in Kubricate Context$var()returns aVariableRef<T>(see section 2.1) — a branded string. At runtime it is"$${{key}}", which is a plain string. It passes anystringtype check and serializes naturally:Users write their own kubricate templates with
stringtypes for synthing-managed fields:8.4 Workflow A: In-Process Example
One config file, one CLI command. Kubricate Stack runs inside synthing's generator pipeline.
Flow:
stack.build()returns objects with$${{tags}}and__synthing_spreadmarkers → generator engine deep-walks, resolves tags, expands spreads →YamlGenerator.serialize()→ writes final YAML.Output:
8.5 Workflow B: Two CLIs Example
For users who want full kubricate features (metadata injection, output modes, filtering), use two separate CLIs with text pipeline.
synthing.config.ts:
kubricate.config.ts:
8.6 What Changes for Whom
$varbecomestring$var()which returns"$${{key}}"strings10. Supported Variable Types
Phase 1 types:
"string"string"number"numberNumber(), error ifNaN"boolean"boolean"true"/"1"->true,"false"/"0"->false, error otherwise"object"object | unknown[]JSON.parse(), error if result is primitive (not object/array)Note:
"number"usesNumber()(strict) instead ofparseFloat()(lenient).Number("123abc")→NaN→ error, whileparseFloat("123abc")→123(silently accepts trailing garbage).11. Phase 1 Deliverables
VariableManagerwith builder pattern (.addVariable(),.addConnector(),.createRef())$var()branded string deferred references (VariableRef<T>)$spread()helper for array/object spread in structural modestring,number,boolean,objectschemafield on all types (Standard Schema interface for runtime validation)BaseConnector(two-phase:load()+get())EnvConnectorBaseGeneratorwithrender()+serialize()YamlGenerator(built-in)GeneratorContextwithoutputDir,loggerdefineConfig()withvariable+pipelinesdomains"generator"and"text""text"pipeline with two modes:$${{key}}fixed tag format,$${{...key}}spread tag,__synthing_spreadmarker"file"onlysynthing generateCLI commandsynthing variable export-schemaCLI commandtoJSON()onVariableManagerfor UI export (with Standard JSON Schema when available)coerceFromString) in@synthing/toolkitsecret: true-> redacted in logs/errors/toJSON)12. Acceptance Criteria
All must pass for milestone-2 to be considered complete.
Build & Quality
pnpm build— all packages producedist/pnpm check-types— zero TypeScript errorspnpm lint:check— zero lint errorspnpm test— zero failuresPackages Exist and Export
@synthing/core— exportsBaseConnector,BaseGenerator@synthing/toolkit— exportscoerceFromString()synthing— exportsVariableManager,YamlGenerator,defineConfig,$spreadhelper@synthing/plugin-env— exportsEnvConnectorFeatures Work End-to-End
YamlGeneratorwith$var()→synthing generate→ resolved YAML output$${{tags}}→ resolve → write$${{tags}}→ typed resolution → write$spread()works — array and object spread in structural modesynthing export-schema— outputs variable metadata JSONstring,number,boolean,objectall coerce correctlyResolutionErrorsecret: true→ redacted in logs andtoJSON()Kubricate Integration Proven
Stack.build()insideYamlGenerator→ tags resolved → correct YAML outputkubricate generate→ files with tags →synthing generate→ resolved output13. Explicitly NOT in Phase 1
@synthing/secrets/SecretManager— not needed in any phase.KubricateGeneratorYamlGeneratorwrappingstack.build()covers this use case"api"/"stdout"writers$${{key}}in Phase 1)14. Design Decision Index
For traceability, each major decision is numbered. These numbers correspond to the grilling session that produced this spec.
$varreturns branded stringVariableRef<T>, runtime value is"$${{key}}"tag).createRef()with generic accumulationschemais provided (Standard Schema interface)ResolutionErrorexceptionsresolve()andresolveAll()toJSON()and CLI export$var()function with options object{ secret: true }$prefix convention for ref variableBaseConnectornaming (notBaseVariableResolver)loadthenget)@synthing/plugin-envpackage namingEnvConnector, notVariableEnvConnector)VariableManagerclass (no separate registry)toJSON()serializes variable metadata onlyBaseGeneratorwithrender()+serialize()GeneratorContextsimplified tooutputDir+loggeronlyGeneratorOutput.contentisunknown(objects, not strings)createfielddefineConfig()follows kubricate's patternpipelinesarray with discriminated union ontype"generator"and"text"pipeline types in Phase 1generatorsfield name (notconfigs)synthing, not@synthing/cli) — follows kubricate pattern@synthing/variablesmerged intosynthingpackage asengine/directory@synthing/toolkitfor pure utilities (coercion) — standalone, zero depscli/+engine/separation insidesynthingfor future extractabilityengine/never imports fromcli/(one-way dependency)@synthing/coreonly"text"pipeline pulled into Phase 1 for kubricate integrationVariableRef<T>is a branded string — runtime value is"$${{key}}", compile-time tracks target type$${{variable_key}}is the contract between kubricate and synthingstringKubricateGeneratorneeded — text pipeline replaces itNumber("123abc")→ NaN → error)objectvariable type — accepts objects and arrays, coerced from JSON string$${{key}}fixed tag format — not configurable in Phase 1$spread()helper returns{ key: "__synthing_spread", value: "$${{...key}}" }__synthing_spreadmarker key for spread in structural mode~standard) for optional runtime validation on all variable types~standard.jsonSchema) fortoJSON()export when availableNumber()(strict) overparseFloat()(lenient) for number coercioncreateRef()returns{ $var, $spread }— both bound to the VariableManager instance$spreadonly accepts keys withtype: "object"(compile-time enforced)__synthing_spreadthroughfilterUndefinedValues+toJSON()variableSpecin-process — no schema file needed in Phase 1synthing export-schemaexports JSON for UI consumers, separate fromsynthing generate---)outputDir+logger(noresolve(), nostrictMode)$var("key", { default })registers default in VariableManager as side effectstack.build()returns map — user callsObject.values()to get array for YamlGeneratorBaseGenerator.formatproperty declares output format ("yaml","json","text")formatfield is explicit — no auto-detection from file extensionformataccepts string shorthand or customTextFormathandler (parse+stringify)TextFormathandlers get full structural resolution (typed values + spread)"text"format is plain string replacement only (no types, no spread)BaseGenerator.formatisstring(not literal union) — extensible, engine does map lookupyargs(same as kubricate)unconfig(same as kubricate)