
### Bash Operations API

Operations authors interact with the shellops runtime through `@`-prefixed functions. The `@` namespace is unclaimed in bash (no collisions with builtins, commands, or system functions). Bash function names support `@`, `.`, `?`, `[`, `]` — all legal since bash 3.2.

All inner functions and outer functions use the `name() {` syntax (not `function name {`). This is required for consistent `declare -F` behavior during `load_ops` contract discovery.

### Defining Operations

An operation is a single outer function using the `@operation.` prefix. The outer function body contains metadata calls and inner contract functions. `load_ops` calls the outer function once at load time to extract metadata and register inner functions.

```bash
@operation.git.sign() {
    @in.string key
    @out.string signature
    @note "Requires GPG key on the local machine"

    @do() {
        local key=$(@in key)
        local sig=$(gpg --sign --armor -u "$key")

        @out.string signature "$sig"
        @return.out
    }

    @undo() {
        git reset --soft HEAD~1
    }

    @is-done() {
        git log -1 --format='%G?' | grep -q 'G'
    }
}
```

**Inner contract functions** (`@`-prefixed, discovered by `load_ops`):

| Function | Role | Required |
| --- | --- | --- |
| `@do` | Main implementation | yes |
| `@undo` | Rollback for `with` blocks | no |
| `@is-done` | Idempotency check — skip if returns 0 | no |

**Private helpers** (`_`-prefixed, ignored by `load_ops`):

```bash
@operation.git.sign() {
    @in.string key

    _find_key() {
        # private helper — not part of the contract
    }

    @do() {
        local key=$(_find_key "$(@in key)")
        # ...
    }
}
```

**How `load_ops` works:**

1. Source the `.op.sh` file (defines outer functions)
2. For each `@operation.*` / `@type.*` function, snapshot defined functions via `compgen -A function`
3. Call the outer function — metadata calls register, inner functions land in global scope
4. Diff the function list. `@`-prefixed = contract functions, `_`-prefixed = private helpers
5. Remap inner functions to namespaced dispatch names (avoids collisions across ops)
6. Unset the raw inner names
7. Build registry JSON entry from metadata + contract mapping

### The `@verb.type` Pattern

Type information flows through function name suffixes rather than string arguments or subshell composition. The same `@verb.type` shape is used for input declaration plus output declaration/construction:

```bash
@in.semver   version          # declare a semver input
@out.semver  version          # declare a semver output field
@out.semver  version "1.2.3"  # set the field to a validated value
```

**Type modifiers** are suffixed to the type name:

| Suffix | Meaning | Example |
| --- | --- | --- |
| *(none)* | Required scalar | `@in.string name` |
| `?` | Optional | `@in.string? description` |
| `[]` | Array | `@out.string[] tags` |
| `[]?` | Optional array | `@out.string[]? tags` |

These are legal bash function name characters (since bash 3.2) and are parsed by the runtime via `command_not_found_handle` — no need to pre-generate every type/modifier combination.

### Input Declaration

`@in.type` declares a named, typed input. Called in the outer function body at load time.

```bash
@in.string     name
@in.int        count
@in.semver     version
@in.string?    description    # optional
@in.int[]      ports          # array
```

**`@in` has two distinct dispatch paths** based on the function name:

- **Registration** (load time): `@in.type name` — the `.type` suffix routes through `command_not_found_handle` to register a typed input.
- **Retrieval** (runtime): `$(@in name)` — bare `@in` with one argument retrieves the value by name. This is a direct function call, not a `command_not_found_handle` dispatch.

### Output Declaration & Construction

`@out.type` is phase-sensitive:

- **Load time** (outer function body): `@out.type field` declares a named, typed output field.
- **Runtime** (inside `@do`): `@out.type field value` validates and accumulates a named field into the output buffer.

Dotted field names declare nested structure in both cases.

```bash
@out.string      endpoint
@out.string      status
@out.string      config.image
@out.semver      config.tag
@out.int         config.port
@out.string[]    config.tags
@out.string?     config.description
```

At runtime, field names mirror the `@out` declarations above.

**Scalar fields:**

