diff --git a/.gitignore b/.gitignore
index b5178feb8..cd0343fd6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,9 @@ src_managed/
project/boot/
project/plugins/project/
+# kotlin
+.kotlin/
+.gradle
# test/
diff --git a/src/core/codes/README.md b/src/core/codes/README.md
index d4ca2172e..2b2f26f86 100644
--- a/src/core/codes/README.md
+++ b/src/core/codes/README.md
@@ -15,7 +15,7 @@ Every `Status` can be represented as a structured response, for example as an AP
```json
{
"name" : "TOKEN_EXPIRED",
- "type" : "Denied",
+ "type" : "denied",
"code" : 400009,
"success": false,
"message": "Session token expired"
@@ -28,7 +28,7 @@ Every `Status` can be represented as a structured response, for example as an AP
1. **Universal** — Usable at any layer: service, background job, route handler, CLI command.
2. **Hierarchy** — Logical grouping of successes and failures for branching and aggregation.
3. **Standard** — Precise, consistent status representation across all layers and targets.
-4. **Compliant** — Convertible to HTTP status codes via `Codes.toHttp(status)`.
+4. **Compliant** — Convertible to HTTP status codes via a `CodeLookup` implementation such as `CodesToHttp`.
5. **Reusable** — A single status instance can be shared across many call sites.
6. **Extensible** — Create domain codes by constructing `Passed.*` or `Failed.*` subtypes directly.
7. **Searchable** — `name` and `type` are stable, unique keys suitable for log queries.
@@ -38,25 +38,29 @@ Every `Status` can be represented as a structured response, for example as an AP
---
## Hierarchy
+Categories are closed/sealed and fixed by design, to enforce a consistent taxonomy across every
+consumer. Individual codes *within* a category are open — create new domain codes by constructing
+a `Passed` or `Failed` subtype directly (see [Built-in Codes](#built-in-codes) for the built-in set).
+
```
-Status = Passed | Failed
-Passed = Succeeded | Pending | Filtered | Ignored
-Failed = Denied | Invalid | Errored | Unknown
+Status = Passed | Failed
+Passed = Succeeded | Pending | Filtered | Information
+Failed = Denied | Invalid | Errored | Unserviceable
```
```mermaid
graph TD
- classDef statusNode fill:#3b82f6,stroke:#1d4ed8,color:#ffffff,font-weight:bold
- classDef passedNode fill:#86efac,stroke:#16a34a,color:#14532d,font-weight:bold
- classDef succeededNode fill:#22c55e,stroke:#15803d,color:#ffffff,font-weight:bold
- classDef pendingNode fill:#fde047,stroke:#ca8a04,color:#713f12,font-weight:bold
- classDef filteredNode fill:#9ca3af,stroke:#6b7280,color:#ffffff,font-weight:bold
- classDef ignoredNode fill:#9ca3af,stroke:#6b7280,color:#ffffff,font-weight:bold
- classDef failedNode fill:#fca5a5,stroke:#f87171,color:#7f1d1d,font-weight:bold
- classDef deniedNode fill:#111827,stroke:#000000,color:#ffffff,font-weight:bold
- classDef invalidNode fill:#f97316,stroke:#c2410c,color:#ffffff,font-weight:bold
- classDef erroredNode fill:#dc2626,stroke:#b91c1c,color:#ffffff,font-weight:bold
- classDef unknownNode fill:#7f1d1d,stroke:#450a0a,color:#ffffff,font-weight:bold
+ classDef statusNode fill:#3b82f6,stroke:#1d4ed8,color:#ffffff,font-weight:bold
+ classDef passedNode fill:#86efac,stroke:#16a34a,color:#14532d,font-weight:bold
+ classDef succeededNode fill:#22c55e,stroke:#15803d,color:#ffffff,font-weight:bold
+ classDef pendingNode fill:#fde047,stroke:#ca8a04,color:#713f12,font-weight:bold
+ classDef filteredNode fill:#9ca3af,stroke:#6b7280,color:#ffffff,font-weight:bold
+ classDef informationNode fill:#38bdf8,stroke:#0284c7,color:#0c4a6e,font-weight:bold
+ classDef failedNode fill:#fca5a5,stroke:#f87171,color:#7f1d1d,font-weight:bold
+ classDef deniedNode fill:#111827,stroke:#000000,color:#ffffff,font-weight:bold
+ classDef invalidNode fill:#f97316,stroke:#c2410c,color:#ffffff,font-weight:bold
+ classDef erroredNode fill:#dc2626,stroke:#b91c1c,color:#ffffff,font-weight:bold
+ classDef unserviceableNode fill:#7f1d1d,stroke:#450a0a,color:#ffffff,font-weight:bold
Status["Status
name / code / message / success"]:::statusNode
@@ -66,40 +70,40 @@ graph TD
Succeeded["Succeeded
type: succeeded"]:::succeededNode
Pending["Pending
type: pending"]:::pendingNode
Filtered["Filtered
type: filtered"]:::filteredNode
- Ignored["Ignored
type: ignored"]:::ignoredNode
+ Information["Information
type: information"]:::informationNode
Denied["Denied
type: denied"]:::deniedNode
Invalid["Invalid
type: invalid"]:::invalidNode
Errored["Errored
type: errored"]:::erroredNode
- Unknown["Unknown
type: unknown"]:::unknownNode
+ Unserviceable["Unserviceable
type: unserviceable"]:::unserviceableNode
Status --> Passed
Status --> Failed
Passed --> Succeeded
Passed --> Pending
Passed --> Filtered
- Passed --> Ignored
+ Passed --> Information
Failed --> Denied
Failed --> Invalid
Failed --> Errored
- Failed --> Unknown
+ Failed --> Unserviceable
```
---
## Grouping
-| Parent | Type | Level | `success` | Purpose |
-|----------|-------------|--------|-----------|----------------------------------------|
-| `Status` | `Passed` | Parent | — | Parent of all non-failure statuses |
-| `Passed` | `Succeeded` | Child | `true` | Operation completed successfully |
-| `Passed` | `Pending` | Child | `true` | Accepted but not yet fully processed |
-| `Passed` | `Filtered` | Child | `true` | Intentionally excluded from processing |
-| `Passed` | `Ignored` | Child | `true` | Processed but result discarded |
-| `Status` | `Failed` | Parent | — | Parent of all failure statuses |
-| `Failed` | `Denied` | Child | `false` | Security / access-control failure |
-| `Failed` | `Invalid` | Child | `false` | Malformed or invalid input data |
-| `Failed` | `Errored` | Child | `false` | Known business-rule failure |
-| `Failed` | `Unknown` | Child | `false` | Unexpected or unhandled failure |
+| Parent | Type | Level | `success` | Purpose |
+|----------|-----------------|--------|-----------|------------------------------------------------------------------|
+| `Status` | `Passed` | Parent | — | Parent of all non-failure statuses |
+| `Passed` | `Succeeded` | Child | `true` | Operation's primary purpose completed |
+| `Passed` | `Pending` | Child | `true` | Accepted but not yet fully processed |
+| `Passed` | `Filtered` | Child | `true` | Excluded from normal output — not processed, or processed and discarded |
+| `Passed` | `Information` | Child | `true` | Informational / metadata response, no primary operation performed |
+| `Status` | `Failed` | Parent | — | Parent of all failure statuses |
+| `Failed` | `Denied` | Child | `false` | Security / access-control failure |
+| `Failed` | `Invalid` | Child | `false` | The request as given cannot be satisfied — bad or missing input |
+| `Failed` | `Errored` | Child | `false` | Known, expected business-rule failure |
+| `Failed` | `Unserviceable` | Child | `false` | Valid & permitted, but can't be serviced right now — capacity, timeout, unimplemented, or truly unexpected |
---
@@ -107,95 +111,138 @@ graph TD
Every `Status` carries the following fields:
-| Field | Property | Purpose |
-|-----------|-----------|-----------------------------------------------------------------------------------------|
-| `name` | `name` | Unique domain label, e.g. `TOKEN_EXPIRED`, `RATE_LIMITED`. Stable — used as a log key. |
-| `code` | `code` | Numeric code. Defaults align with HTTP ranges; convert via `Codes.toHttp(status)`. |
-| `message` | `message` | Human-readable description. Must be a constant — never constructed from runtime data. |
-| `success` | `success` | Boolean shortcut for callers that don't need to narrow the sealed type. |
+| Field | Property | Purpose |
+|-----------|-----------|---------------------------------------------------------------------------------------------------|
+| `name` | `name` | Unique domain label, e.g. `TOKEN_EXPIRED`, `RATE_LIMITED`. Stable — used as a log key. |
+| `code` | `code` | Numeric code. Grouped by category by convention, but NOT a literal HTTP code — convert via a `CodeLookup` (e.g. `CodesToHttp`). |
+| `message` | `message` | Human-readable description. Must be a constant — never constructed from runtime data. |
+| `success` | `success` | Boolean shortcut for callers that don't need to narrow the sealed type. |
---
## Built-in Codes
The `Codes` object provides a standard registry. All codes are optional — create domain-specific
-codes by constructing any `Passed` or `Failed` subtype directly.
-
-### Succeeded (200xxx)
-
-| Code | Value | HTTP |
-|-----------------|--------|------|
-| `SUCCESS` | 200001 | 200 |
-| `CREATED` | 200002 | 201 |
-| `UPDATED` | 200003 | 200 |
-| `FETCHED` | 200004 | 200 |
-| `PATCHED` | 200005 | 200 |
-| `DELETED` | 200006 | 200 |
-| `HANDLED` | 200007 | 204 |
-| `EXIT` | 600002 | 503 |
-| `HELP` | 600003 | 200 |
-| `ABOUT` | 600004 | 200 |
-| `VERSION` | 600005 | 200 |
-
-### Pending (200xxx)
-
-| Code | Value | HTTP |
-|------------|--------|------|
-| `PENDING` | 200008 | 202 |
-| `QUEUED` | 200009 | 202 |
-| `CONFIRM` | 200010 | 200 |
-| `ACTIVE` | 200101 | 200 |
-| `INACTIVE` | 200102 | 200 |
-| `STARTING` | 200103 | 200 |
-| `WAITING` | 200104 | 200 |
-| `RUNNING` | 200105 | 200 |
-| `PAUSED` | 200106 | 200 |
-| `STOPPED` | 200107 | 200 |
-| `COMPLETE` | 200108 | 200 |
-
-### Passed — Filtered / Ignored (200xxx)
-
-| Code | Value | HTTP |
-|------------|--------|------|
-| `FILTERED` | 200204 | 200 |
-| `IGNORED` | 200204 | 200 |
-
-### Denied (400xxx)
-
-| Code | Value | HTTP |
-|-------------------|--------|------|
-| `DENIED` | 400005 | 401 |
-| `UNSUPPORTED` | 400006 | 501 |
-| `UNIMPLEMENTED` | 400007 | 501 |
-| `UNAVAILABLE` | 400008 | 503 |
-| `UNAUTHENTICATED` | 400009 | 401 |
-| `UNAUTHORIZED` | 400010 | 401 |
-
-### Invalid (400xxx)
+codes by constructing any `Passed` or `Failed` subtype directly. Every code's uniqueness is
+enforced at object-init time — a duplicate code fails loudly the first time `Codes` is touched.
+
+The `HTTP` column below reflects the default mapping from `CodesToHttp` — a category default,
+unless a specific code has an override (see [HTTP Conversion](#http-conversion)).
+
+### Succeeded (200000-200099)
+
+| Code | Value | HTTP |
+|-----------|--------|------|
+| `SUCCESS` | 200001 | 200 |
+| `CREATED` | 200002 | 201 |
+| `UPDATED` | 200003 | 200 |
+| `FETCHED` | 200004 | 200 |
+| `PATCHED` | 200005 | 200 |
+| `DELETED` | 200006 | 200 |
+| `HANDLED` | 200007 | 204 |
+
+### Pending (200100-200199)
+
+| Code | Value | HTTP |
+|-----------|--------|------|
+| `PENDING` | 200101 | 202 |
+| `QUEUED` | 200102 | 202 |
+| `CONFIRM` | 200103 | 200 |
+
+### Filtered (200200-200299)
+
+Covers both "not processed at all" (`SKIPPED`) and "processed, then the result was deliberately
+discarded" (`DISCARDED`) — the distinction is carried by name/code, not by separate types.
+
+| Code | Value | HTTP |
+|-------------|--------|------|
+| `SKIPPED` | 200201 | 200 |
+| `DISCARDED` | 200202 | 200 |
+
+### Information (200300-200399)
+
+| Code | Value | HTTP |
+|-----------|--------|------|
+| `HELP` | 200301 | 200 |
+| `ABOUT` | 200302 | 200 |
+| `VERSION` | 200303 | 200 |
+| `EXIT` | 200304 | 200 |
+
+### Denied (400000-400099) — security / access-control
+
+| Code | Value | HTTP |
+|--------------------|--------|------|
+| `DENIED` | 400001 | 401 |
+| `UNAUTHENTICATED` | 400002 | 401 |
+| `UNAUTHORIZED` | 400003 | 401 |
+
+### Invalid (400100-400199) — bad input
| Code | Value | HTTP |
|---------------|--------|------|
-| `BAD_REQUEST` | 400002 | 400 |
-| `INVALID` | 400003 | 400 |
-| `NOT_FOUND` | 400004 | 404 |
+| `BAD_REQUEST` | 400101 | 400 |
+| `INVALID` | 400102 | 400 |
+| `NOT_FOUND` | 400103 | 404 |
-### Errored (500xxx)
+### Errored (500000-500099) — known, expected business-rule failure
| Code | Value | HTTP |
|--------------|--------|------|
-| `MISSING` | 500002 | 400 |
-| `FORBIDDEN` | 500003 | 403 |
-| `CONFLICT` | 500004 | 409 |
-| `DEPRECATED` | 500005 | 426 |
-| `TIMEOUT` | 500006 | 408 |
-| `ERRORED` | 500007 | 500 |
-| `LIMITED` | 500009 | 500 |
+| `MISSING` | 500001 | 400 |
+| `FORBIDDEN` | 500002 | 403 |
+| `CONFLICT` | 500003 | 409 |
+| `DEPRECATED` | 500004 | 426 |
+| `ERRORED` | 500005 | 500 |
+
+### Unserviceable (500100-500199) — valid & permitted, can't be serviced right now
+
+| Code | Value | HTTP |
+|----------------------|--------|------|
+| `UNIMPLEMENTED` | 500101 | 501 |
+| `UNSUPPORTED` | 500102 | 501 |
+| `TIMEOUT` | 500103 | 408 |
+| `RATE_LIMITED` | 500104 | 429 |
+| `UNREACHABLE` | 500105 | 503 |
+| `UNDER_MAINTENANCE` | 500106 | 503 |
+| `UNEXPECTED` | 500107 | 500 |
-### Unknown (500xxx)
+---
-| Code | Value | HTTP |
-|--------------|--------|------|
-| `UNEXPECTED` | 500008 | 500 |
+## HTTP Conversion
+
+`CodeLookup` is a direction-explicit, bidirectional conversion between a `Status` and a target
+protocol's status code (e.g. HTTP) — `toCode(status): Int` / `toStatus(code): Status?` — so the
+two code spaces (internal registry code vs. HTTP code) can never be silently confused at a call
+site.
+
+`CodesToHttp` is the default HTTP implementation. `toCode` is an exhaustive `when` over `Status`'s
+categories (a missing category is a compile error, not a silent fallback), layered with a small
+overrides map for the handful of codes that differ from their category's default (e.g. `CREATED`
+-> 201 vs. `Succeeded`'s default 200). `toStatus` is derived from `toCode`, so the two directions
+can never drift apart from each other. An unrecognized HTTP code returns `null` — the caller
+decides the fallback, rather than the library guessing one from a numeric range.
+
+```kotlin
+val http = CodesToHttp()
+http.toCode(Codes.CREATED) // 201
+http.toCode(Codes.DENIED) // 401 (category default)
+http.toStatus(404)?.name // "NOT_FOUND"
+http.toStatus(999) // null — unrecognized code, no guessed fallback
+```
+
+`CompositeLookup` composes a base `CodeLookup` with client-supplied extensions, without
+subclassing `CodesToHttp` — composition over inheritance. It's keyed by the actual `Status`
+instance (not just its code), so custom statuses outside the `Codes` registry are also
+reverse-lookupable via `toStatus`:
+
+```kotlin
+val PAYMENT_DECLINED = Failed.Errored("PAYMENT_DECLINED", 700123, "Payment declined")
+val lookup = CompositeLookup(base = CodesToHttp(), extensions = mapOf(PAYMENT_DECLINED to 402))
+
+lookup.toCode(PAYMENT_DECLINED) // 402
+lookup.toStatus(402) // PAYMENT_DECLINED
+lookup.toCode(Codes.DENIED) // 401 — falls back to the base lookup
+```
---
@@ -217,9 +264,11 @@ try {
// ...
} catch (e: StatusException) {
when (e.status) {
- is Failed.Denied -> // handle auth failure
- is Failed.Errored -> // handle business error
- else -> // ...
+ is Failed.Denied -> // handle auth failure
+ is Failed.Invalid -> // handle bad input
+ is Failed.Errored -> // handle known business-rule failure
+ is Failed.Unserviceable -> // handle capacity / timeout / unimplemented / unexpected
+ is Passed -> // n/a — Passed statuses aren't normally thrown
}
}
```
diff --git a/src/core/codes/src/commonMain/kotlin/kiit/codes/Codes.kt b/src/core/codes/src/commonMain/kotlin/kiit/codes/Codes.kt
index 9e90cd7d0..c318e17d2 100644
--- a/src/core/codes/src/commonMain/kotlin/kiit/codes/Codes.kt
+++ b/src/core/codes/src/commonMain/kotlin/kiit/codes/Codes.kt
@@ -12,155 +12,197 @@
package kiit.codes
/**
- * Built-in registry of standard [Status] codes covering the most common operation outcomes.
+ * Built-in registry of standard [Status] codes covering common operation outcomes.
*
- * Using these is optional — they are provided for convenience and as defaults for kiit-result
- * builder methods. Custom codes can be created by constructing any [Passed] or [Failed] subtype
- * directly.
+ * Using these is optional — they're provided as sensible defaults and for kiit-result builder
+ * methods. Custom domain codes can be created by constructing any [Passed] or [Failed] subtype
+ * directly; only the four categories under each are fixed/closed (see [Status]).
*
- * Numeric codes default to HTTP-compatible ranges:
- * - 200xxx → success / pending
- * - 400xxx → client / validation failures
- * - 500xxx → server / unexpected failures
- * - 600xxx → interactive / metadata
- *
- * HTTP conversion is available via [toHttp]. Each code maps to the closest semantic HTTP status.
+ * Numeric code ranges (conceptual grouping only — see the NOTE on [Status.code]):
+ * 200000-200099 Succeeded 200100-200199 Pending
+ * 200200-200299 Filtered 200300-200399 Information
+ * 400000-400099 Denied 400100-400199 Invalid
+ * 500000-500099 Errored 500100-500199 Unserviceable
*
+ * Uniqueness of every code in this registry is enforced at object-init time (see the `init`
+ * block below) — a duplicate code will fail loudly the first time [Codes] is touched, rather
+ * than silently producing a wrong HTTP mapping.
*/
object Codes {
- // Success: 200000 + range ( useful for CRUD operations )
+ // ---- Succeeded (200000-200099) ----
val SUCCESS = Passed.Succeeded("SUCCESS", 200001, "Success")
val CREATED = Passed.Succeeded("CREATED", 200002, "Created")
val UPDATED = Passed.Succeeded("UPDATED", 200003, "Updated")
val FETCHED = Passed.Succeeded("FETCHED", 200004, "Fetched")
- val PATCHED = Passed.Succeeded("PATCHED", 200005, "Patched") // E.g. Update a small subset of info
+ val PATCHED = Passed.Succeeded("PATCHED", 200005, "Patched")
val DELETED = Passed.Succeeded("DELETED", 200006, "Deleted")
- val HANDLED = Passed.Succeeded("HANDLED", 200007, "Handled") // E.g. A silent ok ( similar to http 204 )
- val PENDING = Passed.Pending("PENDING", 200008, "Pending")
- val QUEUED = Passed.Pending("QUEUED", 200009, "Queued")
- val CONFIRM = Passed.Pending("CONFIRM", 200010, "Confirm")
- val FILTERED = Passed.Filtered("FILTERED", 200204, "Filtered") // E.g. Ignored, not exactly an error
- val IGNORED = Passed.Ignored("IGNORED", 200204, "Ignored") // E.g. Ignored, not exactly an error
-
- // Success: 200000 + range ( useful for JOB States )
- val ACTIVE = Passed.Pending("ACTIVE", 200101, "Active")
- val INACTIVE = Passed.Pending("INACTIVE", 200102, "Inactive")
- val STARTING = Passed.Pending("STARTING", 200103, "Starting")
- val WAITING = Passed.Pending("WAITING", 200104, "Waiting")
- val RUNNING = Passed.Pending("RUNNING", 200105, "Running")
- val PAUSED = Passed.Pending("PAUSED", 200106, "Paused")
- val STOPPED = Passed.Pending("STOPPED", 200107, "Stopped")
- val COMPLETE = Passed.Pending("COMPLETE", 200108, "Complete")
-
- // Invalid: 400000 + range
- val BAD_REQUEST = Failed.Invalid("BAD_REQUEST", 400002, "Bad Request") // E.g. Invalid JSON
- val INVALID = Failed.Invalid("INVALID", 400003, "Invalid") // E.g. Valid JSON but invalid values
- val NOT_FOUND = Failed.Invalid("NOT_FOUND", 400004, "Not found") // E.g. Resource/End point not found
-
- // Security related
- val DENIED = Failed.Denied("DENIED", 400005, "Denied") // Presumes a checked condition
- val UNSUPPORTED = Failed.Denied("UNSUPPORTED", 400006, "Not supported") // Presumes a checked condition
- val UNIMPLEMENTED = Failed.Denied("UNIMPLEMENTED", 400007, "Not implemented") // Presumes a checked condition
- val UNAVAILABLE = Failed.Denied("UNAVAILABLE", 400008, "Not available") // Presumes a checked condition
- val UNAUTHENTICATED = Failed.Denied("UNAUTHENTICATED", 400009, "Unauthenticated") // Presumes a checked condition
- val UNAUTHORIZED = Failed.Denied("UNAUTHORIZED", 400010, "Unauthorized") // Presumes a checked condition
-
- // Expected errors: 500000 + range
- val MISSING = Failed.Errored("MISSING", 500002, "Missing item") // E.g. Domain model not found
- val FORBIDDEN = Failed.Errored("FORBIDDEN", 500003, "Forbidden")
- val CONFLICT = Failed.Errored("CONFLICT", 500004, "Conflict")
- val DEPRECATED = Failed.Errored("DEPRECATED", 500005, "Deprecated")
- val TIMEOUT = Failed.Errored("TIMEOUT", 500006, "Timeout")
- val ERRORED = Failed.Errored("ERRORED", 500007, "Errored") // General purpose use
- val LIMITED = Failed.Errored("LIMITED", 500009, "Limited")
-
- // Unexpected
- val UNEXPECTED = Failed.Unknown("UNEXPECTED", 500008, "Unexpected")
-
- // Success ( Interactive / Metadata )
- val EXIT = Passed.Succeeded("EXIT", 600002, "Exiting")
- val HELP = Passed.Succeeded("HELP", 600003, "Help")
- val ABOUT = Passed.Succeeded("ABOUT", 600004, "About")
- val VERSION = Passed.Succeeded("VERSION", 600005, "Version")
-
- private val mappings =
+ val HANDLED = Passed.Succeeded("HANDLED", 200007, "Handled") // e.g. a silent OK, similar to HTTP 204
+
+ // ---- Pending (200100-200199) ----
+ val PENDING = Passed.Pending("PENDING", 200101, "Pending")
+ val QUEUED = Passed.Pending("QUEUED", 200102, "Queued")
+ val CONFIRM = Passed.Pending("CONFIRM", 200103, "Confirm")
+
+ // ---- Filtered (200200-200299) ----
+ val SKIPPED = Passed.Filtered("SKIPPED", 200201, "Skipped") // not processed at all
+ val DISCARDED = Passed.Filtered("DISCARDED", 200202, "Discarded") // processed, result thrown away
+
+ // ---- Information (200300-200399) ----
+ val HELP = Passed.Information("HELP", 200301, "Help")
+ val ABOUT = Passed.Information("ABOUT", 200302, "About")
+ val VERSION = Passed.Information("VERSION", 200303, "Version")
+ val EXIT = Passed.Information("EXIT", 200304, "Exiting")
+
+ // ---- Denied (400000-400099) — security / access-control ----
+ val DENIED = Failed.Denied("DENIED", 400001, "Denied")
+ val UNAUTHENTICATED = Failed.Denied("UNAUTHENTICATED", 400002, "Unauthenticated")
+ val UNAUTHORIZED = Failed.Denied("UNAUTHORIZED", 400003, "Unauthorized")
+
+ // ---- Invalid (400100-400199) — bad input ----
+ val BAD_REQUEST = Failed.Invalid("BAD_REQUEST", 400101, "Bad request") // e.g. malformed JSON
+ val INVALID = Failed.Invalid("INVALID", 400102, "Invalid") // e.g. well-formed but invalid values
+ val NOT_FOUND = Failed.Invalid("NOT_FOUND", 400103, "Not found") // e.g. resource/endpoint not found
+
+ // ---- Errored (500000-500099) — known, expected business-rule failure ----
+ val MISSING = Failed.Errored("MISSING", 500001, "Missing item") // e.g. domain model not found
+ val FORBIDDEN = Failed.Errored("FORBIDDEN", 500002, "Forbidden")
+ val CONFLICT = Failed.Errored("CONFLICT", 500003, "Conflict")
+ val DEPRECATED = Failed.Errored("DEPRECATED", 500004, "Deprecated")
+ val ERRORED = Failed.Errored("ERRORED", 500005, "Errored") // general purpose use
+
+ // ---- Unserviceable (500100-500199) — valid & permitted, can't be serviced right now ----
+ val UNIMPLEMENTED = Failed.Unserviceable("UNIMPLEMENTED", 500101, "Not implemented")
+ val UNSUPPORTED = Failed.Unserviceable("UNSUPPORTED", 500102, "Not supported")
+ val TIMEOUT = Failed.Unserviceable("TIMEOUT", 500103, "Timeout")
+ val RATE_LIMITED = Failed.Unserviceable("RATE_LIMITED", 500104, "Rate limited")
+ val UNREACHABLE = Failed.Unserviceable("UNREACHABLE", 500105, "Unreachable") // e.g. dependency down
+ val UNDER_MAINTENANCE = Failed.Unserviceable("UNDER_MAINTENANCE", 500106, "Under maintenance")
+ val UNEXPECTED = Failed.Unserviceable("UNEXPECTED", 500107, "Unexpected") // unhandled/uncaught path
+
+ /** All built-in codes. Used for reverse lookups — see [CodesToHttp], [CompositeLookup]. */
+ val all: List =
listOf(
- // CRUD
- Triple(SUCCESS.code, SUCCESS, 200),
- Triple(CREATED.code, CREATED, 201),
- Triple(UPDATED.code, UPDATED, 200),
- Triple(FETCHED.code, FETCHED, 200),
- Triple(PATCHED.code, PATCHED, 200),
- Triple(DELETED.code, DELETED, 200),
- Triple(PENDING.code, PENDING, 202),
- Triple(QUEUED.code, QUEUED, 202),
- Triple(HANDLED.code, HANDLED, 204),
- Triple(CONFIRM.code, CONFIRM, 200),
- // JOB States
- Triple(ACTIVE.code, ACTIVE, 200),
- Triple(INACTIVE.code, INACTIVE, 200),
- Triple(STARTING.code, STARTING, 200),
- Triple(WAITING.code, WAITING, 200),
- Triple(RUNNING.code, RUNNING, 200),
- Triple(PAUSED.code, PAUSED, 200),
- Triple(STOPPED.code, STOPPED, 200),
- Triple(COMPLETE.code, COMPLETE, 200),
- // Info
- Triple(HELP.code, HELP, 200),
- Triple(ABOUT.code, ABOUT, 200),
- Triple(VERSION.code, VERSION, 200),
- // Invalid
- Triple(IGNORED.code, IGNORED, 400),
- Triple(BAD_REQUEST.code, BAD_REQUEST, 400),
- Triple(INVALID.code, INVALID, 400),
- Triple(UNSUPPORTED.code, UNSUPPORTED, 501),
- Triple(UNIMPLEMENTED.code, UNIMPLEMENTED, 501),
- Triple(UNAVAILABLE.code, UNAVAILABLE, 503),
- // Errors
- Triple(MISSING.code, MISSING, 400),
- Triple(NOT_FOUND.code, NOT_FOUND, 404),
- Triple(DENIED.code, DENIED, 401),
- Triple(UNAUTHENTICATED.code, UNAUTHENTICATED, 401),
- Triple(UNAUTHORIZED.code, UNAUTHORIZED, 401),
- Triple(FORBIDDEN.code, FORBIDDEN, 403),
- Triple(TIMEOUT.code, TIMEOUT, 408),
- Triple(CONFLICT.code, CONFLICT, 409),
- Triple(DEPRECATED.code, DEPRECATED, 426),
- Triple(ERRORED.code, ERRORED, 500),
- Triple(UNEXPECTED.code, UNEXPECTED, 500),
- Triple(EXIT.code, EXIT, 503),
+ SUCCESS, CREATED, UPDATED, FETCHED, PATCHED, DELETED, HANDLED,
+ PENDING, QUEUED, CONFIRM,
+ SKIPPED, DISCARDED,
+ HELP, ABOUT, VERSION, EXIT,
+ DENIED, UNAUTHENTICATED, UNAUTHORIZED,
+ BAD_REQUEST, INVALID, NOT_FOUND,
+ MISSING, FORBIDDEN, CONFLICT, DEPRECATED, ERRORED,
+ UNIMPLEMENTED, UNSUPPORTED, TIMEOUT, RATE_LIMITED, UNREACHABLE, UNDER_MAINTENANCE, UNEXPECTED,
)
- private val lookupByCode = mappings.associateBy { it.first }
- private val lookupByHttp = mappings.associateBy { it.third }
+ private val byCode: Map = all.associateBy { it.code }
- fun contains(code: Int): Boolean = lookupByHttp.containsKey(code)
+ init {
+ check(byCode.size == all.size) {
+ val dupes = all.groupBy { it.code }.filterValues { it.size > 1 }.keys
+ "Duplicate Status codes detected in Codes registry: $dupes"
+ }
+ }
- /**
- * Converts a status to a compatible HTTP status code.
- * TODO: HttpCode support to be added when kiit-codes gains an HttpCode dependency.
- */
- fun toHttp(status: Status): Pair {
- val entry = lookupByCode[status.code]
- return if (entry != null) Pair(entry.third, status) else Pair(status.code, status)
+ /** Looks up a built-in [Status] by its internal registry code (e.g. 400001). Null if unknown. */
+ fun statusForCode(code: Int): Status? = byCode[code]
+}
+
+/**
+ * Bidirectional conversion between a [Status] and a target protocol's status code (e.g. HTTP).
+ *
+ * Implementations should be exhaustive over [Status]'s categories ([Passed]/[Failed] subtypes),
+ * typically via a `when` with no `else` branch, so a newly added category is caught at compile
+ * time. Individual codes within a category do not need an exhaustive mapping — they can be
+ * handled via a small overrides table layered on top of the category default (see [CodesToHttp]).
+ */
+interface CodeLookup {
+ /** Converts a [Status] to the target protocol's code. */
+ fun toCode(status: Status): Int
+
+ /** Converts a target protocol [code] to a matching [Status], or null if there is no match. */
+ fun toStatus(code: Int): Status?
+}
+
+/**
+ * Default [CodeLookup] implementation mapping [Status] to HTTP status codes.
+ *
+ * Category -> HTTP default:
+ * Succeeded / Filtered / Information -> 200 Pending -> 202
+ * Denied -> 401 Invalid -> 400 Errored -> 500 Unserviceable -> 503
+ *
+ * Individual codes can differ from their category's default via [overrides] (e.g. CREATED -> 201,
+ * NOT_FOUND -> 404). [toStatus] is derived from [toCode] rather than a separately maintained
+ * reverse table, so the two directions can never drift out of sync with each other.
+ *
+ * Clients needing additional/custom codes should compose with [CompositeLookup] rather than
+ * subclassing this type directly — see [CompositeLookup] for why.
+ */
+open class CodesToHttp(
+ private val overrides: Map = DEFAULT_OVERRIDES,
+) : CodeLookup {
+ override fun toCode(status: Status): Int {
+ overrides[status.code]?.let { return it }
+ return when (status) {
+ is Passed.Succeeded -> 200
+ is Passed.Pending -> 202
+ is Passed.Filtered -> 200
+ is Passed.Information -> 200
+ is Failed.Denied -> 401
+ is Failed.Invalid -> 400
+ is Failed.Errored -> 500
+ is Failed.Unserviceable -> 503
+ }
}
/**
- * Converts an HTTP status code to a matching [Status], or null if not found.
+ * Reverse lookup, derived from [toCode] over the built-in [Codes.all] registry. Note this
+ * only finds statuses registered in [Codes] — a caller's own custom [Status] instances that
+ * were never added to that registry won't be found here even if they'd resolve to [code]
+ * via [toCode]. Use [CompositeLookup] if you need custom statuses to also be reverse-lookupable.
*/
- fun toStatus(code: Int): Status? = lookupByCode[code]?.second
+ override fun toStatus(code: Int): Status? = Codes.all.firstOrNull { toCode(it) == code }
- /**
- * Converts a numeric code to its matching [Status].
- */
- fun ofCode(code: Int): Status {
- val entry = lookupByHttp[code]
- return when {
- entry != null -> entry.second
- code in 1..999 -> Passed.Succeeded(SUCCESS.name, code, SUCCESS.message)
- code in 2000..2999 -> Failed.Invalid(INVALID.name, code, INVALID.message)
- code >= 3000 -> Failed.Errored(ERRORED.name, code, ERRORED.message)
- else -> Failed.Errored(UNEXPECTED.name, code, "Unexpected")
- }
+ companion object {
+ val DEFAULT_OVERRIDES: Map =
+ mapOf(
+ Codes.CREATED.code to 201,
+ Codes.HANDLED.code to 204,
+ Codes.CONFIRM.code to 200,
+ Codes.NOT_FOUND.code to 404,
+ Codes.MISSING.code to 400,
+ Codes.FORBIDDEN.code to 403,
+ Codes.CONFLICT.code to 409,
+ Codes.DEPRECATED.code to 426,
+ Codes.UNIMPLEMENTED.code to 501,
+ Codes.UNSUPPORTED.code to 501,
+ Codes.TIMEOUT.code to 408,
+ Codes.RATE_LIMITED.code to 429,
+ Codes.UNEXPECTED.code to 500,
+ )
+ }
+}
+
+/**
+ * Composes a [base] [CodeLookup] with client-supplied [extensions], without modifying or
+ * subclassing the base implementation (composition over inheritance). [extensions] take
+ * precedence over [base] for both directions.
+ *
+ * [extensions] is keyed by the actual [Status] instance (not just its numeric code) so that
+ * [toStatus] can be answered correctly for custom statuses that aren't part of the [Codes.all]
+ * registry — a plain `Map` of code-to-code can't support that, since it never holds
+ * a reference to the actual custom Status object to return.
+ *
+ * ```kotlin
+ * val MY_DOMAIN_CODE = Failed.Errored("PAYMENT_DECLINED", 700123, "Payment declined")
+ * val lookup = CompositeLookup(CodesToHttp(), mapOf(MY_DOMAIN_CODE to 402))
+ * ```
+ */
+class CompositeLookup(
+ private val base: CodeLookup,
+ private val extensions: Map,
+) : CodeLookup {
+ override fun toCode(status: Status): Int = extensions[status] ?: base.toCode(status)
+
+ override fun toStatus(code: Int): Status? {
+ val extended = extensions.entries.firstOrNull { it.value == code }?.key
+ return extended ?: base.toStatus(code)
}
}
diff --git a/src/core/codes/src/commonMain/kotlin/kiit/codes/Status.kt b/src/core/codes/src/commonMain/kotlin/kiit/codes/Status.kt
index cb5dc3bb3..990a4b27f 100644
--- a/src/core/codes/src/commonMain/kotlin/kiit/codes/Status.kt
+++ b/src/core/codes/src/commonMain/kotlin/kiit/codes/Status.kt
@@ -12,172 +12,183 @@
package kiit.codes
/**
- * Platform-agnostic status type describing the outcome of any operation.
+ * Platform-agnostic status type describing the outcome of any operation — a service call,
+ * a background job step, an API request, or a CLI command.
*
* Shape (maps directly to JSON / API error responses):
* { "name": "TOKEN_EXPIRED", "code": 400009, "message": "Session token expired", "success": false }
*
- * Hierarchy:
- * Status = Passed | Failed
- * Passed = Succeeded | Pending | Filtered | Ignored
- * Failed = Denied | Invalid | Errored | Unknown
+ * Hierarchy. Categories are closed/sealed and fixed by design, to enforce a consistent taxonomy
+ * across every consumer. Individual codes *within* a category are open — create new domain codes
+ * by constructing a [Passed] or [Failed] subtype directly (see [Codes] for the built-in set):
+ *
+ * Status = Passed | Failed
+ * Passed = Succeeded | Pending | Filtered | Information
+ * Failed = Denied | Invalid | Errored | Unserviceable
+ *
+ * NOTE: [code] ranges are grouped conceptually the way HTTP groups 2xx/4xx/5xx, but this is a
+ * conceptual similarity only — [code] is NOT a literal HTTP status. Always convert via a
+ * [CodeLookup] implementation such as [CodesToHttp] to obtain a real HTTP status code; never
+ * infer one from the numeric prefix.
*/
-interface Status {
+sealed interface Status {
/**
* Unique domain label, e.g. "TOKEN_EXPIRED", "RATE_LIMITED".
- * Should be SCREAMING_SNAKE_CASE and stable — used as a searchable key in logs.
+ * SCREAMING_SNAKE_CASE, stable — used as a searchable/aggregable key in logs and metrics.
*/
val name: String
/**
- * Numeric code whose defaults align with HTTP status codes (200, 400, 500 ranges).
- * Flexible for non-HTTP runtimes — convert via [Codes.toHttp].
+ * Numeric code. Grouped by category by convention (see [Codes]) but NOT a literal HTTP code.
*/
val code: Int
/**
- * Human-readable description of this status.
- * Must be a constant — never constructed from runtime data.
+ * Human-readable, constant description — never constructed from runtime data. Per-instance /
+ * runtime detail (e.g. "field X was invalid because...") belongs on whatever wraps this
+ * Status (an error/result type one layer up), not here — that keeps [message] safe to use
+ * as an aggregation key across every occurrence of this status.
*/
val message: String
/**
- * Boolean shortcut — true for [Passed.Succeeded] and [Passed.Pending],
- * false for [Passed.Filtered], [Passed.Ignored], and all [Failed] subtypes.
- * Callers that don't need to narrow the sealed type can use this directly.
+ * True for all [Passed] subtypes, false for all [Failed] subtypes. Callers that don't need
+ * to narrow the sealed type can branch on this directly instead of pattern matching.
*/
val success: Boolean
+ /** Returns a copy of this status with an updated [msg], preserving name and code. */
fun copyMessage(msg: String): Status
+ /** Returns a copy of this status with an updated [msg] and [code], preserving name. */
fun copyAll(msg: String, code: Int): Status
companion object {
/**
- * Returns a copy of [defaultStatus] with an updated [msg] and/or [code].
- * Optimised to return the original instance when nothing would change.
+ * Returns a copy of [defaultStatus] with [msg] and/or [code] overridden, or the original
+ * instance unchanged if neither override actually differs from the default. A null or
+ * blank [msg] is treated as "no message override" (so it can be safely omitted by callers
+ * without extra null-handling).
*/
@Suppress("UNCHECKED_CAST")
fun ofCode(msg: String?, code: Int?, defaultStatus: T): T {
- if (code == null && msg == null || msg == "") return defaultStatus
- if (code == defaultStatus.code && msg == null) return defaultStatus
- if (code == defaultStatus.code && msg == defaultStatus.message) return defaultStatus
- return defaultStatus.copyAll(msg ?: defaultStatus.message, code ?: defaultStatus.code) as T
+ val resolvedMsg = msg.takeUnless { it.isNullOrEmpty() } ?: defaultStatus.message
+ val resolvedCode = code ?: defaultStatus.code
+
+ return if (resolvedMsg == defaultStatus.message && resolvedCode == defaultStatus.code) {
+ defaultStatus
+ } else {
+ defaultStatus.copyAll(resolvedMsg, resolvedCode) as T
+ }
}
/**
- * Returns a copy of [status] with an updated [msg] and/or the override from [rawStatus].
- * Optimised to return the original instance when nothing would change.
+ * Resolves a status from an optional [msg] override and an optional [rawStatus] override,
+ * falling back to [status] when neither is supplied. [rawStatus], if present, is used as
+ * the base instead of [status]; [msg], if present, is then applied on top of that base.
*/
@Suppress("UNCHECKED_CAST")
fun ofStatus(msg: String?, rawStatus: T?, status: T): T {
- if (msg == null && rawStatus == null) return status
- if (msg == null && rawStatus != null) return rawStatus
- if (msg != null && rawStatus == null) return status.copyMessage(msg) as T
- if (msg != null && rawStatus != null) return rawStatus.copyMessage(msg) as T
- return status
+ val base = rawStatus ?: status
+ return if (msg == null) base else base.copyMessage(msg) as T
}
- fun toType(status: Status): String {
- return when (status) {
+ /** Returns the lowercase category discriminant for a status, e.g. "denied", "errored". */
+ fun toType(status: Status): String =
+ when (status) {
is Passed.Succeeded -> "succeeded"
is Passed.Pending -> "pending"
is Passed.Filtered -> "filtered"
- is Passed.Ignored -> "ignored"
+ is Passed.Information -> "information"
is Failed.Denied -> "denied"
is Failed.Invalid -> "invalid"
is Failed.Errored -> "errored"
- is Failed.Unknown -> "unknown"
- else -> "unknown"
+ is Failed.Unserviceable -> "unserviceable"
}
- }
}
}
/**
- * Parent sealed type for all non-failure statuses.
- * Subtypes: [Succeeded], [Pending], [Filtered], [Ignored].
+ * Parent sealed type for all non-failure statuses (success = true for every subtype).
+ * Subtypes: [Succeeded], [Pending], [Filtered], [Information].
*/
sealed class Passed : Status {
- /** Operation completed successfully. */
- data class Succeeded(override val name: String, override val code: Int, override val message: String) : Passed() {
- override val success = true
- }
+ final override val success: Boolean get() = true
- /** Operation accepted but not yet fully processed (e.g. queued, waiting). */
- data class Pending(override val name: String, override val code: Int, override val message: String) : Passed() {
- override val success = true
- }
+ /** Operation's primary purpose completed (e.g. a value was created, fetched, updated). */
+ data class Succeeded(override val name: String, override val code: Int, override val message: String) : Passed()
- /** Item was intentionally excluded from processing (e.g. deduplicated, out of scope). */
- data class Filtered(override val name: String, override val code: Int, override val message: String) : Passed() {
- override val success = true
- }
+ /** Operation accepted but not yet fully processed (e.g. queued, waiting, confirmed). */
+ data class Pending(override val name: String, override val code: Int, override val message: String) : Passed()
- /** Item was processed but its result was deliberately discarded or suppressed. */
- data class Ignored(override val name: String, override val code: Int, override val message: String) : Passed() {
- override val success = true
- }
+ /**
+ * Item was excluded from the operation's normal output. Covers both:
+ * - not processed at all (e.g. SKIPPED — screened out before any work happened), and
+ * - processed, then its result was deliberately discarded (e.g. DISCARDED).
+ * The distinction is carried by [name]/[code], not by separate types — see [Codes.SKIPPED]
+ * and [Codes.DISCARDED].
+ */
+ data class Filtered(override val name: String, override val code: Int, override val message: String) : Passed()
- override fun copyAll(msg: String, code: Int): Status {
- return when (this) {
- is Succeeded -> this.copy(code = code, message = msg)
- is Pending -> this.copy(code = code, message = msg)
- is Filtered -> this.copy(code = code, message = msg)
- is Ignored -> this.copy(code = code, message = msg)
+ /**
+ * Informational / metadata response — no primary operation was performed.
+ * E.g. HELP, ABOUT, VERSION output from a CLI command.
+ */
+ data class Information(override val name: String, override val code: Int, override val message: String) : Passed()
+
+ override fun copyAll(msg: String, code: Int): Status =
+ when (this) {
+ is Succeeded -> copy(code = code, message = msg)
+ is Pending -> copy(code = code, message = msg)
+ is Filtered -> copy(code = code, message = msg)
+ is Information -> copy(code = code, message = msg)
}
- }
- override fun copyMessage(msg: String): Status {
- return when (this) {
- is Succeeded -> this.copy(message = msg)
- is Pending -> this.copy(message = msg)
- is Filtered -> this.copy(message = msg)
- is Ignored -> this.copy(message = msg)
+ override fun copyMessage(msg: String): Status =
+ when (this) {
+ is Succeeded -> copy(message = msg)
+ is Pending -> copy(message = msg)
+ is Filtered -> copy(message = msg)
+ is Information -> copy(message = msg)
}
- }
}
/**
- * Parent sealed type for all failure statuses (success = false for all subtypes).
- * Subtypes: [Denied], [Invalid], [Errored], [Unknown].
+ * Parent sealed type for all failure statuses (success = false for every subtype).
+ * Subtypes: [Denied], [Invalid], [Errored], [Unserviceable].
*/
sealed class Failed : Status {
- /** Security / access-control failure — the caller is not permitted to perform this action. */
- data class Denied(override val name: String, override val code: Int, override val message: String) : Failed() {
- override val success = false
- }
+ final override val success: Boolean get() = false
- /** The input data is malformed or fails validation rules. */
- data class Invalid(override val name: String, override val code: Int, override val message: String) : Failed() {
- override val success = false
- }
+ /** Security / access-control failure — the caller is not permitted to perform this action. */
+ data class Denied(override val name: String, override val code: Int, override val message: String) : Failed()
- /** A known business-rule failure — expected, handled, and recoverable. */
- data class Errored(override val name: String, override val code: Int, override val message: String) : Failed() {
- override val success = false
- }
+ /** The request as given cannot be satisfied — malformed input, invalid values, or not found. */
+ data class Invalid(override val name: String, override val code: Int, override val message: String) : Failed()
- /** An unexpected or unhandled failure — equivalent to an uncaught exception path. */
- data class Unknown(override val name: String, override val code: Int, override val message: String) : Failed() {
- override val success = false
- }
+ /** A known, expected business-rule failure — understood and handled by the caller. */
+ data class Errored(override val name: String, override val code: Int, override val message: String) : Failed()
- override fun copyAll(msg: String, code: Int): Status {
- return when (this) {
- is Denied -> this.copy(name = name, code = code, message = msg)
- is Invalid -> this.copy(name = name, code = code, message = msg)
- is Errored -> this.copy(name = name, code = code, message = msg)
- is Unknown -> this.copy(name = name, code = code, message = msg)
+ /**
+ * The request is valid and permitted, but cannot be serviced right now for reasons unrelated
+ * to what was sent — capacity, timeout, an unimplemented/unsupported capability, planned
+ * maintenance, or a genuinely unexpected/unhandled failure (see [Codes.UNEXPECTED]).
+ */
+ data class Unserviceable(override val name: String, override val code: Int, override val message: String) : Failed()
+
+ override fun copyAll(msg: String, code: Int): Status =
+ when (this) {
+ is Denied -> copy(code = code, message = msg)
+ is Invalid -> copy(code = code, message = msg)
+ is Errored -> copy(code = code, message = msg)
+ is Unserviceable -> copy(code = code, message = msg)
}
- }
- override fun copyMessage(msg: String): Status {
- return when (this) {
- is Denied -> this.copy(message = msg)
- is Invalid -> this.copy(message = msg)
- is Errored -> this.copy(message = msg)
- is Unknown -> this.copy(message = msg)
+ override fun copyMessage(msg: String): Status =
+ when (this) {
+ is Denied -> copy(message = msg)
+ is Invalid -> copy(message = msg)
+ is Errored -> copy(message = msg)
+ is Unserviceable -> copy(message = msg)
}
- }
}
diff --git a/src/core/codes/src/commonMain/kotlin/kiit/codes/StatusException.kt b/src/core/codes/src/commonMain/kotlin/kiit/codes/StatusException.kt
index 1cad6b702..c4d9ab1f1 100644
--- a/src/core/codes/src/commonMain/kotlin/kiit/codes/StatusException.kt
+++ b/src/core/codes/src/commonMain/kotlin/kiit/codes/StatusException.kt
@@ -9,11 +9,18 @@ package kiit.codes
* ```kotlin
* throw StatusException(Codes.UNAUTHORIZED)
*
- * try { ... } catch (e: StatusException) {
+ * // with a cause
+ * throw StatusException(Codes.TIMEOUT, cause = ioException)
+ *
+ * try {
+ * // ...
+ * } catch (e: StatusException) {
* when (e.status) {
- * is Failed.Denied -> // handle auth failure
- * is Failed.Errored -> // handle business error
- * else -> // ...
+ * is Failed.Denied -> // handle auth failure
+ * is Failed.Invalid -> // handle bad input
+ * is Failed.Errored -> // handle known business-rule failure
+ * is Failed.Unserviceable -> // handle capacity / timeout / unimplemented / unexpected
+ * is Passed -> // n/a — Passed statuses aren't normally thrown
* }
* }
* ```
diff --git a/src/core/codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt b/src/core/codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt
index 33bd29024..6c8d47f62 100644
--- a/src/core/codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt
+++ b/src/core/codes/src/commonTest/kotlin/kiit/codes/CodesTest.kt
@@ -8,11 +8,11 @@ import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
-class CodesTest {
- // -------------------------------------------------------------------------
- // Spot-check a few built-in code values
- // -------------------------------------------------------------------------
+// =================================================================================================
+// CodesTest — the built-in registry
+// =================================================================================================
+class CodesTest {
@Test
fun successHasCorrectValues() {
assertEquals("SUCCESS", Codes.SUCCESS.name)
@@ -24,116 +24,196 @@ class CodesTest {
@Test
fun deniedHasCorrectValues() {
assertEquals("DENIED", Codes.DENIED.name)
- assertEquals(400005, Codes.DENIED.code)
+ assertEquals(400001, Codes.DENIED.code)
assertFalse(Codes.DENIED.success)
}
@Test
- fun filteredAndIgnoredHaveSuccessTrue() {
- assertTrue(Codes.FILTERED.success)
- assertTrue(Codes.IGNORED.success)
+ fun skippedAndDiscardedHaveDistinctCodesAndSuccessTrue() {
+ // Regression test for the original bug: FILTERED and IGNORED both = 200204.
+ assertTrue(Codes.SKIPPED.success)
+ assertTrue(Codes.DISCARDED.success)
+ assertNotEqualsCode(Codes.SKIPPED, Codes.DISCARDED)
+ }
+
+ @Test
+ fun informationCodesHaveSuccessTrue() {
+ assertTrue(Codes.HELP.success)
+ assertTrue(Codes.ABOUT.success)
+ assertTrue(Codes.VERSION.success)
+ assertTrue(Codes.EXIT.success)
}
+ @Test
+ fun registryHasNoDuplicateCodes() {
+ // Codes' init block already enforces this the moment the object is touched (which every
+ // test in this file does) — this test just names the invariant explicitly for clarity.
+ val codes = Codes.all.map { it.code }
+ assertEquals(codes.size, codes.toSet().size, "Duplicate codes found in Codes.all: $codes")
+ }
+
+ @Test
+ fun statusForCodeReturnsMatchForKnownInternalCode() {
+ val status = Codes.statusForCode(Codes.SUCCESS.code)
+ assertNotNull(status)
+ assertEquals(Codes.SUCCESS.name, status.name)
+ }
+
+ @Test
+ fun statusForCodeReturnsNullForUnknownCode() {
+ assertNull(Codes.statusForCode(999999))
+ }
+
+ private fun assertNotEqualsCode(a: Status, b: Status) {
+ assertTrue(a.code != b.code, "Expected distinct codes but both were ${a.code}")
+ }
+}
+
+// =================================================================================================
+// CodesToHttpTest / CompositeLookupTest — CodeLookup implementations
+// =================================================================================================
+
+class CodesToHttpTest {
+ private val http = CodesToHttp()
+
// -------------------------------------------------------------------------
- // toHttp — converts a Status to an HTTP status code
+ // toCode — category defaults
// -------------------------------------------------------------------------
- @Test fun toHttpSuccess() {
- assertEquals(200, Codes.toHttp(Codes.SUCCESS).first)
+ @Test fun categoryDefaultSucceeded() {
+ assertEquals(200, http.toCode(Codes.SUCCESS))
+ assertEquals(200, http.toCode(Codes.UPDATED))
}
- @Test fun toHttpCreated() {
- assertEquals(201, Codes.toHttp(Codes.CREATED).first)
+ @Test fun categoryDefaultPending() {
+ assertEquals(202, http.toCode(Codes.PENDING))
+ assertEquals(202, http.toCode(Codes.QUEUED))
}
- @Test fun toHttpDenied() {
- assertEquals(401, Codes.toHttp(Codes.DENIED).first)
+ @Test fun categoryDefaultFiltered() {
+ assertEquals(200, http.toCode(Codes.SKIPPED))
+ assertEquals(200, http.toCode(Codes.DISCARDED))
}
- @Test fun toHttpNotFound() {
- assertEquals(404, Codes.toHttp(Codes.NOT_FOUND).first)
+ @Test fun categoryDefaultInformation() {
+ assertEquals(200, http.toCode(Codes.ABOUT))
}
- @Test fun toHttpUnexpected() {
- assertEquals(500, Codes.toHttp(Codes.UNEXPECTED).first)
+ @Test fun categoryDefaultDenied() {
+ assertEquals(401, http.toCode(Codes.DENIED))
+ assertEquals(401, http.toCode(Codes.UNAUTHENTICATED))
}
- @Test
- fun toHttpPreservesOriginalStatus() {
- val (_, status) = Codes.toHttp(Codes.SUCCESS)
- assertSame(Codes.SUCCESS, status)
+ @Test fun categoryDefaultInvalid() {
+ assertEquals(400, http.toCode(Codes.BAD_REQUEST))
+ assertEquals(400, http.toCode(Codes.INVALID))
}
- @Test
- fun toHttpFallsBackToStatusCodeForUnknownStatus() {
- val custom = Failed.Errored("CUSTOM", 599, "Custom error")
- val (httpCode, _) = Codes.toHttp(custom)
- assertEquals(599, httpCode)
+ @Test fun categoryDefaultErrored() {
+ assertEquals(500, http.toCode(Codes.ERRORED))
+ }
+
+ @Test fun categoryDefaultUnserviceable() {
+ assertEquals(503, http.toCode(Codes.UNREACHABLE))
+ assertEquals(503, http.toCode(Codes.UNDER_MAINTENANCE))
}
// -------------------------------------------------------------------------
- // toStatus — reverse lookup by kiit numeric code
+ // toCode — per-code overrides
// -------------------------------------------------------------------------
- @Test
- fun toStatusReturnsMatchForKnownCode() {
- val status = Codes.toStatus(Codes.SUCCESS.code)
- assertNotNull(status)
- assertEquals(Codes.SUCCESS.name, status.name)
+ @Test fun overrideCreated() {
+ assertEquals(201, http.toCode(Codes.CREATED))
+ }
+
+ @Test fun overrideHandled() {
+ assertEquals(204, http.toCode(Codes.HANDLED))
+ }
+
+ @Test fun overrideNotFound() {
+ assertEquals(404, http.toCode(Codes.NOT_FOUND))
+ }
+
+ @Test fun overrideForbidden() {
+ assertEquals(403, http.toCode(Codes.FORBIDDEN))
+ }
+
+ @Test fun overrideConflict() {
+ assertEquals(409, http.toCode(Codes.CONFLICT))
+ }
+
+ @Test fun overrideTimeout() {
+ assertEquals(408, http.toCode(Codes.TIMEOUT))
}
+ @Test fun overrideRateLimited() {
+ assertEquals(429, http.toCode(Codes.RATE_LIMITED))
+ }
+
+ @Test fun overrideUnexpected() {
+ assertEquals(500, http.toCode(Codes.UNEXPECTED))
+ }
+
+ /**
+ * A custom, unregistered status still resolves via its category's default rather than a
+ * guessed/literal fallback — this replaces the old (buggy) behavior of returning the
+ * status's own internal code as if it were a valid HTTP code.
+ */
@Test
- fun toStatusReturnsNullForUnknownCode() {
- assertNull(Codes.toStatus(99999))
+ fun toCodeFallsBackToCategoryDefaultForCustomStatus() {
+ val custom = Failed.Errored("CUSTOM", 700123, "Custom error")
+ assertEquals(500, http.toCode(custom)) // Errored's category default, not 700123
}
// -------------------------------------------------------------------------
- // ofCode — maps an HTTP code to a Status, with range-based fallbacks
+ // toStatus — reverse lookup, derived from toCode
// -------------------------------------------------------------------------
@Test
- fun ofCodeReturnsKnownStatusForRegisteredHttpCode() {
- // 201 is unique in the table so the name is deterministic
- assertEquals(Codes.CREATED.name, Codes.ofCode(201).name)
- // 200/500 have multiple mappings — just verify success flag and type
- assertTrue(Codes.ofCode(200).success)
- assertFalse(Codes.ofCode(500).success)
- assertFalse(Codes.ofCode(404).success)
+ fun toStatusFindsRegisteredStatusForUniqueHttpCode() {
+ val status = http.toStatus(201)
+ assertNotNull(status)
+ assertEquals(Codes.CREATED.name, status.name)
}
@Test
- fun ofCodeFallsBackToSucceededForRange1To999() {
- val status = Codes.ofCode(42)
- assertTrue(status is Passed.Succeeded)
- assertEquals(42, status.code)
+ fun toStatusReturnsNullForUnrecognizedHttpCode() {
+ // No guessed range fallback — an unrecognized code is honestly null, caller decides the default.
+ assertNull(http.toStatus(999))
}
@Test
- fun ofCodeFallsBackToInvalidForRange2000To2999() {
- val status = Codes.ofCode(2001)
- assertTrue(status is Failed.Invalid)
- assertEquals(2001, status.code)
+ fun toStatusRoundTripsForOverriddenCode() {
+ val status = http.toStatus(404)
+ assertNotNull(status)
+ assertEquals(Codes.NOT_FOUND.name, status.name)
}
+}
+
+class CompositeLookupTest {
+ private val customCode = Failed.Errored("PAYMENT_DECLINED", 700123, "Payment declined")
+ private val lookup = CompositeLookup(base = CodesToHttp(), extensions = mapOf(customCode to 402))
@Test
- fun ofCodeFallsBackToErroredForCodeAbove3000() {
- val status = Codes.ofCode(9999)
- assertTrue(status is Failed.Errored)
- assertEquals(9999, status.code)
+ fun extensionTakesPrecedenceForToCode() {
+ assertEquals(402, lookup.toCode(customCode))
}
- // -------------------------------------------------------------------------
- // contains — checks whether an HTTP code is in the mapping table
- // -------------------------------------------------------------------------
+ @Test
+ fun extensionSupportsReverseLookup() {
+ val status = lookup.toStatus(402)
+ assertNotNull(status)
+ assertSame(customCode, status)
+ }
@Test
- fun containsReturnsTrueForKnownHttpCode() {
- assertTrue(Codes.contains(200))
- assertTrue(Codes.contains(404))
+ fun fallsBackToBaseForRegisteredCodes() {
+ assertEquals(401, lookup.toCode(Codes.DENIED))
+ assertSame(Codes.CREATED, lookup.toStatus(201))
}
@Test
- fun containsReturnsFalseForUnknownCode() {
- assertFalse(Codes.contains(99999))
+ fun fallsBackToBaseNullWhenNeitherKnows() {
+ assertNull(lookup.toStatus(999))
}
}
diff --git a/src/core/codes/src/commonTest/kotlin/kiit/codes/Example_Codes.kt b/src/core/codes/src/commonTest/kotlin/kiit/codes/Example_Codes.kt
deleted file mode 100644
index ebd4fb155..000000000
--- a/src/core/codes/src/commonTest/kotlin/kiit/codes/Example_Codes.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package kiit.codes
-
-//
-// import kiit.codes.Code
-//
-
-//
-//
diff --git a/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusExceptionTest.kt b/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusExceptionTest.kt
index bd46598c5..385c8c608 100644
--- a/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusExceptionTest.kt
+++ b/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusExceptionTest.kt
@@ -5,6 +5,11 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+// =================================================================================================
+// StatusExceptionTest
+// =================================================================================================
class StatusExceptionTest {
@Test
@@ -47,9 +52,16 @@ class StatusExceptionTest {
@Test
fun worksWithCustomStatus() {
- val custom = Failed.Errored("RATE_LIMITED", 500099, "Rate limited")
+ val custom = Failed.Errored("RATE_LIMITED", 500199, "Rate limited")
val ex = StatusException(custom)
assertEquals("Rate limited", ex.message)
assertSame(custom, ex.status)
}
+
+ @Test
+ fun worksWithUnserviceableStatus() {
+ val ex = StatusException(Codes.UNREACHABLE)
+ assertEquals(Codes.UNREACHABLE.message, ex.message)
+ assertTrue(ex.status is Failed.Unserviceable)
+ }
}
diff --git a/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt b/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt
index 76d8973b2..24fb5a9d1 100644
--- a/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt
+++ b/src/core/codes/src/commonTest/kotlin/kiit/codes/StatusTest.kt
@@ -7,9 +7,13 @@ import kotlin.test.assertNotSame
import kotlin.test.assertSame
import kotlin.test.assertTrue
+// =================================================================================================
+// StatusTest — Passed/Failed subtypes, copy helpers, ofCode/ofStatus companion functions
+// =================================================================================================
+
class StatusTest {
// -------------------------------------------------------------------------
- // success flag — Passed subtypes
+ // success flag — Passed subtypes (hoisted onto Passed itself; see Passed.success)
// -------------------------------------------------------------------------
@Test fun succeededHasSuccessTrue() {
@@ -17,35 +21,35 @@ class StatusTest {
}
@Test fun pendingHasSuccessTrue() {
- assertTrue(Passed.Pending("P", 200008, "P").success)
+ assertTrue(Passed.Pending("P", 200101, "P").success)
}
@Test fun filteredHasSuccessTrue() {
- assertTrue(Passed.Filtered("F", 200204, "F").success)
+ assertTrue(Passed.Filtered("F", 200201, "F").success)
}
- @Test fun ignoredHasSuccessTrue() {
- assertTrue(Passed.Ignored("I", 200204, "I").success)
+ @Test fun informationHasSuccessTrue() {
+ assertTrue(Passed.Information("I", 200301, "I").success)
}
// -------------------------------------------------------------------------
- // success flag — Failed subtypes
+ // success flag — Failed subtypes (hoisted onto Failed itself; see Failed.success)
// -------------------------------------------------------------------------
@Test fun deniedHasSuccessFalse() {
- assertFalse(Failed.Denied("D", 400005, "D").success)
+ assertFalse(Failed.Denied("D", 400001, "D").success)
}
@Test fun invalidHasSuccessFalse() {
- assertFalse(Failed.Invalid("I", 400003, "I").success)
+ assertFalse(Failed.Invalid("I", 400102, "I").success)
}
@Test fun erroredHasSuccessFalse() {
- assertFalse(Failed.Errored("E", 500007, "E").success)
+ assertFalse(Failed.Errored("E", 500005, "E").success)
}
- @Test fun unknownHasSuccessFalse() {
- assertFalse(Failed.Unknown("U", 500008, "U").success)
+ @Test fun unserviceableHasSuccessFalse() {
+ assertFalse(Failed.Unserviceable("U", 500107, "U").success)
}
// -------------------------------------------------------------------------
@@ -64,7 +68,7 @@ class StatusTest {
@Test
fun copyMessageOnPending() {
- val s = Passed.Pending("PENDING", 200008, "Pending")
+ val s = Passed.Pending("PENDING", 200101, "Pending")
val copy = s.copyMessage("Custom")
assertEquals("Custom", copy.message)
assertEquals(s.code, copy.code)
@@ -72,21 +76,22 @@ class StatusTest {
@Test
fun copyMessageOnFiltered() {
- val s = Passed.Filtered("FILTERED", 200204, "Filtered")
+ val s = Passed.Filtered("SKIPPED", 200201, "Skipped")
val copy = s.copyMessage("Custom")
assertEquals("Custom", copy.message)
}
@Test
- fun copyMessageOnIgnored() {
- val s = Passed.Ignored("IGNORED", 200204, "Ignored")
+ fun copyMessageOnInformation() {
+ val s = Passed.Information("HELP", 200301, "Help")
val copy = s.copyMessage("Custom")
assertEquals("Custom", copy.message)
+ assertTrue(copy.success)
}
@Test
fun copyMessageOnDenied() {
- val s = Failed.Denied("DENIED", 400005, "Denied")
+ val s = Failed.Denied("DENIED", 400001, "Denied")
val copy = s.copyMessage("Custom")
assertEquals("Custom", copy.message)
assertFalse(copy.success)
@@ -94,23 +99,24 @@ class StatusTest {
@Test
fun copyMessageOnInvalid() {
- val s = Failed.Invalid("INVALID", 400003, "Invalid")
+ val s = Failed.Invalid("INVALID", 400102, "Invalid")
val copy = s.copyMessage("Custom")
assertEquals("Custom", copy.message)
}
@Test
fun copyMessageOnErrored() {
- val s = Failed.Errored("ERRORED", 500007, "Errored")
+ val s = Failed.Errored("ERRORED", 500005, "Errored")
val copy = s.copyMessage("Custom")
assertEquals("Custom", copy.message)
}
@Test
- fun copyMessageOnUnknown() {
- val s = Failed.Unknown("UNKNOWN", 500008, "Unknown")
+ fun copyMessageOnUnserviceable() {
+ val s = Failed.Unserviceable("UNEXPECTED", 500107, "Unexpected")
val copy = s.copyMessage("Custom")
assertEquals("Custom", copy.message)
+ assertFalse(copy.success)
}
// -------------------------------------------------------------------------
@@ -128,15 +134,24 @@ class StatusTest {
@Test
fun copyAllOnDenied() {
- val s = Failed.Denied("DENIED", 400005, "Denied")
+ val s = Failed.Denied("DENIED", 400001, "Denied")
val copy = s.copyAll("Custom", 403)
assertEquals("Custom", copy.message)
assertEquals(403, copy.code)
assertEquals(s.name, copy.name)
}
+ @Test
+ fun copyAllOnUnserviceable() {
+ val s = Failed.Unserviceable("TIMEOUT", 500103, "Timeout")
+ val copy = s.copyAll("Custom", 408)
+ assertEquals("Custom", copy.message)
+ assertEquals(408, copy.code)
+ assertEquals(s.name, copy.name)
+ }
+
// -------------------------------------------------------------------------
- // toType — returns lowercase discriminant string
+ // toType — returns lowercase category discriminant, exhaustive over all 8 subtypes
// -------------------------------------------------------------------------
@Test
@@ -144,15 +159,15 @@ class StatusTest {
assertEquals("succeeded", Status.toType(Passed.Succeeded("S", 1, "S")))
assertEquals("pending", Status.toType(Passed.Pending("P", 2, "P")))
assertEquals("filtered", Status.toType(Passed.Filtered("F", 3, "F")))
- assertEquals("ignored", Status.toType(Passed.Ignored("I", 4, "I")))
+ assertEquals("information", Status.toType(Passed.Information("N", 4, "N")))
assertEquals("denied", Status.toType(Failed.Denied("D", 5, "D")))
assertEquals("invalid", Status.toType(Failed.Invalid("I", 6, "I")))
assertEquals("errored", Status.toType(Failed.Errored("E", 7, "E")))
- assertEquals("unknown", Status.toType(Failed.Unknown("U", 8, "U")))
+ assertEquals("unserviceable", Status.toType(Failed.Unserviceable("U", 8, "U")))
}
// -------------------------------------------------------------------------
- // ofCode — returns same instance when nothing changes (optimisation)
+ // ofCode — returns same instance when nothing changes; applies overrides independently
// -------------------------------------------------------------------------
@Test
@@ -197,6 +212,21 @@ class StatusTest {
assertEquals(default.message, result.message)
}
+ /**
+ * Regression test for the original operator-precedence bug:
+ * `if (code == null && msg == null || msg == "") return defaultStatus`
+ * bound as `(code == null && msg == null) || (msg == "")`, so any call with msg == ""
+ * returned defaultStatus regardless of a supplied code, silently dropping the override.
+ */
+ @Test
+ fun ofCodeAppliesCodeOverrideEvenWhenMsgIsEmptyString() {
+ val default = Codes.DENIED
+ val result = Status.ofCode("", 401099, default)
+ assertNotSame(default, result)
+ assertEquals(401099, result.code)
+ assertEquals(default.message, result.message)
+ }
+
// -------------------------------------------------------------------------
// ofStatus — selects correct instance based on msg / rawStatus nullability
// -------------------------------------------------------------------------