# Starlark Reference

Armature extensions can include Starlark scripts for server-side logic.
Starlark is a Python-like language designed for configuration and
embedding — see the [official spec](https://github.com/google/starlark-go).

## Sandbox constraints

- **No `while` loops** — use `for` with bounded ranges.
- **No `load()`** — use `lib.require()` for library dependencies.
- **Max steps:** 1,000,000 bytecode operations per execution.
- **No filesystem or OS access** — all I/O goes through gated modules.
- **Deterministic** — same input produces same output (no `random`, no `time`).

## Always-available modules

These modules are injected into every script with no permission required.

### json

Standard Starlark JSON module.

```python
data = json.decode('{"key": "value"}')
text = json.encode({"key": "value"})
```

### settings

Read resolved package settings (global → team → user cascade).

```python
val = settings.get("theme", "light")
# Returns the resolved value, or the default if unset.
```

The cascade respects the `user_overridable` flag from the package manifest.
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for details.

```python
# Check if a runtime capability is available
if settings.has_capability("pgvector"):
    # Use native vector search
    ...
```

`has_capability(name)` returns `True` if the named environment capability
is detected by the kernel. Detected capabilities: `pgvector`, `workspace`,
`object_storage`, `s3`, `postgres`.

### lib

Load exported functions from other packages.

```python
helpers = lib.require("my-utils")
result = helpers.format_date("2026-01-15")
```

Requirements:
- The target package must be declared in your manifest's `depends` array.
- The target package must declare `exports` in its manifest.
- The target package must be status `active`, tier `starlark`.
- Any package type (`library`, `extension`, `full`) can be called as long
  as it declares exports. This enables full packages to expose callable
  functions alongside their UI and API routes.
- Circular dependencies are detected and rejected.
- Results are cached per execution (calling `require` twice returns the
  same object).
- The called function runs with the *target* package's permissions, not
  the caller's.

### permissions

Check whether a user has a specific permission.

```python
if permissions.check(user_id, "image-gen.use"):
    # User is authorized
    ...
```

Returns `True` if the user has the permission, `False` otherwise (including
when the user is not found). Resolves the user's groups and merges granted
permissions — works for both kernel and extension-declared permissions.

## Permission-gated modules

These modules are only available if the package has the corresponding
permission granted in **Admin > Packages**.

### secrets

**Permission:** `secrets.read`

Read admin-configured secrets for this package.

```python
api_key = secrets.get("OPENAI_KEY")  # str or None
all_keys = secrets.list()             # list of key names
```

Secrets are set in **Admin > Packages > Secrets** and scoped per package.

### notifications

**Permission:** `notifications.send`

Send in-app notifications to users.

```python
notifications.send(
    user_id,        # str — target user UUID
    title,          # str — notification title
    body="",        # str — optional body text
    type="extension.notify"  # str — notification type
)
```

### db

**Permission:** `db.read` (queries) or `db.write` (mutations)

Read and write extension data tables. All tables are automatically
namespaced as `ext_{package_id}_{table_name}`.

#### Read operations

```python
# Query with filters, ordering, and pagination
rows = db.query(
    "tasks",                    # table name (without prefix)
    filters={"status": "open"}, # equality WHERE clauses
    order="-created_at",        # column name (prefix - for DESC)
    limit=50,                   # max 1000
    before={"created_at": ts},  # range: column < value
    after={"created_at": ts},   # range: column > value
    search_like={"title": "%bug%"}  # LIKE/ILIKE search
)

# Read from system views (read-only)
users = db.view("users", filters={"display_name": "Alice"}, limit=10)
channels = db.view("channels", limit=100)

# List all tables owned by this package
tables = db.list_tables()
```

Available views: `users`, `channels`.

#### Aggregate operations

```python
# Count rows matching filters
count = db.count("tasks", filters={"status": "open"})

# Aggregate a column (sum, avg, min, max, count)
total = db.aggregate("orders", "amount", "sum", filters={"status": "paid"})
# Returns int, float, or None (if no matching rows)

# Batch multiple queries in a single call
results = db.query_batch([
    {"table": "tasks", "filters": {"status": "open"}, "limit": 10},
    {"table": "logs", "order": "-created_at", "limit": 5},
])
# Returns list of result lists. Max 10 queries per batch.
# Each query spec supports: table (required), filters, order, limit, before, after, search_like
```

#### Vector similarity search

```python
# Find rows with the most similar embeddings (cosine distance)
rows = db.query_similar(
    "documents",        # table name
    "embedding",        # vector column name
    vector=[0.1, 0.2, ...],  # query vector (list of floats)
    limit=10,           # max results (default 10, max 100)
    filters={"active": True},  # optional equality filters
    metric="cosine",    # only "cosine" supported
)
# Returns rows ordered by ascending _distance (0.0 = identical, 1.0 = orthogonal)
# Each row dict includes an injected "_distance" float key.
```

Vector columns are declared as `"vector(N)"` in the manifest `db_tables` block
(N = dimension, 1–4096). Storage varies by backend:

| Backend | Column type | Search |
|---------|-------------|--------|
| Postgres + pgvector | `vector(N)` with HNSW index | Native `<=>` operator |
| Postgres (no pgvector) | `JSONB` | Go-side cosine computation |
| SQLite | `TEXT` | Go-side cosine computation |

Insert vectors as lists: `db.insert("docs", {"embedding": [0.1, 0.2, 0.3]})`.

#### Write operations

```python
row = db.insert("tasks", {"title": "Fix bug", "status": "open"})
# Returns the inserted row dict (with generated id, created_at)

db.update("tasks", row_id, {"status": "closed"})
# Returns True on success

db.delete("tasks", row_id)
# Returns True on success
```

### http

**Permission:** `api.http`

Make outbound HTTP requests.

```python
resp = http.get("https://api.example.com/data", headers={"Authorization": "Bearer ..."})
resp = http.post(url, body='{"key": "val"}', headers={"Content-Type": "application/json"})
resp = http.put(url, body="...", headers={})
resp = http.delete(url, headers={})
resp = http.request("PATCH", url, body="...", headers={})
```

Response dict:

```python
{
    "status": 200,
    "headers": {"content-type": "application/json"},
    "body": "..."   # capped at 1 MB
}
```

#### Batch requests

```python
responses = http.batch([
    {"method": "GET", "url": "https://api.example.com/a"},
    {"method": "POST", "url": "https://api.example.com/b", "body": "{}", "headers": {"Content-Type": "application/json"}},
])
# Returns list of response dicts (same shape as individual calls).
# Individual failures return {"status": 0, "body": "error: ...", "headers": {}}.
# Max 10 requests per batch. Dispatched concurrently.
```

**Security:**
- Private/loopback IPs are blocked (SSRF protection).
- Packages can declare `network_access.allow` (allowlist) or
  `network_access.block` (blocklist) in their manifest.
- Max 10 redirects. 10-second timeout. 1 MB response body limit.

### realtime

**Permission:** `realtime.publish`

Publish WebSocket events to subscribed clients.

```python
realtime.publish(
    "my-channel",     # channel name
    "item.updated",   # event label
    {"id": "abc"}     # payload dict (max 7 KB)
)
```

The payload is automatically tagged with `_pkg: package_id`.

### connections

**Permission:** `connections.read`

Read external connection configurations (secrets are decrypted).

```python
conn = connections.get("postgres", "main-db")
# Returns dict with id, type, name, scope, plus flattened config fields
# Returns None if not found

all_pg = connections.list("postgres")
# Returns list of connection dicts
```

Connections are resolved via scope chain: personal → team → global.

### workflow

**Permission:** `workflow.access`

Read workflow definitions and instances (read-only from Starlark;
mutations go through the HTTP API).

```python
defn = workflow.get_definition(workflow_id)
# Returns dict: id, name, slug, entry_mode, is_active, version, stages[]

inst = workflow.get_instance(instance_id)
# Returns dict: id, workflow_id, current_stage, status, stage_data, ...

instances = workflow.list_instances(workflow_id, status="active")
# Returns list of instance dicts
```

### batch

**Permission:** `batch.exec`

Run multiple callables concurrently. Each callable gets its own
execution thread with an independent step budget.

```python
jira = lib.require("jira-client")
confluence = lib.require("confluence-client")

results, errors = batch.exec([
    lambda: jira.create_issue(issue_data),
    lambda: confluence.create_page(page_data),
    lambda: send_notification(user_id),
], timeout=15)

# results[i] = return value of callables[i], or None on error
# errors[i]  = None on success, or error string on failure
# All three ran concurrently.
```

**Constraints:**

- Max 8 callables per call. Dispatched concurrently via goroutines.
- `timeout` (optional): 1–30 seconds per branch (default 10).
- `lib.require()` is not available inside branch callables.
  Load libraries before the `batch.exec` call.
- `batch.exec()` cannot be called from within a branch (no nesting).
- `print()` output from branches is discarded.

---

### files

**Permissions:** `files.read`, `files.write`

Store and retrieve files via the kernel ObjectStore (PVC or S3).
All keys are scoped to `ext/{packageID}/` — extensions cannot access
each other's files.

```python
# Store a file with optional metadata
files.put("reports/q1.pdf", pdf_bytes,
          content_type="application/pdf",
          metadata={"quarter": "Q1", "year": 2026})

# Read a file
result = files.get("reports/q1.pdf")
# result = {"content": b"...", "content_type": "application/pdf",
#           "size": 12345, "metadata": {"quarter": "Q1", "year": 2026}}

# Metadata only (no content transfer)
meta = files.meta("reports/q1.pdf")

# List files by prefix
entries = files.list(prefix="reports/", limit=50)
# entries = [{"name": "reports/q1.pdf", "size": 12345, "content_type": "..."}]

# Check existence
if files.exists("reports/q1.pdf"):
    files.delete("reports/q1.pdf")

# Bulk delete
files.delete_prefix("temp/")
```

**Notes:**
- `content` accepts string or bytes; `get()` returns bytes.
- Maximum file size: 50 MB (configurable via `EXT_FILES_MAX_SIZE`).
- Metadata is stored as a companion JSON object, not in the file itself.
- `files.list()` automatically filters out internal metadata companions.

---

### workspace

**Permission:** `workspace.manage`

Managed disk directories for extensions that need a real filesystem
(git clones, compilers, media tools). Each workspace is scoped to
`{WORKSPACE_ROOT}/{packageID}/{name}/`.

```python
# Create a workspace (idempotent)
path = workspace.create("my-repo")
# Returns the absolute path to the directory

# Get the path (None if workspace doesn't exist)
path = workspace.path("my-repo")

# List all workspaces owned by this extension
names = workspace.list()  # ["my-repo", "cache"]

# Delete a workspace and all its contents
workspace.delete("my-repo")

# Get disk usage in bytes (10-second timeout)
size = workspace.usage("my-repo")  # 1048576
```

**Constraints:**
- Names must match `^[a-z][a-z0-9_]{0,62}$` (lowercase, no spaces or separators).
- Path traversal and symlink escape are blocked.
- Quota enforcement via `WORKSPACE_QUOTA_MB` env var (0 = unlimited).
- Module not available if `WORKSPACE_ROOT` is unset or not writable.
  Use `settings.has_capability("workspace")` to check availability.

---

## Example: automated stage hook

A simple hook that reads a setting, queries data, and advances:

```python
def on_run(ctx):
    threshold = settings.get("approval_threshold", 1000)
    amount = ctx["stage_data"].get("amount", 0)

    if amount > threshold:
        notifications.send(
            ctx["started_by"],
            "High-value submission",
            body="Amount %d exceeds threshold." % amount,
        )
        return {"advance": True, "data": {"needs_review": True}}

    return {"advance": True, "data": {"needs_review": False}}
```

---

## `forms` Module (v0.9.5)

**Permission:** `forms.validate`

Validates form data against a typed form template.

### `forms.validate(template, data)`

Validates `data` (a dict) against a `template` (a dict matching the TypedFormTemplate schema).

Returns a dict: `{"valid": True/False, "errors": [{"key": "...", "message": "..."}]}`.

```python
result = forms.validate(
    {"fields": [{"key": "name", "type": "text", "label": "Name", "required": True}]},
    {"name": "Alice"},
)
# result["valid"] == True
# result["errors"] == []
```

Field types: `text`, `email`, `select`, `number`, `date`, `textarea`, `checkbox`, `file`.

Supports: required checks, min/max length, pattern (regex), number range, date range, select option whitelist, conditional visibility (`condition.when`/`op`/`value`).