```bash
@out.string   endpoint "https://api.svc:8443"
@out.string   status   "deployed"
@out.string   config.image "api-server"
@out.semver   config.tag   "2.1.0"
@out.int      config.port  8443
```

Dotted field names build nested JSON structure automatically. In the **authoring layer** (bash runtime), `@out` delegates to `json.api.sh` for correct escaping and nesting. In **emitted scripts** (POSIX output), typed fields are flattened to shell variables at compile time; opaque JSON uses `__json_get`/`__json_set` backed by POSIX awk (see [architecture.md](./architecture.md)).

**Zero-arg construction** — omitting the value triggers the type's `@default`:

```bash
@out.port  port           # → 8080 (from @port's @default)
@out.time  timestamp      # → current time (from @time's @default)
@out.int   retry-count    # → 0 (built-in default)
```

**Arrays** — variadic elements, each validated against the type:

```bash
@out.string[] tags "v1" "v2" "v3"
@out.port[]   ports 8080 8443 9090
```

This works with structured types too. Each element goes through `@is`, so validate-and-transform types produce an array of structured objects:

```bash
@out.endpoint[] nodes "host1:443" "host2:443" "host3:443"
# → [{"host":"host1","port":443}, {"host":"host2","port":443}, ...]
```

**Nested objects** — use a subshell to scope construction:

```bash
@out.object config "$({
    @out.email email "jerrod@kernelle-soft.com"
    @out.port  port 3000
    @out.time  timestamp
    @return.out
})"
```

The subshell builds its own output buffer independently. `@return.out` inside the subshell echoes the completed JSON. `@out.object` receives that JSON string and sets it at the field path — it does not merge with or inherit from the parent's buffer. This avoids the footgun of parent state leaking into nested object construction.

**Typed objects** — `@out.custom-type` with a subshell validates the entire structure through the type's `@is`:

```bash
@out.database-config db "$({
    @out.endpoint primary  "db-primary.internal:5432"
    @out.endpoint fallback "db-replica.internal:5432"
    @out.port[]   healthchecks 8080 9090
    @return.out
})"
```

`@out.object` is untyped — it sets the JSON blob as-is. `@out.custom-type` passes the blob through the type's `@is` for validation before setting it. The subshell construction pattern is identical for both.

### Output Reading

`@result.get` reads back from the result buffer. `@result` (no `.get`) returns the full buffer.

```bash
@out.string name "foo"
@out.string greeting "hello $(@result.get name)"

local full=$(@result)          # full JSON object
local subset=$(@result.get config)  # nested sub-object
```

### Returning

Three explicit forms. No implicit behavior — each says exactly what it does.

| Call | Effect |
| --- | --- |
| `@return` | Return void — op produces no output |
| `@return.out` | Return the full output buffer (no subshell fork) |

The subshell equivalents also work when composition is needed:

```bash
@return "$(@result)"            # equivalent to @return.out
@return "$(@result.get config)" # return a subset when needed
```

### Errors

`@throw` signals a catchable error. Three forms:

```bash
# Simple: key + message (code defaults to e000)
@throw "version-exists" "version already exists"

# Rethrow: preserves original error, appends to stack
@throw "$error"

# Structured: full @error construction
@throw @error {
    key: "provision-failed"
    code: e001
    message: "failed to provision $dc"
    help: "Verify the datacenter endpoint is reachable and retry"
    context: "$({
        @out.string datacenter "$dc"
        @out.int attempt "$attempt"
        @return.out
    })"
}
```

`@throw` clears the result buffer, constructs an `@error` object, sets error context, and exits non-zero. Caught by DSL-level `try`/`catch`. Distinct from `@fatal` which hard-exits and is not catchable.

The `@error` type is a built-in structural type with a fixed schema:

| Field | Type | Required | Constraint |
|---|---|---|---|
| `code` | `<c>###` | auto | `e` = user, `E` = engine. `e000` default for user throws. |
| `key` | string | yes | <32 chars, no spaces, kebab-case |
| `message` | string | yes | what went wrong |
| `help` | string | structured | what to do about it — required in `@error` blocks |
| `origin` | string | auto | where it originated (`custom` for all user errors) |
| `context` | object | no | arbitrary key-value pairs |
| `location` | location | auto | file:line:col, filled at throw site |
| `stack` | array | auto | appended on rethrow |

