Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ jobs:
bun run --cwd packages/cli typecheck
bun run build
bun scripts/check-config-compiler-cli.ts
bunx ultracite check scripts/check-native-env-plan-cli.ts tests/native-env-plan-transport.test.ts tests/native-env-metadata.test.ts
bun test tests/native-env-plan-transport.test.ts tests/native-env-metadata.test.ts tests/project-env-config.test.ts tests/project-env-metadata.test.ts
bun scripts/check-native-env-plan-cli.ts

state-models:
name: Runtime state models
Expand Down
25 changes: 25 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ hack config <subcommand> [options]
| `hack config get <key>` | Read a value from hack.config.json |
| `hack config set <key> <value>` | Update a value in hack.config.json |
| `hack config validate` | Validate native configuration and selected local overlays without starting workloads |
| `hack config plan` | Inspect native environment binding completeness without decrypting or starting workloads |

### Options

Expand Down Expand Up @@ -594,6 +595,30 @@ Uses the matching bundled Rust compiler. Without --file, discovers a native proj
| `--help, -h` | Show help |
| `--version, -v` | Show version |

## `hack config plan`

Inspect native environment binding completeness without decrypting or starting workloads

### Usage

```bash
hack config plan [options]
```

Resolves the native project and permitted local env selection, then inspects managed-env names, winning scopes and secret flags. Managed YAML is parsed by its existing owner; keys and values are never decrypted or returned. Required unresolved refs return a nonzero exit. This experimental report does not establish runtime admission or enable native execution; use config validate for validation without managed-document reads.

### Options

| Option | Description |
| --- | --- |
| `--profile <names>` | Comma-separated declared native profiles |
| `--path, -p <dir>` | Run a project command against a repo path (overrides cwd search) |
| `--env <name|base>` | Apply an optional env overlay by name (use 'base' to bypass overlays) |
| `--json` | Output JSON (machine-readable) |
| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) |
| `--help, -h` | Show help |
| `--version, -v` | Show version |


## `hack session`

Expand Down
60 changes: 54 additions & 6 deletions docs/reference/native-config-compiler.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ versions refuse; omitted fields retain their documented defaults.
destination names match `[A-Za-z_][A-Za-z0-9_]*`; managed `env_ref` names match
the owning store's `[A-Z_][A-Z0-9_]*`. Public literals are authored configuration;
never copy secrets into them. The compiler never reads managed stores or process
environment. Required references stay symbolic; managed-layer selection, missing
keys, remapping collisions and secret delivery remain later owner/admission checks.
environment. Required references stay symbolic during validation. Explicit
metadata planning checks managed-layer selection, missing keys and remapping
collisions; secret delivery and runtime admission remain separate steps.
- Project `environment.default_overlay` may select a canonical named overlay;
omission selects base. Names must already match `[a-z0-9]+(?:-[a-z0-9]+)*`;
noncanonical spellings refuse rather than selecting a normalized different name.
Expand Down Expand Up @@ -137,13 +138,14 @@ authored public literals and commands. Those values are intentionally visible;
diagnostic redaction does not turn the plan into a secret-safe storage format.

- `hack-config-compiler --protocol` emits
`{"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1}`.
The CLI requires the two new capabilities for project-aware validation; an older
compiler can still serve explicit-file validation.
`{"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1}`.
Project-aware validation requires local resolution capabilities; explicit
metadata planning additionally requires `env_plan_version:1`. Older matching
compilers can still serve their supported validation modes.
- `hack-config-compiler compile [--profile NAME]...` reads one UTF-8 JSON document
from stdin through EOF. Input is limited to 1 MiB and 64 nested containers. These
are parser safety bounds, not container resource or workload-count limits.
- Success exits 0 and emits `{transport_version:1,ok:true,plan,semantic_hash}`.
- Success exits 0 and emits `{transport_version:1,ok:true,plan,semantic_hash,declared_workloads}`.
The plan declares `plan_version:1`. Failure exits 1 and emits
`{transport_version:1,ok:false,diagnostics:[...]}`. One deterministic first
diagnostic contains a stable code, fixed redacted message, JSON pointer and
Expand All @@ -161,6 +163,52 @@ diagnostic redaction does not turn the plan into a secret-safe storage format.
`hack.project.schema.json`, `hack.local.schema.json` (2020-12) and
`native-config.ts` projections.

## Explicit managed-env metadata planning

```sh
hack config plan --json
hack config plan --path /path/to/native-project --env qa --profile dev --json
hack config plan --env base
```

