diff --git a/cli/README.md b/cli/README.md
index 0d27cc1..47c3dea 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -22,6 +22,8 @@ npx @rep-protocol/cli [command]
Validate a `.rep.yaml` manifest file against the JSON schema.
+Structural check only: the gateway, not `rep validate`, checks each [default](https://rep-protocol.dev/guides/manifest/#defaults) against its `type` and `pattern`.
+
```bash
rep validate --manifest .rep.yaml
```
diff --git a/cli/schema/rep-manifest.schema.json b/cli/schema/rep-manifest.schema.json
index 4e57203..212512f 100644
--- a/cli/schema/rep-manifest.schema.json
+++ b/cli/schema/rep-manifest.schema.json
@@ -39,7 +39,7 @@
},
"default": {
"type": ["string", "number", "boolean"],
- "description": "Default value if the environment variable is not set. Only valid for non-required variables. Non-string values are coerced to strings."
+ "description": "Injected by the gateway when the variable is unset in every tier; ignored if required. Must satisfy type and pattern, or the gateway refuses to start. Non-string values are coerced to strings."
},
"description": {
"type": "string",
diff --git a/docs/public/schema/rep-manifest.schema.json b/docs/public/schema/rep-manifest.schema.json
index 4e57203..212512f 100644
--- a/docs/public/schema/rep-manifest.schema.json
+++ b/docs/public/schema/rep-manifest.schema.json
@@ -39,7 +39,7 @@
},
"default": {
"type": ["string", "number", "boolean"],
- "description": "Default value if the environment variable is not set. Only valid for non-required variables. Non-string values are coerced to strings."
+ "description": "Injected by the gateway when the variable is unset in every tier; ignored if required. Must satisfy type and pattern, or the gateway refuses to start. Non-string values are coerced to strings."
},
"description": {
"type": "string",
diff --git a/docs/src/content/docs/agents.mdx b/docs/src/content/docs/agents.mdx
index 53b4713..3b464df 100644
--- a/docs/src/content/docs/agents.mdx
+++ b/docs/src/content/docs/agents.mdx
@@ -366,6 +366,8 @@ settings:
allowed_origins: ["https://app.example.com"]
```
+An optional variable that declares `default:` is [injected by the gateway](/guides/manifest.md#defaults) when it is unset, so `rep.get('FEATURE_FLAGS')` above returns `""` rather than `undefined`.
+
```bash
rep validate # fail fast on a malformed or incomplete manifest
rep typegen -o src/rep.d.ts # typed get() / getSecure() overloads
diff --git a/docs/src/content/docs/concepts/hot-reload.mdx b/docs/src/content/docs/concepts/hot-reload.mdx
index e98fcf6..2752a44 100644
--- a/docs/src/content/docs/concepts/hot-reload.mdx
+++ b/docs/src/content/docs/concepts/hot-reload.mdx
@@ -40,6 +40,8 @@ id: 1708267831000
| `rep:config:update` | A variable's value changed or a new variable was added |
| `rep:config:delete` | A variable was removed |
+Manifest [defaults](/guides/manifest/#defaults) are re-applied on every reload. Removing a variable that declares a default sends `rep:config:update` with the default value, not `rep:config:delete`.
+
## Change detection modes
The gateway supports three modes for detecting environment variable changes:
diff --git a/docs/src/content/docs/concepts/how-it-works.mdx b/docs/src/content/docs/concepts/how-it-works.mdx
index 5defc3c..ed9187b 100644
--- a/docs/src/content/docs/concepts/how-it-works.mdx
+++ b/docs/src/content/docs/concepts/how-it-works.mdx
@@ -50,7 +50,7 @@ The gateway performs these steps in order at process start:
2. Read all `REP_*` environment variables
3. Classify each into PUBLIC, SENSITIVE, or SERVER tier
4. Validate name uniqueness after prefix stripping
-5. Load and validate `.rep.yaml` manifest if `--manifest` is specified
+5. Load and validate `.rep.yaml` manifest if `--manifest` is specified, then inject the `default` of any optional variable left unset
6. Run secret detection guardrails on PUBLIC variables
7. Exit with error if `--strict` and guardrails triggered
8. Generate ephemeral master key, derive AES-256 key via HKDF-SHA256
diff --git a/docs/src/content/docs/concepts/security-model.mdx b/docs/src/content/docs/concepts/security-model.mdx
index 73645d1..c41994f 100644
--- a/docs/src/content/docs/concepts/security-model.mdx
+++ b/docs/src/content/docs/concepts/security-model.mdx
@@ -150,6 +150,7 @@ The `"))
+ if !ok || !ok2 || !ok3 {
+ t.Fatalf("no REP payload in response:\n%s", rec.Body.String())
+ }
+
+ var p payload.Payload
+ if err := json.Unmarshal(body, &p); err != nil {
+ t.Fatalf("decoding payload %q: %v", body, err)
+ }
+ return p.Public
+}
+
+// newTestServer builds a gateway from the given manifest declarations.
+func newTestServer(decls map[string]*manifest.VarDecl, logger *slog.Logger) (*Server, error) {
+ return New(&config.Config{
+ Mode: "embedded",
+ StaticDir: "../../testdata/static",
+ Manifest: &manifest.Manifest{Variables: decls},
+ }, logger, "0.0.0-test")
+}
+
+func TestNew_ManifestDefaults(t *testing.T) {
+ ptr := func(s string) *string { return &s }
+
+ // Every case starts from the manifest shape StageFlow ships: two required
+ // variables that are set, and a csv flag list that is optional.
+ tests := []struct {
+ name string
+ flagsEnv *string // REP_PUBLIC_FEATURE_FLAGS; nil = unset
+ flagsDefault string
+ extra map[string]*manifest.VarDecl
+ wantErr string
+ wantFlags string
+ wantDefaulted float64
+ }{
+ {name: "empty default is injected and counted", flagsDefault: "", wantFlags: "", wantDefaulted: 1},
+ {name: "non-empty default is injected", flagsDefault: "dark-mode,beta", wantFlags: "dark-mode,beta", wantDefaulted: 1},
+ {name: "environment value wins over the default", flagsEnv: ptr("new-checkout"), flagsDefault: "dark-mode", wantFlags: "new-checkout"},
+ {name: "environment empty string wins over a non-empty default", flagsEnv: ptr(""), flagsDefault: "dark-mode", wantFlags: ""},
+ {
+ name: "required and unset is still an error despite a default",
+ extra: map[string]*manifest.VarDecl{"REGION": {Tier: "public", Required: true, Default: "eu", HasDefault: true}},
+ wantErr: `required variable "REGION" is not set`,
+ },
+ {
+ name: "a default that fails its declared type is refused",
+ extra: map[string]*manifest.VarDecl{"STATUS_URL": {Tier: "public", Type: "url", HasDefault: true}},
+ wantErr: `variable "STATUS_URL" must be a valid URL`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ clearREPEnv(t)
+ t.Setenv("REP_PUBLIC_API_URL", "https://api.example.com")
+ t.Setenv("REP_PUBLIC_ENV_NAME", "production")
+ if tt.flagsEnv != nil {
+ t.Setenv("REP_PUBLIC_FEATURE_FLAGS", *tt.flagsEnv)
+ }
+ decls := map[string]*manifest.VarDecl{
+ "API_URL": {Tier: "public", Type: "url", Required: true},
+ "ENV_NAME": {Tier: "public", Type: "string", Required: true},
+ "FEATURE_FLAGS": {Tier: "public", Type: "csv", Default: tt.flagsDefault, HasDefault: true},
+ }
+ maps.Copy(decls, tt.extra)
+
+ var logs bytes.Buffer
+ s, err := newTestServer(decls, slog.New(slog.NewJSONHandler(&logs, nil)))
+ if tt.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("New() error = %v, want it to contain %q", err, tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("New() unexpected error: %v", err)
+ }
+
+ want := map[string]string{
+ "API_URL": "https://api.example.com",
+ "ENV_NAME": "production",
+ "FEATURE_FLAGS": tt.wantFlags,
+ }
+ if got := injectedPublic(t, s); !maps.Equal(got, want) {
+ t.Errorf("payload public = %q, want %q", got, want)
+ }
+
+ entry := startupLog(t, &logs)
+ if entry["public_vars"] != float64(len(want)) {
+ t.Errorf("startup log public_vars = %v, want %d", entry["public_vars"], len(want))
+ }
+ if entry["defaulted_vars"] != tt.wantDefaulted {
+ t.Errorf("startup log defaulted_vars = %v, want %v", entry["defaulted_vars"], tt.wantDefaulted)
+ }
+ })
+ }
+}
+
+// A default must survive a reload, and must not look like a change to the
+// poller on every tick.
+func TestReload_KeepsManifestDefaults(t *testing.T) {
+ clearREPEnv(t)
+ t.Setenv("REP_PUBLIC_API_URL", "https://api.example.com")
+
+ s, err := newTestServer(map[string]*manifest.VarDecl{
+ "FEATURE_FLAGS": {Tier: "public", Type: "csv", Default: "", HasDefault: true},
+ }, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatalf("New() unexpected error: %v", err)
+ }
+
+ polled, err := s.readVars()
+ if err != nil {
+ t.Fatalf("readVars() unexpected error: %v", err)
+ }
+ if varsChanged(s.vars, polled) {
+ t.Error("poller sees a change on an unchanged environment")
+ }
+
+ if err := s.Reload(); err != nil {
+ t.Fatalf("Reload() unexpected error: %v", err)
+ }
+ if _, ok := injectedPublic(t, s)["FEATURE_FLAGS"]; !ok {
+ t.Error("FEATURE_FLAGS missing from the payload after reload")
+ }
+}
diff --git a/gateway/internal/server/env_test.go b/gateway/internal/server/env_test.go
new file mode 100644
index 0000000..4d24468
--- /dev/null
+++ b/gateway/internal/server/env_test.go
@@ -0,0 +1,22 @@
+package server
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// clearREPEnv removes every REP_* variable from the process environment for
+// the duration of the test, so server.New sees only what the test sets.
+func clearREPEnv(t *testing.T) {
+ t.Helper()
+ for _, env := range os.Environ() {
+ key, _, _ := strings.Cut(env, "=")
+ if strings.HasPrefix(key, "REP_") {
+ t.Setenv(key, "") // registers restoration on cleanup
+ if err := os.Unsetenv(key); err != nil {
+ t.Fatalf("unsetenv %q: %v", key, err)
+ }
+ }
+ }
+}
diff --git a/gateway/internal/server/server.go b/gateway/internal/server/server.go
index ac9b7a2..244418d 100644
--- a/gateway/internal/server/server.go
+++ b/gateway/internal/server/server.go
@@ -77,6 +77,15 @@ func New(cfg *config.Config, logger *slog.Logger, version string) (*Server, erro
}
}
+ // Step 2c: Fill in manifest defaults for optional variables left unset
+ // (§6.3). After validation, which judges only what the environment set
+ // (a defaulted deprecated variable is not "present") and has already
+ // checked every default; before guardrails, so a PUBLIC default is scanned.
+ defaulted := vars.ApplyDefaults(cfg.Manifest)
+ for _, v := range defaulted {
+ logger.Info("rep.manifest.default_applied", "name", v.Name, "tier", v.Tier.String())
+ }
+
// Step 3–4: Run secret detection guardrails.
logger.Info("running guardrail scan on PUBLIC tier variables")
gr := guardrails.Scan(vars, logger)
@@ -197,6 +206,7 @@ func New(cfg *config.Config, logger *slog.Logger, version string) (*Server, erro
"public_vars", len(vars.Public),
"sensitive_vars", len(vars.Sensitive),
"server_vars", len(vars.Server),
+ "defaulted_vars", len(defaulted),
"guardrail_warnings", len(gr.Warnings),
"hot_reload", cfg.HotReload,
"strict", cfg.Strict,
@@ -349,7 +359,7 @@ func (s *Server) runPoller(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
- newVars, err := config.ReadAndClassify(s.cfg.EnvFile)
+ newVars, err := s.readVars()
if err != nil {
s.logger.Error("rep.hotreload.poll.classify_error", "error", err)
continue
@@ -395,7 +405,7 @@ func (s *Server) Reload() error {
s.logger.Info("reloading configuration")
// Re-read and classify.
- vars, err := config.ReadAndClassify(s.cfg.EnvFile)
+ vars, err := s.readVars()
if err != nil {
return fmt.Errorf("re-classifying variables: %w", err)
}
@@ -430,6 +440,18 @@ func (s *Server) Reload() error {
return nil
}
+// readVars re-reads and classifies the environment and fills in manifest
+// defaults, so a reload or a poll sees the same variable set startup built.
+// Manifest validation is a startup-only step and is not repeated here.
+func (s *Server) readVars() (*config.ClassifiedVars, error) {
+ vars, err := config.ReadAndClassify(s.cfg.EnvFile)
+ if err != nil {
+ return nil, err
+ }
+ vars.ApplyDefaults(s.cfg.Manifest)
+ return vars, nil
+}
+
// broadcastChanges compares old and new variables and emits SSE events.
func (s *Server) broadcastChanges(oldVars, newVars *config.ClassifiedVars) {
oldPublic := oldVars.PublicMap()
diff --git a/schema/rep-manifest.schema.json b/schema/rep-manifest.schema.json
index 4e57203..212512f 100644
--- a/schema/rep-manifest.schema.json
+++ b/schema/rep-manifest.schema.json
@@ -39,7 +39,7 @@
},
"default": {
"type": ["string", "number", "boolean"],
- "description": "Default value if the environment variable is not set. Only valid for non-required variables. Non-string values are coerced to strings."
+ "description": "Injected by the gateway when the variable is unset in every tier; ignored if required. Must satisfy type and pattern, or the gateway refuses to start. Non-string values are coerced to strings."
},
"description": {
"type": "string",
diff --git a/spec/REP-RFC-0001.md b/spec/REP-RFC-0001.md
index 01ec66f..2afc649 100644
--- a/spec/REP-RFC-0001.md
+++ b/spec/REP-RFC-0001.md
@@ -2,11 +2,11 @@
```
Title: Runtime Environment Protocol (REP)
-Version: 0.1.0
+Version: 0.2.0
Status: Active
Authors: Olamide Adebayo (Ruach Tech)
Created: 2026-02-18
-Updated: 2026-02-21
+Updated: 2026-10-01
License: CC BY 4.0
```
@@ -150,6 +150,8 @@ On process start, the gateway MUST perform the following steps in order:
5. IF --manifest specified → LOAD the .rep.yaml manifest and validate
all declared variables against the environment (required vars present,
types match, patterns match). On validation failure → EXIT with error.
+ Then INJECT the `default` of every declared variable that is not
+ required and is unset in every tier (see §6.3).
6. RUN secret detection guardrails on PUBLIC tier variables
7. IF --strict AND guardrails triggered → EXIT with error
8. GENERATE ephemeral master key, derive AES-256 encryption key via
@@ -163,7 +165,8 @@ On process start, the gateway MUST perform the following steps in order:
- Health check endpoint (/rep/health)
- Hot reload SSE endpoint (/rep/changes) [if enabled]
13. START accepting connections
-14. LOG startup summary: variable counts per tier, any guardrail warnings
+14. LOG startup summary: variable counts per tier (injected defaults
+ included), any guardrail warnings
```
### 4.3 HTML Injection
@@ -489,6 +492,18 @@ settings:
| `json` | Must be valid JSON |
| `enum` | Must match one of the values in the `values` array |
+### 6.3 Defaults
+
+A variable that declares `default` and is not `required` has a value whether or not the environment sets one. When no variable of that name is set in any tier, the gateway MUST inject the default as though `REP__` had been set to it, where `` is the declared `tier`:
+
+- A PUBLIC default appears in the payload and is readable with `rep.get()`; a SENSITIVE default is encrypted like any other SENSITIVE value; a SERVER default stays in the gateway.
+- A default MUST pass the same type and pattern validation as a set value, whether or not the environment currently overrides it. A default that fails is a manifest error and the gateway MUST refuse to start.
+- A PUBLIC default is scanned by the guardrails (§3.3) like any other PUBLIC value.
+- An empty string is a default: `default: ""` injects `""`.
+- A value in the environment always wins, including an empty one.
+- `required: true` takes precedence. A required variable that is unset is a startup error even if it declares a default.
+- Defaults are re-applied on every hot reload, so removing a variable from the environment reverts it to its default rather than deleting it.
+
---
## 7. Gateway Configuration
@@ -800,7 +815,7 @@ An implementation is **REP-conformant** if it satisfies the following:
### 11.3 Optional Features (MAY)
1. Hot reload via SSE.
-2. Manifest validation.
+2. Manifest validation and default injection (§6.3).
3. Type generation.
4. Framework-specific adapters.
5. Codemod tooling.
@@ -872,4 +887,15 @@ A: REP requires a compute layer (the gateway) between the CDN and the client. Fo
---
+## Appendix C: Revision History
+
+The protocol version is independent of implementation versions; the payload's `_meta.version` carries the gateway's build version.
+
+| Version | Date | Changes |
+|---|---|---|
+| 0.2.0 | 2026-10-01 | §4.2 step 5, new §6.3 and §11.3: the gateway MUST inject a manifest `default` for an optional variable that is unset in every tier, validated like a set value. |
+| 0.1.0 | 2026-02-18 | Initial publication. |
+
+---
+
*End of REP-RFC-0001*