Skip to content

Commit 89f8c5e

Browse files
authored
Merge pull request #142 from LAA-Software-Engineering/feat/112-config-resolution
feat(config,spec): user-local config, resolved snapshot, strict YAML (#112)
2 parents 6e278ed + 3d53aad commit 89f8c5e

40 files changed

Lines changed: 2036 additions & 318 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,5 @@ go.work.sum
3636

3737
tmp/
3838
examples/**/.agentic/
39+
**/.agentic/local.yaml
40+
**/.agentic/resolved-config.json

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
### Added
1010

11+
- **Config resolution hardening** (issue #112): user-local overlays (`~/.config/agentctl/config.yaml`, `.agentic/local.yaml`), strict unknown-key rejection in all YAML layers, and an immutable resolved-config snapshot (`.agentic/resolved-config.json`) with digest checks across `validate`/`plan`/`apply``run` (exit **3** on drift). `plan` JSON/YAML includes `resolvedConfigDigest`.
1112
- **Run attribution** (issue #111): `tenant_id`, `thread_id`, `actor_id`, `parent_run_id`, `request_id`, `idempotency_key`, and `source` on `runs`; trace events carry matching tenant/thread/actor for filterable logs and inspector queries. `agentctl run` accepts `--tenant-id`, `--thread-id`, `--actor-id` (local defaults `tenant-1` / `thread-1` / `user-1`); `agentctl logs` and `GET /api/runs` filter by the same dimensions. `--resume` reuses persisted `run_id` and `thread_id`. OTel spans emit `gen_ai.tenant.id`, `gen_ai.thread.id`, `gen_ai.actor.id`, and `gen_ai.request.id`. See [`docs/ATTRIBUTION.md`](docs/ATTRIBUTION.md).
1213
- **Trace payload redaction** (issue #110): trace events are sanitized, key-redacted, and size-capped before SQLite storage. Defaults mask common secret key names; override via `Project.spec.traces.redactKeys`, `maxPayloadBytes`, and `spec.traces.redaction` (`maxDepth`, `maxBytes` for binary previews, `maxStringChars`). HITL edit `argsDiff` is redacted before persistence. Local runs use [trace.NewRecorderForGraph] from project spec.
1314
- **Optional OpenTelemetry trace export** (issue #108): `Project.spec.telemetry` (`enabled`, `serviceName`, `endpoint` with `env:` tokens, `consoleExport`) emits WayFind-aligned `gen_ai.*` spans (`agent.run`, `model.chat`, `tool.exec`, `approval`) alongside SQLite traces. Disabled by default; init failures log a warning and never fail runs. See [`docs/OTEL.md`](docs/OTEL.md) for a Jaeger quick start.

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,20 @@ Notes:
164164
| `-o` / `--output` | `table`, `json`, or `yaml` |
165165
| `--no-color` | ASCII-friendly validate output |
166166

167-
Exit codes are summarized in **section 11.2** of [`docs/DESIGN_DOC.md`](docs/DESIGN_DOC.md) (`0` success, `2` validation, **`3` plan/apply conflict** when deployment state changed after `plan`, `4` execution, `5` policy denial, …).
167+
Exit codes are summarized in **section 11.2** of [`docs/DESIGN_DOC.md`](docs/DESIGN_DOC.md) (`0` success, `2` validation, **`3` plan/apply conflict** when deployment state changed after `plan` or resolved config drifted before `run`, `4` execution, `5` policy denial, …).
168+
169+
### User-local config (per-developer overrides)
170+
171+
Config is resolved in this order (highest wins): **CLI flags****environment overlay** (`-e`) → **project YAML****user-local****built-in defaults**.
172+
173+
Optional user-local files (git-ignored, strict YAML — typos fail `validate`):
174+
175+
| Path | Scope |
176+
|------|--------|
177+
| `$XDG_CONFIG_HOME/agentctl/config.yaml` or `~/.config/agentctl/config.yaml` | Global per-user defaults (`defaults`, `state`, `providers`, `traces`, `telemetry`) |
178+
| `.agentic/local.yaml` under `--project` | Project-scoped overrides (same fields; wins over the global file) |
179+
180+
`validate`, `plan`, and `apply` write `.agentic/resolved-config.json` (digest of the resolved graph + env + state path). `run` rejects drift from that snapshot with exit **3** — re-run `validate` or `plan` after changing config.
168181

169182
---
170183

@@ -175,6 +188,7 @@ Exit codes are summarized in **section 11.2** of [`docs/DESIGN_DOC.md`](docs/DES
175188
| `cmd/agentctl` | CLI entrypoint |
176189
| `internal/cli` | Cobra commands, flags, golden tests |
177190
| `internal/spec` | YAML types, normalize, validate |
191+
| `internal/config` | Layered config resolution, immutable snapshot |
178192
| `internal/project` | Load project + imports |
179193
| `internal/plan` | Planner and risk summary |
180194
| `internal/apply` | Apply plan to deployment store |

internal/cli/apply.go

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/apply"
15+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/config"
1516
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
1617
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
1718
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
@@ -70,16 +71,13 @@ func runApply(cmd *cobra.Command, flagAutoApprove bool) error {
7071
g := Globals()
7172
approved := flagAutoApprove || envAutoApproveEnabled()
7273

73-
graph, root, err := prepareProjectGraph(g.ProjectRoot, g)
74+
rc, err := prepareResolvedConfig(g)
7475
if err != nil {
7576
return NewExitError(ExitValidationError, err)
7677
}
77-
78-
env := planEnvironment(g)
79-
dsn, err := resolveStateSQLitePath(root, graph, g.StatePath)
80-
if err != nil {
81-
return fmt.Errorf("apply: resolve state path: %w", err)
82-
}
78+
graph := rc.Graph()
79+
env := rc.Environment()
80+
dsn := rc.StatePath()
8381
if err := os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
8482
return fmt.Errorf("apply: create state directory: %w", err)
8583
}
@@ -96,7 +94,10 @@ func runApply(cmd *cobra.Command, flagAutoApprove bool) error {
9694
}
9795

9896
if len(pl.Operations) == 0 {
99-
return writeApplyEmptyOutput(cmd, env, dsn, pl, g)
97+
if err := writeApplyEmptyOutput(cmd, env, dsn, pl, rc, g); err != nil {
98+
return err
99+
}
100+
return config.WriteSnapshot(rc)
100101
}
101102

102103
if g.Output != render.FormatTable {
@@ -133,7 +134,10 @@ func runApply(cmd *cobra.Command, flagAutoApprove bool) error {
133134
return fmt.Errorf("apply: %w", err)
134135
}
135136

136-
return writeApplySuccessOutput(cmd, env, dsn, pl, g, at)
137+
if err := writeApplySuccessOutput(cmd, env, dsn, pl, rc, g, at); err != nil {
138+
return err
139+
}
140+
return config.WriteSnapshot(rc)
137141
}
138142

139143
func readApplyConfirmation(r io.Reader) (bool, error) {
@@ -145,16 +149,16 @@ func readApplyConfirmation(r io.Reader) (bool, error) {
145149
return s == "y" || s == "yes", nil
146150
}
147151

148-
func writeApplyEmptyOutput(cmd *cobra.Command, env, dsn string, pl *plan.Plan, g *Global) error {
152+
func writeApplyEmptyOutput(cmd *cobra.Command, env, dsn string, pl *plan.Plan, rc *config.ResolvedConfig, g *Global) error {
149153
out := cmd.OutOrStdout()
150154
switch g.Output {
151155
case render.FormatJSON:
152-
m := planJSONModel(env, dsn, pl)
156+
m := planJSONModel(env, dsn, pl, rc)
153157
m["applied"] = false
154158
m["message"] = "no changes"
155159
return render.WriteJSON(out, m)
156160
case render.FormatYAML:
157-
m := planJSONModel(env, dsn, pl)
161+
m := planJSONModel(env, dsn, pl, rc)
158162
m["applied"] = false
159163
m["message"] = "no changes"
160164
return render.WriteYAML(out, m)
@@ -164,17 +168,17 @@ func writeApplyEmptyOutput(cmd *cobra.Command, env, dsn string, pl *plan.Plan, g
164168
}
165169
}
166170

167-
func writeApplySuccessOutput(cmd *cobra.Command, env, dsn string, pl *plan.Plan, g *Global, at time.Time) error {
171+
func writeApplySuccessOutput(cmd *cobra.Command, env, dsn string, pl *plan.Plan, rc *config.ResolvedConfig, g *Global, at time.Time) error {
168172
out := cmd.OutOrStdout()
169173
c, u, d := planCounts(pl)
170174
switch g.Output {
171175
case render.FormatJSON:
172-
m := planJSONModel(env, dsn, pl)
176+
m := planJSONModel(env, dsn, pl, rc)
173177
m["applied"] = true
174178
m["appliedAt"] = at.Format(time.RFC3339Nano)
175179
return render.WriteJSON(out, m)
176180
case render.FormatYAML:
177-
m := planJSONModel(env, dsn, pl)
181+
m := planJSONModel(env, dsn, pl, rc)
178182
m["applied"] = true
179183
m["appliedAt"] = at.Format(time.RFC3339Nano)
180184
return render.WriteYAML(out, m)

internal/cli/diff.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func runDiff(cmd *cobra.Command, args []string) error {
101101
ctx := context.Background()
102102
g := Globals()
103103

104-
graph, root, err := prepareProjectGraph(g.ProjectRoot, g)
104+
graph, root, err := prepareProjectGraph(g)
105105
if err != nil {
106106
return NewExitError(ExitValidationError, err)
107107
}

internal/cli/diff_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func TestDiff_afterApply_noDifferences(t *testing.T) {
4949
db := filepath.Join(t.TempDir(), "diff2.db")
5050

5151
g := &Global{ProjectRoot: root}
52-
graph, _, err := prepareProjectGraph(root, g)
52+
graph, _, err := prepareProjectGraph(g)
5353
if err != nil {
5454
t.Fatal(err)
5555
}
@@ -87,7 +87,7 @@ func TestDiff_singleResource_inSync(t *testing.T) {
8787
db := filepath.Join(t.TempDir(), "diff3.db")
8888

8989
g := &Global{ProjectRoot: root}
90-
graph, _, err := prepareProjectGraph(root, g)
90+
graph, _, err := prepareProjectGraph(g)
9191
if err != nil {
9292
t.Fatal(err)
9393
}
@@ -126,7 +126,7 @@ func TestDiff_singleResource_policyUpdate(t *testing.T) {
126126
db := filepath.Join(t.TempDir(), "diff4.db")
127127

128128
g := &Global{ProjectRoot: root}
129-
graph, _, err := prepareProjectGraph(root, g)
129+
graph, _, err := prepareProjectGraph(g)
130130
if err != nil {
131131
t.Fatal(err)
132132
}
@@ -269,7 +269,7 @@ func TestDiff_json_inSyncSingleTarget(t *testing.T) {
269269
db := filepath.Join(t.TempDir(), "diff9.db")
270270

271271
g := &Global{ProjectRoot: root}
272-
graph, _, err := prepareProjectGraph(root, g)
272+
graph, _, err := prepareProjectGraph(g)
273273
if err != nil {
274274
t.Fatal(err)
275275
}

internal/cli/golden_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ func TestGolden_plan_noop_after_apply_table(t *testing.T) {
108108
db := filepath.Join(t.TempDir(), "golden-plan2.db")
109109

110110
g := &Global{ProjectRoot: root}
111-
graph, _, err := prepareProjectGraph(root, g)
111+
graph, _, err := prepareProjectGraph(g)
112112
if err != nil {
113113
t.Fatal(err)
114114
}

internal/cli/init_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func TestInit_defaultPolicyExpandsShellSafePreset(t *testing.T) {
5252

5353
ResetGlobalsForTest()
5454
g := &Global{ProjectRoot: filepath.Join(parent, name)}
55-
graph, _, err := prepareProjectGraph(g.ProjectRoot, g)
55+
graph, _, err := prepareProjectGraph(g)
5656
if err != nil {
5757
t.Fatal(err)
5858
}

internal/cli/inspect.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ func runInspect(cmd *cobra.Command, args []string) error {
119119
return NewExitError(ExitValidationError, fmt.Errorf("inspect: %w", err))
120120
}
121121
gl := Globals()
122-
graph, _, err := prepareProjectGraph(gl.ProjectRoot, gl)
122+
graph, _, err := prepareProjectGraph(gl)
123123
if err != nil {
124124
return NewExitError(ExitValidationError, err)
125125
}

internal/cli/inspect_web.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func runInspectWeb(cmd *cobra.Command, port int, traceUIBase string) error {
1919
return NewExitError(ExitValidationError, err)
2020
}
2121
g := Globals()
22-
graph, root, err := prepareProjectGraph(g.ProjectRoot, g)
22+
graph, root, err := prepareProjectGraph(g)
2323
if err != nil {
2424
return NewExitError(ExitValidationError, err)
2525
}

0 commit comments

Comments
 (0)