Skip to content

Specification: Templating Semantics

Spec ID: tower-fleet/template/v1 Status: Normative Version: 1.0.0 Created: 2025-12-18

Abstract

This specification defines the templating semantics for variable interpolation in tower-fleet intent definitions. It establishes deterministic resolution rules, fail-fast behavior, and safe-by-default escaping.

Conformance

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.


1. Goals

  1. Deterministic interpolation - Same inputs produce identical outputs
  2. Fail-fast on ambiguity - Missing variables are hard errors, not silent defaults
  3. No implicit execution - Templates are pure substitution, no code evaluation
  4. Safe by default - Shell contexts use escaped interpolation

2. Variable Scopes

Templates MAY reference variables from exactly four scopes:

Scope Prefix Description Available When
Parameters params.<name> Resolved, typed intent parameters After param resolution
Context context.<name> Captured execution context After context capture
Step Outputs steps.<step_id>.outputs.<name> Outputs from completed runbook steps After step completes
Facts facts.<name> Runtime state and environment facts During/after execution

Facts Scope

The facts.* scope contains runtime information populated by the executor:

Fact Description Available When
facts.failed_step Name of step that failed On failure
facts.rollback_status Status of rollback operation After rollback
facts.current_step Currently executing step name During execution
facts.execution_id Unique execution identifier Always

Custom facts MAY be added via environment fact collection if configured.

Templates MUST NOT reference: - Environment variables directly ($ENV_VAR or ${ENV.VAR}) - Arbitrary expressions or computed values - Variables from incomplete or future steps


3. Syntax

3.1 Delimiters

Template expressions MUST use the delimiter syntax: ${...}

Valid:   ${params.app}
Valid:   ${context.git_sha}
Invalid: $params.app
Invalid: {{params.app}}
Invalid: $(params.app)

3.2 Identifier Rules

Inside ${...}, only dotted identifiers are permitted:

template_expr = "${" identifier ("." identifier)* "}"
identifier    = [a-z_][a-z0-9_]*

Examples:

Valid:   ${params.app}
Valid:   ${params.app_name}
Valid:   ${context.git_sha}
Valid:   ${steps.build_image.outputs.image_tag}
Invalid: ${params.app-name}      # hyphen not allowed in identifier
Invalid: ${params['app']}        # bracket notation not allowed
Invalid: ${params.app.toUpper()} # function calls not allowed

3.3 No Functions

In v1, template expressions MUST NOT contain: - Function calls (${params.app | lower}, ${lower(params.app)}) - Conditionals (${params.app ? 'yes' : 'no'}) - Arithmetic (${params.count + 1}) - String concatenation operators (${params.a + params.b})

Concatenation is achieved by adjacent interpolation:

Valid:   registry.internal/${params.app}:${context.git_sha}
Invalid: ${params.registry + '/' + params.app}

3.4 No Nesting

Template expressions MUST NOT be nested:

Invalid: ${params.${params.key}}
Invalid: ${params.prefix_${params.suffix}}

4. Resolution Rules

4.1 Resolution Order

Template resolution is: 1. Single-pass - Each ${...} is resolved exactly once 2. Left-to-right - Expressions are resolved in order of appearance 3. Eager - All expressions in a field are resolved before use

4.2 Missing Variable Handling

If a referenced variable does not exist in its scope:

  1. Resolution MUST fail with error type template_missing_variable
  2. The error MUST include:
  3. The full template expression (${params.missing_var})
  4. The scope that was searched (params)
  5. Available variables in that scope (for debugging)
  6. Execution MUST NOT proceed
{
  "error": "template_missing_variable",
  "expression": "${params.environment}",
  "scope": "params",
  "available": ["app", "skip_build_check"],
  "message": "Variable 'environment' not found in params scope"
}

4.3 Null and Empty Values

  • null values MUST be treated as missing (trigger template_missing_variable)
  • Empty string "" is a valid value and MUST interpolate as empty string
  • Boolean false MUST interpolate as the string "false"
  • Numeric 0 MUST interpolate as the string "0"

4.4 No Implicit Defaults

Default values MUST be applied during parameter resolution, BEFORE template rendering. Templates MUST NOT support default syntax like ${params.app:-default}.


5. Render Modes

Every field containing templates MUST have an associated render mode, either explicit or inherited from field type.

5.1 Literal Mode

Identifier: render_mode: literal

Substitution is performed without escaping. The interpolated value is inserted as-is.

Use for: - Log messages - URLs - Resource identifiers - Display strings

Example:

# render_mode: literal (implicit for outputs)
outputs:
  success:
    - "Deployed ${params.app} to ${params.environment}"

Result: Deployed money-tracker to production

5.2 Shell Mode

Identifier: render_mode: shell

Substitution is shell-escaped to ensure the value is treated as a single token, preventing injection.

Use for: - exec.command fields - check.command fields - rollback.command fields

Escaping strategy:

Values MUST be escaped using single-quote wrapping with embedded single-quote handling:

value → 'value'
value with 'quote → 'value with '\''quote'