This project-aware command uses the same validated local selection as `config
validate`, then asks the existing managed-env owner for effective key names,
winning scopes and secret flags. It parses selected managed YAML files internally;
it does not read the secret key, decrypt values, run hooks, touch the registry or
start workloads. `config validate` continues to perform no managed-document reads.
`config plan` has no `--file` mode.

The owner merges tracked base/overlay, verified primary local base/overlay and
current checkout local base/overlay in that order. Later layers win before scope
precedence is applied. Null tombstones remove a key; an empty string is present.
CI, slim mode and `inherit_local:false` exclude primary inheritance. Unreadable,
redirected or invalid selected files refuse with fixed redacted errors. Missing
optional files are allowed. A missing selected named overlay keeps base/local
fallback and produces `missing_overlay`; unknown stored scopes produce
`inactive_env_scope` and grant no target authority. Services and jobs, including
inactive declarations, share one namespace. A workload named `host` owns that
scope; generic host overrides are otherwise excluded from guest bindings.

The Rust planner binds `env_ref` against each workload's immutable managed baseline,
before authored directives. Literals replace bindings, defaults apply only when
the destination is absent, and unset removes it. Remapping into a different
already-bound managed destination refuses with `env_reference_collision`.
Unresolved required refs produce `missing_env_reference`. These failures yield
`ok:true`, `environment_plan.complete:false` and exit 1; a complete report exits 0.
Malformed inputs yield `ok:false` and exit 1.

The report contains symbolic managed bindings and authored public literal/default
text, never managed values, ciphertext, key material or private file paths. It
leaves the authored `semantic_hash` and local `resolution_hash` unchanged. This is
binding completeness, not runtime admission, secret delivery or an atomic snapshot.

The sidecar `plan` operation accepts the original `resolve` request fields plus
`env_metadata:{metadata_version:1,overlay:null|string,overlay_exists:boolean,
workloads:{NAME:{KEY:{scope:string,secret:boolean}}},inactive_scopes:string[]}`.
Every declared workload must appear, including inactive ones. Unknown fields,
target names, invalid scopes and mismatched selections refuse. Serialized metadata
is limited to 1 MiB in addition to the existing document and encoded-request bounds.

## Local settings and worktrees