### Metadata Calls

Called inside the outer function body. Execute at load time during `load_ops`.

**DSL integration:**

| Call | Purpose | Example |
| --- | --- | --- |
| `@infix [precedence]` | Binary operator between expressions | `@infix 3` |
| `@symbol "token"` | Operator token for infix | `@symbol '=='` |
| `@block` | Op wraps a `{ body }` of steps | |
| `@declarative` | Op wraps a `{ body }` of key-value entries | |
| `@alias "name"` | Alternative name in DSL | `@alias "and"` |
| `@variadic` | Unbounded positional args | |
| `@spread` | Accepts forwarded args via `...` | |
| `@lazy` | Deferred argument evaluation | |

Infix precedence: `or` (1) < `and` (2) < `comparison` (3).

**Pipeline markers:**

| Call | Purpose |
| --- | --- |
| `@remote` | Op talks to something offsite |
| `@dangerous` | Op can cause irreversible damage |
| `@deprecated` | Op is slated for removal |

Marker calls on ops propagate transitively — any DSL block calling a `@remote` op is transitively remote, same for `@dangerous`. `@remote`/`@dangerous` use the skip mechanism: without `--allow-remote` or `--allow-dangerous`, the op resolves as `__SKIPPED__`. `@deprecated` emits a compile warning at every call site. See also doc-comment `@deprecated` annotations in `.ops` files.

**Lifecycle & dependencies:**

| Call | Purpose | Example |
| --- | --- | --- |
| `@requires "tool" [config]` | Tool dependency | `@requires "shellcheck" '{"version":"0.9.0"}'` |
| `@parent "op"` | Op tree hierarchy | `@parent "install"` |
| `@before "target"` | Auto-run before target op | `@before "git.commit"` |
| `@after "target"` | Auto-run after target op | `@after "git.commit"` |

**Documentation:**

| Call | Purpose |
| --- | --- |
| `@note "text"` | Note for `--help` Notes: section (repeatable) |

### Full Operation Examples

```bash
# Infix comparison op — scalar bool return
@operation.condition.eq() {
    @infix 3
    @symbol '=='
    @in.string left
    @in.string right
    @out.bool result

    @do() {
        local left=$(@in left)
        local right=$(@in right)
        if [[ "$left" = "$right" ]]; then
            @out.bool result true
        else
            @out.bool result false
        fi
        @return.out
    }
}

# Multi-output op with nested object via subshell, markers, and undo
@operation.deploy.service() {
    @in.string image
    @in.string tag
    @in.int    port

    @out.string endpoint
    @out.string status
    @out.string config.image
    @out.string config.tag
    @out.int    config.port

    @remote
    @dangerous
    @note "Deploys to the active cluster context"

    @do() {
        local image=$(@in image)
        local tag=$(@in tag)
        local port=$(@in port)

        @out.string endpoint "https://${image}.svc:${port}"
        @out.string status   "deployed"
        @out.object config "$({
            @out.string image "$image"
            @out.string tag   "$tag"
            @out.int    port  "$port"
            @return.out
        })"
        @return.out
    }

    @undo() {
        kubectl rollback deployment "$(@in image)"
    }
}

# Op using custom types and arrays
@operation.release.tag() {
    @in.semver      version
    @in.datacenter  dc

    @out.semver      version
    @out.datacenter  dc
    @out.string[]    targets

    @do() {
        local version=$(@in version)
        local dc=$(@in dc)

        @out.semver     version "$version"
        @out.datacenter dc      "$dc"
        @out.string[]   targets "${dc}-primary" "${dc}-replica"
        @return.out
    }
}

# Simple op with idempotency check
@operation.install.shellcheck() {
    @requires "curl"
    @parent "install"

    @do() {
        curl -sSL "$url" -o shellcheck.tar.gz
        tar xf shellcheck.tar.gz
    }

    @is-done() {
        command -v shellcheck > /dev/null
    }
}
```

