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

The parser whitelists those two keys (from_env, from_file) and produces a SecretRef AST node — not a Rec. There's no other secret syntax. Adding from_vault = "key" (or similar) would be a parser change, not a runtime change.

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 in v0.6. 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 and from_file 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 from_env or from_file.

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.

For from_env, unset ⇒ [E11]. For from_file, missing file ⇒ [E11]. The resolver collects every missing reference before raising — operators see the full gap in one message.

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