Skip to content

Secrets in PCL

The full architectural story is in concepts/secrets. This page covers the language surface: what you write in PCL.

Syntax

A secret is a record literal with exactly one whitelisted field:

{ from_env   = "ENV_VAR_NAME" }       # read from os.environ at deploy time
{ from_file  = "/path/to/file" }      # read from disk at deploy time
{ from_vault = "secret/data/app#tok" } # read from a secret store (ADR-014)

The parser whitelists exactly those three keys and produces a SecretRef AST node, not a Rec. There's no other secret syntax; a fourth kind would require a parser change.

from_vault takes an opaque reference resolved by an oracle you supply at deploy time with --vault-secrets (the shipped adapter reads a flat {ref: value} JSON export). Without it, a from_vault reference is reported as [E11] exactly like an unset env var. See secrets at deploy.

The frontend type-checker types SecretRef as Str, flagged secret. It behaves like a string at use sites; but the lowerer emits an IrSecret, not a string literal, so the canonical derivation sees a reference string ("from_env:NAME"), never the resolved value.

Where you can put them

A secret is Str-typed, so anywhere a Str is allowed:

module Api {
  pname = "api"
  source = {
    type = "url"
    url = "https://internal.example/artifacts/api.tar.xz"
    # token = { from_env = "FETCH_TOKEN" }    # not yet — see Roadmap
  }
}

Secrets in source not yet wired

Stage 6 wires secrets at the deploy boundary; Service.environment. Resolving secrets at the realise boundary (e.g. an authenticated type=url fetch) is on the roadmap but not built. For now, the practical place to put a secret is service.environment per stack.

The Stage 6 surface:

module Api { … }

module ApiStack {
  backend = "systemd"
  storeModules = ["Api"]
  services = [{
    name = "api"
    package = "Api"
    binary = "api"
    environment = {
      LOG_LEVEL    = "info"
      DB_PASSWORD  = { from_env = "DB_PWD" }
      OAUTH_PRIVK  = { from_file = "/run/secrets/oauth.key" }
    }
  }]
}

What's NOT a secret

The whitelist is from_env, from_file and from_vault only. Any other single-field record literal is just a normal record:

{ password = "literal-secret-bad-idea" }     # NOT a SecretRef — a plain record

{password = "..."} parses as Rec<password: Str>. The value flows through the pure layer like any other string and ends up in the store + the manifest. Don't do this. Use one of the three secret forms.

At deploy time

punix service deploy resolves every SecretValue in Service.environment via secrets.resolve_secrets(stack, env, fs):

  • env defaults to os.environ (a Mapping); tests inject a dict.
  • fs defaults to Path(p).read_bytes(); tests inject a callable.
  • vault is the from_vault oracle, supplied by --vault-secrets; absent by default.

Unset env var, missing file, or unresolvable vault reference ⇒ [E11]. The resolver collects every missing reference before raising; operators see the full gap in one message.

The resolved values reach one file on the target: a 0600 EnvironmentFile.

Concepts: secrets for the hash-exclusion conformance property. → Deploy: secrets at deploy for the operator-facing recipe.