### Data & Control Functions

Used inside `@do` / `@undo` / `@is-done` implementations.

| Function | Purpose |
| --- | --- |
| `@out.type field value` | Validate value against type, accumulate into the output buffer |
| `@out.type field` | Zero-arg: set field to the type's `@default` value |
| `@out.type[] field vals...` | Variadic: validate each element, accumulate as array |
| `@out.object field "$({...})"` | Set field to an untyped JSON object from a scoped subshell |
| `@out.custom-type field "$({...})"` | Set field to a typed JSON object (validated via `@is`) from a scoped subshell |
| `@result.get [path]` | Read a field from the result buffer |
| `@result` | Read the full result buffer |
| `@return` | Return void |
| `@return.out` | Return the full output buffer |
| `@throw "key" "message"` | Construct `@error` with key + message (code `e000`), exit non-zero |
| `@throw "$error"` | Rethrow caught error, appending to stack |
| `@throw @error { ... }` | Construct `@error` with full schema, exit non-zero |
| `@export` | Produce a cross-group value. Writes to `__OPS_EXPORTS[VAR]`, or to FIFO in parallel branches. |
| `@defer` | Push a cleanup command onto the LIFO stack. Drained at group completion. |

`@export` exists as a distinct primitive because parallel branches (`parallel.all`/`parallel.any`) run in subshells that can't write to the parent's associative arrays. `@export` writes to the FIFO when inside a parallel context, and directly to `__OPS_EXPORTS` otherwise.

**Banned in `.op.sh` files:**

| Banned | Replacement | Why |
| --- | --- | --- |
| `echo` | `@out.type` / `@return` for data; `@print` / `@info` / etc. for human output | Stdout is reserved for the return channel. |
| `return N` (N > 0) | `@throw` | Non-zero exits must carry error context. |
| `return` / `return 0` | `@return` / `@return.out` | All exits go through `@return` for consistency. |

Enforcement: static analysis lint pass on `.op.sh` files. The runtime shim redirects stdout for op function bodies so stray subprocess output doesn't leak into the return channel.

**Logging** (all output to stderr):

| Function | Prefix | Behavior |
| --- | --- | --- |
| `@print` | *(none)* | Raw output, no formatting |
| `@debug` | `[DBUG]` | |
| `@info` | `[INFO]` | |
| `@warn` | `[WARN]` | |
| `@error` | `[EROR]` | |
| `@fatal` | `[FATL]` | Logs then hard-exits |

### Defining Types

Types use the `@type.` prefix. The outer function contains contract functions that define the type's behavior.

```bash
@type.port() {
    @is() {
        local value="$1"
        @int.is "$value"
        if (( value < 1 || value > 65535 )); then
            @throw "port-out-of-range" "port must be between 1 and 65535"
        fi
        @return "$value"
    }

    @default() {
        @return 8080
    }
}
```

**Type contract functions:**

| Function | Role | Required |
| --- | --- | --- |
| `@is` | Constructor/validator — validates input, optionally transforms, returns typed value | yes |
| `@default` | Zero value — returned when the type is constructed with no argument | no |

`load_ops` calls the outer function, discovers contract functions, and generates:
- `@port` — constructor. With args: calls `@is`. No args: calls `@default` (or throws if no `@default`).
- `@port.is` — validator (calls `@is`, discards output, surfaces throws)
- `@in.port`, `@out.port` — registered via `command_not_found_handle` for the `@verb.type` pattern

**Default values** work like zero values in typed languages. Zero-arg construction returns the default:

```bash
$(@port)         # → 8080 (from @default)
$(@port 3000)    # → 3000 (from @is)
```

Built-in types have implicit defaults: `@int` → `0`, `@string` → `""`, `@bool` → `false`, `@array` → `[]`.

**Regex types** use `@regex` — no outer function needed:

```bash
@regex "^v[0-9]+\.[0-9]+\.[0-9]+$" "semver-tag"
# generates @semver-tag, @semver-tag.is, and @verb.semver-tag variants
```

DSL inline regex types (`@/pattern/ VAR`) compile to anonymous `@regex` calls with generated names.