Implementation (bash):

escape_shell() {
  printf '%s' "$1" | sed "s/'/'\\\\''/g; s/^/'/; s/$/'/"
}

Example:

exec:
  command: docker build -t registry.internal/${params.app}:${context.git_sha} .

If params.app = "my-app" and context.git_sha = "abc123":

docker build -t registry.internal/'my-app':'abc123' .

5.3 Field Mode Defaults

Field Path Default Mode
exec.command shell
check.command shell
rollback.command shell
check.path literal
check.url literal
controls.lock literal
resources[*].name literal
resources[*].namespace literal
resources[*].path literal
outputs.* literal

6. Allowed Template Fields

To maintain parsing determinism, only these fields MAY contain template expressions in v1:

6.1 Allowed

spec:
  controls:
    lock: "${params.app}-${params.environment}"  # allowed

  resources:
    - name: "${params.app}"                      # allowed
      namespace: "${params.app}"                 # allowed
      path: "registry.internal/${params.app}"    # allowed

  prerequisites:
    - check:
        path: "/root/projects/${params.app}/Dockerfile"  # allowed
        command: "npm run build"                          # allowed (with cwd)

  runbook:
    - exec:
        command: "docker build -t ${steps.build.outputs.tag} ."  # allowed
      rollback:
        command: "docker rmi ${steps.build.outputs.tag}"         # allowed

  verify:
    - check:
        url: "https://${params.app}.bogocat.com/health"  # allowed

  outputs:
    success:
      - "Deployed ${params.app}"                # allowed

6.2 Not Allowed

metadata:
  name: "${params.app}-deploy"     # NOT allowed - must be literal
  version: "${context.version}"    # NOT allowed - must be literal

spec:
  mode: "${params.mode}"           # NOT allowed - must be literal enum
  risk: "${computed.risk}"         # NOT allowed - must be literal enum

  parameters:
    - name: "${dynamic}"           # NOT allowed - param names are literal
      type: "${dynamic}"           # NOT allowed - types are literal

7. Error Handling

7.1 Error Types

Error Cause Recovery
template_missing_variable Referenced variable not in scope Fix intent definition or provide param
template_invalid_syntax Malformed ${...} expression Fix intent definition
template_nested_expression Nested ${...} detected Fix intent definition
template_invalid_identifier Non-compliant identifier Fix intent definition
template_disallowed_field Template in non-templatable field Fix intent definition

7.2 Error Response

On any template error: 1. Emit audit event template_error with error details 2. Abort intent execution 3. Return error to caller with actionable message


8. Implementation Notes

8.1 Reference Implementation (bash)

#!/bin/bash
# Minimal template resolver

resolve_template() {
  local template="$1"
  local -n params_ref=$2
  local -n context_ref=$3

  local result="$template"
  local regex='\$\{([a-z_][a-z0-9_.]*)\}'

  while [[ $result =~ $regex ]]; do
    local full_match="${BASH_REMATCH[0]}"
    local var_path="${BASH_REMATCH[1]}"
    local scope="${var_path%%.*}"
    local name="${var_path#*.}"

    local value=""
    case "$scope" in
      params)  value="${params_ref[$name]}" ;;
      context) value="${context_ref[$name]}" ;;
      *)       echo "ERROR: Unknown scope: $scope" >&2; return 1 ;;
    esac

    if [[ -z "${value+x}" ]]; then
      echo "ERROR: template_missing_variable: $var_path" >&2
      return 1
    fi

    result="${result//$full_match/$value}"
  done

  echo "$result"
}

8.2 Validation

Before execution, intent definitions SHOULD be validated for: 1. All template expressions use valid syntax 2. All template expressions reference declared parameters or known context keys 3. Templates only appear in allowed fields


9. Future Considerations (v2)

The following MAY be considered for future versions: - Limited function set (lower, upper, replace, default) - Conditional expressions for outputs only - Array/map iteration for multi-resource definitions

These MUST NOT be implemented in v1.


10. Examples

10.1 Complete Resolution Flow

Intent definition:

spec:
  parameters:
    - name: app
      type: string
      required: true
    - name: environment
      type: enum
      values: [sandbox, production]
      required: true

  controls:
    lock: "${params.app}-${params.environment}"

  runbook:
    - name: build_image
      exec:
        command: |
          docker build \
            -t registry.internal/${params.app}:${context.git_sha} \
            /root/projects/${params.app}
      outputs:
        - name: image_tag
          value: "registry.internal/${params.app}:${context.git_sha}"

    - name: push_image
      exec:
        command: docker push ${steps.build_image.outputs.image_tag}

Inputs:

params:
  app: "money-tracker"
  environment: "production"
context:
  git_sha: "abc123def"

Resolution (step 1 - lock):

${params.app}-${params.environment}
→ money-tracker-production

Resolution (step 2 - build command, shell mode):

docker build -t registry.internal/'money-tracker':'abc123def' /root/projects/'money-tracker'

Resolution (step 3 - push command, using step output):

docker push 'registry.internal/money-tracker:abc123def'