From 48d4ef6c74dd143ecc9bb36fc1e58558de105621 Mon Sep 17 00:00:00 2001 From: Peter Smith Date: Thu, 27 Aug 2026 11:00:01 +1200 Subject: [PATCH 1/2] Add the IAC error category and IAC_FILE_NOT_FOUND `lstk deploy` is being built as a bundled extension, and its failures have no good home in the existing seven categories. RESOURCE is the closest - it is deliberately named generically so a future non-snapshot resource error has somewhere to land - and it would fit this one code. It is not used anyway, because a single code is not the whole picture: deploy's later phases bring a family of infrastructure-as-code failures (an unsupported service, an unimplemented resource type, an unresolvable input variable), and scattering those across RESOURCE, CONFIG and VALIDATION_ERROR would leave the domain with no coherent vocabulary. IAC_FILE_NOT_FOUND is its first member: a required IaC file or directory does not exist or cannot be read, which is what `lstk deploy detect` reports for an unreadable `--dir`. Not retryable - the same path stays missing. Note this is the first category no built-in command emits. An extension renders its own envelope rather than having lstk wrap it, so nothing in this repository returns the code today. It is enumerated here regardless, because a closed enumeration is only useful if a consumer can switch on it exhaustively without caring which binary produced the envelope - an extension's output is meant to be indistinguishable from a built-in's, and that is exactly what would break if the vocabulary lived only in the extension. Co-Authored-By: Claude Opus 5 --- docs/structured-output.md | 11 +++++++++-- internal/output/error_code.go | 8 +++++++- internal/output/error_code_test.go | 5 +++-- .../json-output-schema/specs/error-codes/spec.md | 5 +++-- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/structured-output.md b/docs/structured-output.md index 4030305c..264ec6c6 100644 --- a/docs/structured-output.md +++ b/docs/structured-output.md @@ -79,7 +79,7 @@ Within the main payload, the `error` field contains the following sub-fields: | Field | Type | Notes | |---|---|---| | `code` | string | One of the enumerated codes below. Never free text — see the error-codes table. The primary, stable identifier — branch on this for anything specific. | -| `category` | string | One of 7 coarse groupings of `code` (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`) — see [Error categories](#error-categories) below. Additive alongside `code`, not a replacement for it: a caller that only wants broad handling can switch on `category`'s ~7 values instead of `code`'s ~28, while a caller that already keys off a specific `code` is unaffected. | +| `category` | string | One of 8 coarse groupings of `code` (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, `IAC`) — see [Error categories](#error-categories) below. Additive alongside `code`, not a replacement for it: a caller that only wants broad handling can switch on `category`'s ~8 values instead of `code`'s ~29, while a caller that already keys off a specific `code` is unaffected. | | `message` | string | Human-readable headline, informational only. **Not guaranteed stable across versions** — scripts must branch on `code`, not `message`. | | `retryable` | bool | A static property of `code` (not computed per failure) — see below. Note this is independent of `category`: a category can contain both retryable and non-retryable codes (e.g. `RUNTIME` contains both `NETWORK_ERROR` [retryable] and `DNS_RESOLUTION_REQUIRED` [not]), so `retryable` can't be inferred from `category` alone. | | `details` | object | Optional, code-specific structured context. Omitted when empty. Illustrative example, for a future `SNAPSHOT_BUCKET_NOT_FOUND`: `{"bucket": "my-terraform-state"}`. Also where the additional diagnostic depth plain text and the TUI show alongside the `message` headline lands, as `summary`/`detail` string keys, when available — e.g. `{"summary": "cannot connect to Docker daemon: ..."}` for `RUNTIME_UNAVAILABLE`. | @@ -121,10 +121,11 @@ Every `error.code` is one of the following fixed constants. A failure that doesn | `NETWORK_ERROR` | An unclassified network/transport failure occurred | Yes | `RUNTIME` | | `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | `INTERNAL` | | `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | `INTERNAL` | +| `IAC_FILE_NOT_FOUND` | A required infrastructure-as-code file or directory does not exist or cannot be read (e.g. the workspace `lstk deploy detect --dir` was pointed at) | No | `IAC` | ### Error categories -`error.category` groups the 28 codes above into 7 buckets, additive alongside `code` — it exists purely so a caller that only wants coarse handling doesn't have to build and maintain its own mapping from all 28 codes. `code` is unaffected and remains the primary, stable identifier for anything more specific. +`error.category` groups the 29 codes above into 8 buckets, additive alongside `code` — it exists purely so a caller that only wants coarse handling doesn't have to build and maintain its own mapping from all 29 codes. `code` is unaffected and remains the primary, stable identifier for anything more specific. ``` RUNTIME RUNTIME_UNAVAILABLE, IMAGE_PULL_FAILED, DEPENDENCY_MISSING, @@ -154,10 +155,16 @@ USAGE CONFIRMATION_REQUIRED, VALIDATION_ERROR, USAGE_ERROR, INTERNAL CANCELLED, INTERNAL_ERROR → catch-all: unexpected failure, or a user-initiated interruption + +IAC IAC_FILE_NOT_FOUND + → the workspace's infrastructure-as-code is the problem (the + domain `lstk deploy` operates in) ``` Every code maps to exactly one category, and the mapping is static — the same code always reports the same category, regardless of which command emitted it (see the Category column above for the authoritative per-code mapping). +`IAC` is the first category no built-in command emits today: `lstk deploy` is a bundled extension, and an extension renders its own envelope (see the note on extension dispatch under [Commands that will never support `--json`](#commands-that-will-never-support---json)). The vocabulary is enumerated here anyway, because the whole point of a closed enumeration is that a consumer can switch on it exhaustively without caring which binary produced the envelope — an extension's output is meant to be indistinguishable from a built-in's. + ## Exit codes ``` diff --git a/internal/output/error_code.go b/internal/output/error_code.go index ee10d7cc..49ad0673 100644 --- a/internal/output/error_code.go +++ b/internal/output/error_code.go @@ -34,6 +34,7 @@ const ( ErrNetworkError ErrorCode = "NETWORK_ERROR" ErrCancelled ErrorCode = "CANCELLED" ErrInternal ErrorCode = "INTERNAL_ERROR" + ErrIACFileNotFound ErrorCode = "IAC_FILE_NOT_FOUND" ) // retryableCodes is the single source of truth for whether a given ErrorCode @@ -60,7 +61,7 @@ func (c ErrorCode) Retryable() bool { // additive alongside Code (not a replacement for it — see design.md's // naming decisions). A caller that only wants to distinguish broad kinds of // failure (an environment problem vs. a usage problem vs. an auth problem) -// can switch on the ~7 Category values instead of the ~28 Code values; +// can switch on the ~8 Category values instead of the ~29 Code values; // Code remains the primary, stable identifier for anything more specific. type ErrorCategory string @@ -84,6 +85,9 @@ const ( // CategoryInternal: catch-all — unexpected failure or user-initiated // interruption. CategoryInternal ErrorCategory = "INTERNAL" + // CategoryIAC: the workspace's infrastructure-as-code is the problem — + // the domain `lstk deploy` operates in. + CategoryIAC ErrorCategory = "IAC" ) // allErrorCodes lists every defined ErrorCode, so tests can assert every code @@ -118,6 +122,7 @@ var allErrorCodes = []ErrorCode{ ErrNetworkError, ErrCancelled, ErrInternal, + ErrIACFileNotFound, } // categoryByCode is the single source of truth mapping each ErrorCode to its @@ -152,6 +157,7 @@ var categoryByCode = map[ErrorCode]ErrorCategory{ ErrNotJSONCapable: CategoryUsage, ErrCancelled: CategoryInternal, ErrInternal: CategoryInternal, + ErrIACFileNotFound: CategoryIAC, } // Category reports the code's static, coarse grouping. Every ErrorCode in diff --git a/internal/output/error_code_test.go b/internal/output/error_code_test.go index 0a0fd0ec..ce24a4a0 100644 --- a/internal/output/error_code_test.go +++ b/internal/output/error_code_test.go @@ -31,8 +31,8 @@ func TestErrorCode_AllErrorCodesIsComplete(t *testing.T) { t.Errorf("ErrorCode %q appears %d times in allErrorCodes, want exactly once", code, count) } } - if len(allErrorCodes) != 28 { - t.Errorf("expected 28 documented error codes, got %d — update this test's expectation alongside error-codes/spec.md if a code was intentionally added or removed", len(allErrorCodes)) + if len(allErrorCodes) != 29 { + t.Errorf("expected 29 documented error codes, got %d — update this test's expectation alongside error-codes/spec.md if a code was intentionally added or removed", len(allErrorCodes)) } } @@ -56,6 +56,7 @@ func TestErrorCode_Category(t *testing.T) { {ErrUsageError, CategoryUsage}, {ErrCancelled, CategoryInternal}, {ErrInternal, CategoryInternal}, + {ErrIACFileNotFound, CategoryIAC}, } for _, c := range cases { if got := c.code.Category(); got != c.want { diff --git a/openspec/changes/json-output-schema/specs/error-codes/spec.md b/openspec/changes/json-output-schema/specs/error-codes/spec.md index 00297682..64e3f103 100644 --- a/openspec/changes/json-output-schema/specs/error-codes/spec.md +++ b/openspec/changes/json-output-schema/specs/error-codes/spec.md @@ -33,6 +33,7 @@ Every `error.code` value emitted in a JSON envelope SHALL be one of a fixed, doc | `NETWORK_ERROR` | An unclassified network/transport failure occurred | Yes | `RUNTIME` | | `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | `INTERNAL` | | `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | `INTERNAL` | +| `IAC_FILE_NOT_FOUND` | A required infrastructure-as-code file or directory does not exist or cannot be read (e.g. the workspace `lstk deploy detect --dir` was pointed at) | No | `IAC` | #### Scenario: Error code is one of the documented constants - **WHEN** any JSON-capable command emits an `error` object @@ -65,11 +66,11 @@ Every `error` object SHALL include a `retryable` boolean, a static property of ` - **THEN** both error objects report the same `retryable` value for that code ### Requirement: Error objects declare a coarse category, additive alongside code -Every `error` object SHALL include a `category` string, a static property of `code` (the same code always carries the same category, per the table above) drawn from a fixed, small set: `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`. `category` is additive: it exists so a caller that only wants to distinguish broad kinds of failure can switch on roughly 7 values instead of the full code list, without requiring `code` to change meaning or cardinality — `code` remains the primary, stable identifier for anything more specific, and every existing rule keyed on `code` (the exit-code reservations, `retryable`, every scenario elsewhere in this document) is unaffected by `category`'s presence. +Every `error` object SHALL include a `category` string, a static property of `code` (the same code always carries the same category, per the table above) drawn from a fixed, small set: `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, `IAC`. `category` is additive: it exists so a caller that only wants to distinguish broad kinds of failure can switch on roughly 8 values instead of the full code list, without requiring `code` to change meaning or cardinality — `code` remains the primary, stable identifier for anything more specific, and every existing rule keyed on `code` (the exit-code reservations, `retryable`, every scenario elsewhere in this document) is unaffected by `category`'s presence. #### Scenario: Category is one of the documented constants - **WHEN** any JSON-capable command emits an `error` object -- **THEN** `error.category` is exactly one of `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, or `INTERNAL` +- **THEN** `error.category` is exactly one of `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, or `IAC` #### Scenario: Category is consistent for a given code - **WHEN** the same `error.code` is emitted by two different commands From 0710723d67ed4117011979f919928aa1adaf86e6 Mon Sep 17 00:00:00 2001 From: Peter Smith Date: Thu, 27 Aug 2026 12:33:11 +1200 Subject: [PATCH 2/2] Add the three remaining IAC error codes The IAC category landed with one member, on the argument that `lstk deploy`'s later phases would bring a family of infrastructure-as-code failures and that scattering them across RESOURCE, CONFIG and VALIDATION_ERROR would leave the domain with no coherent vocabulary. The first of those phases is now specified, so the rest of the family arrives with it: IAC_NO_TOOL_DETECTED no IaC tool could be resolved for the workspace - nothing matched, and no --tool was given. `lstk deploy` reports this rather than guessing at a tool. IAC_TOOL_AMBIGUOUS more than one tool matched and no choice could be made. A workspace holding Terraform alongside a hand-written SAM template is an ordinary layout, so detection reports both; deploying exactly one is then a question, and this is what it becomes when there is nobody to ask. IAC_DEPLOY_FAILED a delegated deployment command exited non-zero. All three are not retryable: the same invocation against the same workspace fails the same way until something changes. IAC_DEPLOY_FAILED is the one worth a second look, because it exists to avoid a worse alternative. The exit-code table maps status from the envelope and reserves 3 and 4, so a delegated tool's own exit status cannot be relayed as lstk's - terraform exits 2 from `plan` to mean "changes present", which would collide. The tool's status is reported in error.details instead, and this code is what carries it. Like IAC_FILE_NOT_FOUND, no built-in command emits any of these today: they are enumerated here so a consumer can switch on the vocabulary exhaustively without caring whether a built-in or a bundled extension produced the envelope. Co-Authored-By: Claude Opus 5 --- docs/structured-output.md | 8 ++++++-- internal/output/error_code.go | 11 ++++++++++- internal/output/error_code_test.go | 7 +++++-- .../json-output-schema/specs/error-codes/spec.md | 3 +++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/structured-output.md b/docs/structured-output.md index 264ec6c6..3319030d 100644 --- a/docs/structured-output.md +++ b/docs/structured-output.md @@ -79,7 +79,7 @@ Within the main payload, the `error` field contains the following sub-fields: | Field | Type | Notes | |---|---|---| | `code` | string | One of the enumerated codes below. Never free text — see the error-codes table. The primary, stable identifier — branch on this for anything specific. | -| `category` | string | One of 8 coarse groupings of `code` (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, `IAC`) — see [Error categories](#error-categories) below. Additive alongside `code`, not a replacement for it: a caller that only wants broad handling can switch on `category`'s ~8 values instead of `code`'s ~29, while a caller that already keys off a specific `code` is unaffected. | +| `category` | string | One of 8 coarse groupings of `code` (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, `IAC`) — see [Error categories](#error-categories) below. Additive alongside `code`, not a replacement for it: a caller that only wants broad handling can switch on `category`'s ~8 values instead of `code`'s ~32, while a caller that already keys off a specific `code` is unaffected. | | `message` | string | Human-readable headline, informational only. **Not guaranteed stable across versions** — scripts must branch on `code`, not `message`. | | `retryable` | bool | A static property of `code` (not computed per failure) — see below. Note this is independent of `category`: a category can contain both retryable and non-retryable codes (e.g. `RUNTIME` contains both `NETWORK_ERROR` [retryable] and `DNS_RESOLUTION_REQUIRED` [not]), so `retryable` can't be inferred from `category` alone. | | `details` | object | Optional, code-specific structured context. Omitted when empty. Illustrative example, for a future `SNAPSHOT_BUCKET_NOT_FOUND`: `{"bucket": "my-terraform-state"}`. Also where the additional diagnostic depth plain text and the TUI show alongside the `message` headline lands, as `summary`/`detail` string keys, when available — e.g. `{"summary": "cannot connect to Docker daemon: ..."}` for `RUNTIME_UNAVAILABLE`. | @@ -122,6 +122,9 @@ Every `error.code` is one of the following fixed constants. A failure that doesn | `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | `INTERNAL` | | `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | `INTERNAL` | | `IAC_FILE_NOT_FOUND` | A required infrastructure-as-code file or directory does not exist or cannot be read (e.g. the workspace `lstk deploy detect --dir` was pointed at) | No | `IAC` | +| `IAC_NO_TOOL_DETECTED` | No IaC tool could be resolved for the workspace: nothing matched, and no `--tool` was given | No | `IAC` | +| `IAC_TOOL_AMBIGUOUS` | More than one IaC tool matched the workspace and no choice could be made (no `--tool`, and nobody to prompt) | No | `IAC` | +| `IAC_DEPLOY_FAILED` | A delegated deployment command (e.g. `terraform apply`) exited non-zero; the tool's own status is reported in `error.details` | No | `IAC` | ### Error categories @@ -156,7 +159,8 @@ USAGE CONFIRMATION_REQUIRED, VALIDATION_ERROR, USAGE_ERROR, INTERNAL CANCELLED, INTERNAL_ERROR → catch-all: unexpected failure, or a user-initiated interruption -IAC IAC_FILE_NOT_FOUND +IAC IAC_FILE_NOT_FOUND, IAC_NO_TOOL_DETECTED, IAC_TOOL_AMBIGUOUS, + IAC_DEPLOY_FAILED → the workspace's infrastructure-as-code is the problem (the domain `lstk deploy` operates in) ``` diff --git a/internal/output/error_code.go b/internal/output/error_code.go index 49ad0673..e1314847 100644 --- a/internal/output/error_code.go +++ b/internal/output/error_code.go @@ -35,6 +35,9 @@ const ( ErrCancelled ErrorCode = "CANCELLED" ErrInternal ErrorCode = "INTERNAL_ERROR" ErrIACFileNotFound ErrorCode = "IAC_FILE_NOT_FOUND" + ErrIACNoToolDetected ErrorCode = "IAC_NO_TOOL_DETECTED" + ErrIACToolAmbiguous ErrorCode = "IAC_TOOL_AMBIGUOUS" + ErrIACDeployFailed ErrorCode = "IAC_DEPLOY_FAILED" ) // retryableCodes is the single source of truth for whether a given ErrorCode @@ -61,7 +64,7 @@ func (c ErrorCode) Retryable() bool { // additive alongside Code (not a replacement for it — see design.md's // naming decisions). A caller that only wants to distinguish broad kinds of // failure (an environment problem vs. a usage problem vs. an auth problem) -// can switch on the ~8 Category values instead of the ~29 Code values; +// can switch on the ~8 Category values instead of the ~32 Code values; // Code remains the primary, stable identifier for anything more specific. type ErrorCategory string @@ -123,6 +126,9 @@ var allErrorCodes = []ErrorCode{ ErrCancelled, ErrInternal, ErrIACFileNotFound, + ErrIACNoToolDetected, + ErrIACToolAmbiguous, + ErrIACDeployFailed, } // categoryByCode is the single source of truth mapping each ErrorCode to its @@ -158,6 +164,9 @@ var categoryByCode = map[ErrorCode]ErrorCategory{ ErrCancelled: CategoryInternal, ErrInternal: CategoryInternal, ErrIACFileNotFound: CategoryIAC, + ErrIACNoToolDetected: CategoryIAC, + ErrIACToolAmbiguous: CategoryIAC, + ErrIACDeployFailed: CategoryIAC, } // Category reports the code's static, coarse grouping. Every ErrorCode in diff --git a/internal/output/error_code_test.go b/internal/output/error_code_test.go index ce24a4a0..e2b1f2e2 100644 --- a/internal/output/error_code_test.go +++ b/internal/output/error_code_test.go @@ -31,8 +31,8 @@ func TestErrorCode_AllErrorCodesIsComplete(t *testing.T) { t.Errorf("ErrorCode %q appears %d times in allErrorCodes, want exactly once", code, count) } } - if len(allErrorCodes) != 29 { - t.Errorf("expected 29 documented error codes, got %d — update this test's expectation alongside error-codes/spec.md if a code was intentionally added or removed", len(allErrorCodes)) + if len(allErrorCodes) != 32 { + t.Errorf("expected 32 documented error codes, got %d — update this test's expectation alongside error-codes/spec.md if a code was intentionally added or removed", len(allErrorCodes)) } } @@ -57,6 +57,9 @@ func TestErrorCode_Category(t *testing.T) { {ErrCancelled, CategoryInternal}, {ErrInternal, CategoryInternal}, {ErrIACFileNotFound, CategoryIAC}, + {ErrIACNoToolDetected, CategoryIAC}, + {ErrIACToolAmbiguous, CategoryIAC}, + {ErrIACDeployFailed, CategoryIAC}, } for _, c := range cases { if got := c.code.Category(); got != c.want { diff --git a/openspec/changes/json-output-schema/specs/error-codes/spec.md b/openspec/changes/json-output-schema/specs/error-codes/spec.md index 64e3f103..19c15cad 100644 --- a/openspec/changes/json-output-schema/specs/error-codes/spec.md +++ b/openspec/changes/json-output-schema/specs/error-codes/spec.md @@ -34,6 +34,9 @@ Every `error.code` value emitted in a JSON envelope SHALL be one of a fixed, doc | `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | `INTERNAL` | | `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | `INTERNAL` | | `IAC_FILE_NOT_FOUND` | A required infrastructure-as-code file or directory does not exist or cannot be read (e.g. the workspace `lstk deploy detect --dir` was pointed at) | No | `IAC` | +| `IAC_NO_TOOL_DETECTED` | No IaC tool could be resolved for the workspace: nothing matched, and no `--tool` was given | No | `IAC` | +| `IAC_TOOL_AMBIGUOUS` | More than one IaC tool matched the workspace and no choice could be made (no `--tool`, and nobody to prompt) | No | `IAC` | +| `IAC_DEPLOY_FAILED` | A delegated deployment command (e.g. `terraform apply`) exited non-zero; the tool's own status is reported in `error.details` | No | `IAC` | #### Scenario: Error code is one of the documented constants - **WHEN** any JSON-capable command emits an `error` object