The optional `.hack/hack.local.json` is a separate versioned document:
Expand Down
61 changes: 61 additions & 0 deletions packages/config-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,64 @@ cargo +1.97.1 fmt --manifest-path packages/config-compiler/Cargo.toml --check
cargo +1.97.1 clippy --locked --manifest-path packages/config-compiler/Cargo.toml --all-targets -- -D warnings
cargo +1.97.1 test --locked --manifest-path packages/config-compiler/Cargo.toml
```

## Metadata-only environment planning

`compile` and `resolve` return a separate `declared_workloads` map from every
validated service/job name to `service` or `job`, including inactive profiles.
This projection does not change the authored plan or semantic hash. Consumers use
it to ask the existing environment owner for effective metadata without parsing
authored project JSON themselves.

The protocol advertises `env_plan_version: 1`. `plan [--profile NAME]...` accepts
the resolve request fields plus required `env_metadata`:

```json
{
"metadata_version": 1,
"overlay": null,
"overlay_exists": false,
"workloads": {
"web": { "TOKEN": { "scope": "global", "secret": true } }
},
"inactive_scopes": []
}
```

`overlay` is required and nullable. It must equal the resolved selection; base
uses `overlay_exists: false`. Metadata must include exactly the complete declared
workload namespace. Each winning scope must be `global` or that owning workload;
managed key names use `[A-Z_][A-Z0-9_]*`. A workload named `host` owns its own
metadata; it does not grant generic host scope to other workloads. Inactive scopes
must be distinct valid stored scope names outside the declared namespace, excluding
reserved `global` and `host`. All supplied metadata is validated, including inactive
workloads. Unknown fields, arrays used as objects, and duplicates refuse. The JSON
serialization of metadata is limited to 1 MiB, in addition to the existing document
and encoded-request bounds. Managed values, ciphertext and paths are unsupported.

Successful parsing returns `ok: true` and the unchanged plan, semantic hash, local
resolution and declared namespace, plus `environment_plan`. That object contains
`plan_version: 1`, selection metadata, `complete`, selected `workloads`, `warnings`
and `diagnostics`. Bindings have one of three forms:

- `managed`: symbolic `key`, winning `scope`, and `secret` flag;
- `literal`: the explicit public authored `value`;
- `default`: the explicit public fallback `value` used when no baseline key exists.

Managed baseline presence includes an empty managed value. `unset` removes a
binding. References always read the immutable owning baseline before authored
changes; a literal cannot create a reference source, nor can unset remove one.
Same-key references are allowed. A reference remapped onto a different existing
managed destination produces `env_reference_collision`; a missing source produces
`missing_env_reference`. Both make `complete: false`. Only selected workloads
contribute binding diagnostics. Missing selected overlays and inactive scopes emit
visible fixed warnings without authorizing new targets or suppressing missing refs.
Binding diagnostics use original project pointers and positions, without managed
values. Missing-overlay warnings identify the document that supplied the winning
selection: project, primary local, checkout local, or explicit request, preserving
that document’s pointer and line/column.

The process exits `1` for incomplete bindings even when `ok: true`, and `0` only
for a complete metadata plan. Completeness is not runtime admission: this operation
cannot decrypt, verify secret availability, apply config, run hooks, or start a
workload. No metadata-derived or secret-derived hash is added to the portable plan.
11 changes: 9 additions & 2 deletions packages/config-compiler/generated/native-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
// Generated by hack-config-compiler; do not edit.
export type ManagedBindingMetadata = { scope: string, secret: boolean, };
export type EnvMetadata = { metadata_version: 1, overlay: string | null, overlay_exists: boolean, workloads: { [key in string]: { [key in string]: ManagedBindingMetadata } }, inactive_scopes: Array<string>, };
export type EnvPlanRequest = { env_metadata: EnvMetadata, request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, };
export type EnvironmentBinding = { "kind": "managed", key: string, scope: string, secret: boolean, } | { "kind": "literal", value: string, } | { "kind": "default", value: string, };
export type EnvironmentPlan = { plan_version: 1, overlay: string | null, overlay_exists: boolean, complete: boolean, workloads: { [key in string]: { [key in string]: EnvironmentBinding } }, warnings: Array<ResolveDiagnostic>, diagnostics: Array<ResolveDiagnostic>, };
export type PlanResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, local_resolution: LocalResolution, environment_plan: EnvironmentPlan, } | { transport_version: 1, ok: false, diagnostics: Array<ResolveDiagnostic>, };
export type WorktreePolicy = { auto_branch?: boolean, inherit_local?: boolean, };
export type SourceMode = "host-mounted";
export type Source = { root?: string, mode?: SourceMode, };
Expand All @@ -20,12 +26,13 @@ export type Workload = { image?: string, build?: Build, command?: Command, worki
export type Project = { schema_version: 1, name: string, source?: Source, services?: { [key in string]: Workload }, jobs?: { [key in string]: Workload }, storage?: { [key in string]: Storage }, profiles?: Array<string>, environment?: EnvironmentSelection, worktree?: WorktreePolicy, };
export type Plan = { plan_version: 1, name: string, source: Source, environment: EnvironmentSelection, worktree: WorktreePolicy, selected_profiles: Array<string>, storage: { [key in string]: Storage }, services: { [key in string]: Workload }, jobs: { [key in string]: Workload }, };
export type Diagnostic = { code: string, message: string, pointer: string, line: number, column: number, };
export type CompileResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, } | { transport_version: 1, ok: false, diagnostics: Array<Diagnostic>, };
export type WorkloadKind = "service" | "job";
export type CompileResult = { transport_version: 1, ok: true, plan: Plan, declared_workloads: { [key in string]: WorkloadKind }, semantic_hash: string, } | { transport_version: 1, ok: false, diagnostics: Array<Diagnostic>, };
export type LocalEnvironment = { default_overlay?: string | null, };
export type LocalConfig = { schema_version: 1, environment?: LocalEnvironment, };
export type ResolveRequest = { request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, };
export type DocumentRole = "project" | "primary_local" | "checkout_local" | "request";
export type ResolveDiagnostic = { document: DocumentRole, code: string, message: string, pointer: string, line: number, column: number, };
export type OverlayOrigin = "project" | "primary_local" | "checkout_local" | "explicit";
export type LocalResolution = { overlay: string | null, origin: OverlayOrigin, auto_branch: boolean, inherit_local: boolean, resolution_hash: string, };
export type ResolveResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, local_resolution: LocalResolution, } | { transport_version: 1, ok: false, diagnostics: Array<ResolveDiagnostic>, };
export type ResolveResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, local_resolution: LocalResolution, } | { transport_version: 1, ok: false, diagnostics: Array<ResolveDiagnostic>, };
Loading
Loading