### Built-in Types

All built-in types follow the `@verb.type` pattern and expose `.is()` validators.

**Scalar types:**

| Type | Validates | Example |
| --- | --- | --- |
| `string` | passthrough | `@in.string name` |
| `int` | integer | `@in.int count` |
| `number` | numeric | `@in.number threshold` |
| `bool` | `true` / `false` | `@out.bool success` |

**Domain types:**

| Type | Validates |
| --- | --- |
| `email` | email format |
| `url` | URL format |
| `semver` / `version` | semver format |
| `duration` | duration format (ms/s/m/h/d) |
| `secret` | masks in logging, protected from interpolation |
| `json` | valid JSON |
| `glob` | passthrough (no validation) |

**Path types:**

| Type | Validates |
| --- | --- |
| `file-exists` | file exists (`-f`) |
| `dir-exists` | directory exists (`-d`) |
| `file-opt` | passthrough (may not exist) |
| `dir-opt` | passthrough (may not exist) |
| `command` | command on `$PATH` (`command -v`) |

**Enum types** (generated from `@enum` declarations):

| Type | Validates |
| --- | --- |
| `enum.name` | value is one of the declared variants |

**Validation compositors** — every type exposes `.is()` for pass-or-throw validation:

| Function | Purpose |
| --- | --- |
| `@int.is val` | throws if not a valid integer |
| `@semver.is val` | throws if not valid semver |
| `@port.is val` | throws if not a valid port (custom type) |
| `@enum.datacenter.is val` | throws if not in variant set |

### Dynamic Type Dispatch

The runtime uses `command_not_found_handle` to dispatch `@verb.type` calls dynamically. When bash encounters `@in.port`, `@out.semver[]?`, or `@out.datacenter`, the handler:

1. Splits the function name on `.`
2. Identifies the verb (`in` or `out`)
3. Strips type modifiers: trailing `?` (optional), trailing `[]` (array)
4. Looks up the base type name in the type registry
5. Dispatches to the appropriate handler with type, modifiers, and arguments

This means registering a custom type automatically makes it available in all `@verb.type` positions — no separate registration step per verb.

### Registry JSON

`load_ops` builds the registry from structural contracts. The engine reads it via `--registry`.

```json
{
  "ops": {
    "git.sign": {
      "params": { "key": { "type": "string" } },
      "returns": { "signature": { "type": "string" } },
      "contract": { "do": true, "undo": true, "is_done": true },
      "notes": ["Requires GPG key on the local machine"]
    },
    "condition.eq": {
      "params": {
        "left": { "type": "string" },
        "right": { "type": "string" }
      },
      "returns": { "result": { "type": "bool" } },
      "infix": true,
      "precedence": 3,
      "symbol": "=="
    },
    "release.tag": {
      "params": {
        "version": { "type": "semver" },
        "dc": { "type": "datacenter" }
      },
      "returns": {
        "version": { "type": "semver" },
        "dc": { "type": "datacenter" },
        "targets": { "type": "string", "array": true }
      },
      "contract": { "do": true, "is_done": true }
    }
  },
  "types": {
    "port": {},
    "datacenter": {},
    "semver-tag": { "regex": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" }
  }
}
```

### Migration

| Old | New |
| --- | --- |
| `register_op` / `@operation --name ...` | `@operation.name() { @metadata; @do() { ... } }` |
| `--undo FN` flag | `@undo() { ... }` inner function |
| `--is-done FN` flag | `@is-done() { ... }` inner function |
| `@param "name:type"` | `@in.type name` |
| `@output "name:type"` (schema) | `@out.type name` |
| `@output key value` (builder) | `@out.type key value` |
| `@return` (implicit flush) | `@return.out` (explicit) or `@return` (void) |
| `$(@object key $(@string val))` | `@out.string key val` |
| `@type @fn` call | `@type.name() { @is() { ... } }` |
| `echo` in ops | `@return` / `@out.type` for data; `@print`/`@info`/etc. for human output |
| `return N` in ops | `@throw "key" "message"` for errors, `@return` for success |
