Surface Schema
This document defines every field in
surface.json— the module’s view contract. Module authors read this to configure how their module appears in the REPL. Core readssurface.jsonat startup; no module process is required.
Overview
Section titled “Overview”surface.json sits alongside manifest.json in the module directory. Its absence means the module is a util module — present in the capability surface but declaring no surface. Its presence gives the module one or more rows on the home page (REPL face today) — one per declared surface, each opening its own survey page.
A module may declare multiple surfaces — each surface is a distinct subject type (e.g., a fail2ban module may have a jail surface and a ban surface). Each surface becomes its own row on the home page.
Core itself has an embedded surface.json (in sdk/system) declaring three surfaces: module (the Inventory page), job (the Jobs page), and message (the Messages page), reached from the home page’s exit row. The job surface also drills into a job’s recorded message exchanges.
File Shape
Section titled “File Shape”Canonical — multi-surface
Section titled “Canonical — multi-surface”{ "surfaces": [ { "subject": "...", "survey": { ... }, "focus": { ... } }, { "subject": "...", "survey": { ... }, "focus": { ... } } ]}The surfaces array is ordered. The first surface keeps the module’s bare short name as its identity; each subsequent surface is suffixed with its subject (name:subject).
Surface object fields
Section titled “Surface object fields”| Field | Type | Required | Description |
|---|---|---|---|
subject | string | yes | Subject type this surface manages. Unique within the module. Keys the surface’s home-page row and survey page. Examples: domain, jail, ban, database, service, module, job. |
name | string | no | Display label override. Module surfaces normally omit this and inherit the module short name. Core surfaces (which have no enclosing manifest) declare their own. |
desc | string | no | One-line description of this surface. Used in operator help. |
survey | object | yes | Survey (list view) configuration. See below. |
focus | object | yes | Focus (detail view) configuration. See below. |
drills | array | no | Nested child surfaces reachable from this surface’s rows. Drill children never appear on the home page — position in the tree determines visibility. See Drills (nested surfaces) below. |
There is no refresh field. Survey freshness is not configurable: faces always re-read live state on survey page entry, on return from focus, and on explicit refresh. Facet chip toggles re-filter the cached subject list locally — no round-trip to the module.
survey object
Section titled “survey object”| Field | Type | Required | Description |
|---|---|---|---|
entry | string | yes | Survey capability name. Convention: {module-short}.{subject}.survey. Must be declared in manifest.json with async: false. |
columns | array | no | Ordered column declarations for the survey table. The name column is always implicit and first — do not declare it here. |
facets | array | no | Facet chip declarations. When absent, no facet row is shown. |
survey.columns — column object
Section titled “survey.columns — column object”| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Column identifier. Must match the key in survey response subjects[].columns. Lowercase, no spaces. |
label | string | yes | Column header text. |
width | integer | no | Width cap in terminal character units. When absent or 0, core auto-sizes to max(len(label), max(len(value))) across all subjects. Declare only for unbounded free-text fields. |
align | string | no | "left" (default) or "right". Use "right" for numeric columns. |
from | string | no | Capability name that fills this column’s cells — a whole-column data source, fired once per distinct from after the survey resolves (not once per row). Absent ⇒ the value comes inline from the survey response (subjects[].columns). Columns sharing one from fill together. Returns the Cell-Fill Capability Response Contract. |
survey.facets — facet object
Section titled “survey.facets — facet object”| Field | Type | Required | Description |
|---|---|---|---|
label | string | yes | Chip label. Unicode symbols allowed ("● active"). |
value | string | yes | Value the module uses to tag matching subjects in its facets array in the survey response. |
focus object
Section titled “focus object”| Field | Type | Required | Description |
|---|---|---|---|
entry | string | yes | Focus capability name. Convention: {module-short}.{subject}.focus. Must be declared in manifest.json with async: false. |
The focus handler computes and returns available actions per subject; a read-only module returns an empty actions array. There is no actions field in surface.json.
Drills (nested surfaces)
Section titled “Drills (nested surfaces)”A surface may declare child surfaces in a drills[] array. Each drill defines a subject relationship reachable from a parent surface’s rows — a database surface drills into module and user surfaces, each scoped to the selected database row.
Visibility rule
Section titled “Visibility rule”Only top-level surfaces — entries in the surfaces[] array — appear as rows on the home page. Drill children are reached only by selecting a row in a parent surface and choosing a drill chip. They are never home roots. Nesting is the sole visibility discriminant: no separate flag required.
Drill object fields
Section titled “Drill object fields”A drill child has the same shape as a surface object, plus a label field:
| Field | Type | Required | Description |
|---|---|---|---|
subject | string | yes | Subject type this drill manages. Unique within its parent. |
label | string | no | Chip label rendered on the parent surface’s rows as the drill entry. Defaults to subject when absent. |
survey | object | yes | Survey configuration. Same shape as a top-level surface survey. |
focus | object | yes | Focus configuration. Same shape as a top-level surface focus. |
drills | array | no | Grandchild drill surfaces — recursively same shape. Depth is not capped by core. |
Scope argument
Section titled “Scope argument”When the operator drills from a parent row into a child surface, core passes the selected row’s opaque id as a scope argument to the child’s survey capability. The survey receives:
{ "scope": "<parent-row-id>" }The child survey reads scope, queries only rows belonging to that parent, and returns its standard SurveyResponse. Core does not interpret scope — it is opaque to core and meaningful only to the module that minted the parent id.
At deeper nesting levels, scope always contains the immediately selected parent row’s id. Lineage to higher ancestors is encoded inside the id strings the module mints — for example prod/sale where prod is the database id and sale is the module technical name. A single opaque string carries the full context without core needing an ancestor stack.
Capability naming convention
Section titled “Capability naming convention”Each surface’s survey.entry and focus.entry point to capabilities declared in manifest.json:
{module-short}.{subject}.survey{module-short}.{subject}.focusFor suctl-mod-fail2ban with a jail surface: fail2ban.jail.survey / fail2ban.jail.focus.
For suctl-mod-fail2ban with a ban surface: fail2ban.ban.survey / fail2ban.ban.focus.
These names are a recommended convention, not a parsed rule — survey.entry and focus.entry may name any capability the module declares. The convention keeps names predictable across faces and tooling. The one hard requirement is that the capability each entry names is declared in manifest.json and implemented: a face invokes it by the name in entry, and a name that is not in the module’s active capability surface fails at invoke time.
Validation rules enforced by core
Section titled “Validation rules enforced by core”subjectis required on every surface and every drill child. Duplicate subjects within the same parent are a hard error.survey.entryandfocus.entryare required on every surface and every drill child.- All entry capabilities must be declared in
manifest.jsonand implemented; a face invokes them by the name inentry, and a name not in the active capability surface fails at invoke time. - Columns declared in
surface.jsonmust be present in every subject returned by the survey response (validated bymodtestconformance probes). - A drill child’s
subjectmust be unique within its parent’sdrills[]array. It need not be globally unique across the module’s top-level surfaces. - Cycles are structurally impossible — the schema is a tree and a child cannot reference a parent.
- A module without
surface.jsonis valid — it is a util module declaring no surface.
Complete examples
Section titled “Complete examples”Single-subject module
Section titled “Single-subject module”{ "surfaces": [ { "subject": "domain", "survey": { "entry": "nginx.domain.survey", "columns": [ { "id": "status", "label": "status" }, { "id": "ssl", "label": "ssl" }, { "id": "rps", "label": "req/s", "align": "right" } ], "facets": [ { "label": "● active", "value": "active" }, { "label": "ssl expiring", "value": "ssl-warn" }, { "label": "suspended", "value": "suspended" } ] }, "focus": { "entry": "nginx.domain.focus" } } ]}Multi-subject module
Section titled “Multi-subject module”{ "surfaces": [ { "subject": "jail", "survey": { "entry": "fail2ban.jail.survey", "columns": [ { "id": "status", "label": "status" }, { "id": "banned", "label": "banned", "align": "right" }, { "id": "maxretry", "label": "max", "align": "right" } ], "facets": [ { "label": "active", "value": "active" }, { "label": "inactive", "value": "inactive" } ] }, "focus": { "entry": "fail2ban.jail.focus" } }, { "subject": "ban", "survey": { "entry": "fail2ban.ban.survey", "columns": [ { "id": "ip", "label": "ip" }, { "id": "jail", "label": "jail" }, { "id": "banned_at", "label": "since", "align": "right" } ] }, "focus": { "entry": "fail2ban.ban.focus" } } ]}Drill surface module
Section titled “Drill surface module”{ "surfaces": [ { "subject": "database", "survey": { "entry": "odoo.database.survey", "columns": [] }, "focus": { "entry": "odoo.database.focus" }, "drills": [ { "subject": "module", "label": "modules", "survey": { "entry": "odoo.module.survey", "columns": [ { "id": "summary", "label": "summary" }, { "id": "version", "label": "version" }, { "id": "status", "label": "status" } ], "facets": [ { "label": "installed", "value": "installed" }, { "label": "not installed", "value": "uninstalled" } ] }, "focus": { "entry": "odoo.module.focus" } }, { "subject": "user", "label": "users", "survey": { "entry": "odoo.user.survey", "columns": [ { "id": "login", "label": "login" }, { "id": "status", "label": "status" } ] }, "focus": { "entry": "odoo.user.focus" } } ] } ]}odoo.module.survey and odoo.user.survey each receive { "scope": "<db-id>" } and return all rows belonging to that database, each tagged with applicable facets. The database surface appears on the home page; module and user are drill-only.
See: manifest schema for manifest.json field reference. REPL frame for the REPL frame contract.
Survey Capability Response Contract
Section titled “Survey Capability Response Contract”The capability named in survey.entry MUST return a JSON object with this exact shape:
{ "total": 8, "status_summary": "ssl: 2 expiring", "subjects": [ { "id": "client-a.com", "name": "client-a.com", "columns": { "status": { "value": "active", "color": "green" }, "ssl": { "value": "82d", "color": "ok" }, "php": { "value": "8.2", "color": null }, "rps": { "value": "4.3", "color": "cyan" }, "uptime": { "value": "99.98%", "color": null } }, "facets": ["active"], "inline_actions": [ { "capability": "nginx.ssl.renew", "label": "renew" }, { "capability": "nginx.domain.suspend", "label": "suspend" } ] } ]}| Field | Type | Required | Description |
|---|---|---|---|
total | integer | yes | Total subject count. Invariant across all filter changes — always the full unfiltered count. Used as the M in the filter badge (N of M) and in the bare-count summary fallback. |
status_summary | string | no | One short phrase rendered in the in-box summary row (amber). Falls back to "%d {subject}" in dim when empty/omitted and total > 0. Always draws the row. |
subjects | array | yes | All subjects, each tagged with applicable facet values. May be empty. |
subjects[].id | string | yes | Opaque unique identifier. Passed back as subject param in focus call. Never displayed. |
subjects[].name | string | yes | Display name. Shown in the always-first name column. |
subjects[].facets | array | no | Facet values that apply to this row. Each value must be declared in surface.json survey.facets. Core uses these tags to filter rows locally when the operator activates chips — the module is never called again on chip toggle. Omit or leave empty for rows that match no declared facet. |
subjects[].columns | object | yes | Keyed by column id from survey.columns. Missing column IDs render as —. |
subjects[].columns[id].value | string | yes | Display string. Must be pre-formatted and safe for terminal output. |
subjects[].columns[id].color | string or null | no | Semantic color token, or null/omitted for default text color. See Color Tokens below. |
subjects[].inline_actions | array | no | Quick-action buttons rendered on the subject row in survey. Computed per row from live state at survey time — there is no static declaration in surface.json (mirrors focus actions). May be absent or empty. |
subjects[].inline_actions[].capability | string | yes | Capability name to invoke when the operator activates this inline action. |
subjects[].inline_actions[].label | string | yes | Short button label rendered as [label] on the subject row. |
subjects[].inline_actions[].destructive | boolean | no | If true, the face routes through a CONFIRM step before invoking. Default: false. Button color is owned by the face (destructive vs safe) — there is no color field on actions. |
The module MUST return all subjects regardless of which facets the operator has active. Each subject carries a facets array listing every declared facet value that applies to that row — classification is the module’s responsibility; filter selection is core’s. total reflects the full unfiltered count and must never change based on filter state.
Go SDK: Use
sdk/surfacetyped structs instead of rawmap[string]interface{}.surface.SurveyResponse,surface.Subject,surface.Column,surface.Action. Column shorthand:surface.Col(value, color). Import path:github.com/solutionsunity/suctl/suctl/sdk/surface.
Focus Capability Response Contract
Section titled “Focus Capability Response Contract”The capability named in focus.entry MUST return a JSON object with this exact shape:
{ "id": "client-a.com", "name": "client-a.com", "sections": [ { "title": "runtime state", "fields": [ { "label": "status", "value": "active", "color": "green", "full_width": false }, { "label": "ssl", "value": "valid · 82d remaining", "color": "green", "full_width": false }, { "label": "vhost config", "value": "/etc/nginx/…", "color": "dim", "full_width": true } ] } ], "actions": [ { "capability": "nginx.domain.suspend", "label": "suspend", "destructive": false }, { "capability": "nginx.ssl.renew", "label": "renew ssl", "destructive": false }, { "capability": "nginx.domain.delete", "label": "delete", "destructive": true } ]}| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | The subject id echoed back from the focus call param. |
name | string | yes | Display name shown in the focus header breadcrumb. |
sections | array | yes | Ordered list of KV sections. May be empty. |
sections[].title | string | yes | Section heading. Rendered uppercase. |
sections[].fields | array | yes | Ordered list of field objects. |
fields[].label | string | yes | Field label. Rendered in dim uppercase. |
fields[].value | string | yes | Field value. Pre-formatted for terminal. |
fields[].color | string or null | no | Semantic color token, or null/omitted for default. See Color Tokens below. |
fields[].full_width | boolean | no | If true, field spans both grid columns. Default: false. |
actions | array | yes | Available actions for this subject’s current state. May be empty. |
actions[].capability | string | yes | Capability name. Must be declared in capabilities. Frame invokes this on selection. |
actions[].label | string | yes | Button label. Short, verb-first. |
actions[].destructive | boolean | no | If true, the frame routes through a CONFIRM step before invoking the capability. Default: false. Button color is owned by the face (destructive vs safe) — there is no color field on actions. |
The module computes actions from live subject state in its focus handler. The frame renders exactly what the handler returns — availability logic belongs in the handler, not in a manifest expression.
Go SDK: Use
sdk/surfacetyped structs instead of rawmap[string]interface{}.surface.FocusResponse,surface.Section,surface.Field,surface.Action. Import path:github.com/solutionsunity/suctl/suctl/sdk/surface.
Cell-Fill Capability Response Contract
Section titled “Cell-Fill Capability Response Contract”A column may declare a from capability (see survey.columns) that fills its cells after the survey resolves. Unlike survey and focus, a from is a whole-column data source: the face requests it once per distinct from — not once per row — and it returns values for every row at once, keyed by the opaque subject id minted in the survey response. Columns that share a from are filled by the same call. Fan-out is therefore O(distinct froms), independent of row count.
The capability named in a column’s from MUST return a JSON object with this exact shape:
{ "rows": { "prod": { "columns": { "modules": { "value": "123/866", "color": null }, "users": { "value": "5/12", "color": "green" } } }, "staging": { "columns": { "modules": { "value": "40/866", "color": null }, "users": { "value": "2/3", "color": "green" } } } }}| Field | Type | Required | Description |
|---|---|---|---|
rows | object | yes | Keyed by the opaque subject id from the survey response. A row id absent from the map leaves that row’s cells at —. |
rows[id].columns | object | yes | Keyed by column id. Only the columns this from fills need appear; mirrors the survey subjects[].columns shape. |
rows[id].columns[col].value | string | yes | Display string. Pre-formatted and safe for terminal output. |
rows[id].columns[col].color | string or null | no | Semantic color token, or null/omitted for default text color. See Color Tokens below. |
The face fires each from after the survey resolves and paints cells progressively as each call’s result correlates back — partial success never aborts the load; a column whose from stalls or fails leaves its cells at — until the next refresh.
Go SDK: Build the row-keyed map with
surface.Column(the same cell shape as a survey column); the response is{"rows": {<id>: {"columns": {<col>: surface.Column}}}}. Import path:github.com/solutionsunity/suctl/suctl/sdk/surface.
Python SDK: Use
surface.rows({id: {col: surface.col(value, color)}})— it wraps the row id → column id → cell map in the{"rows": …}envelope.
Color Tokens
Section titled “Color Tokens”Standard vocabulary for coloring values in survey columns and focus fields. Module capabilities use these token strings — never raw terminal color codes or ANSI escape sequences.
| Token | Aliases | Meaning | Use |
|---|---|---|---|
"ok" | "green" | Healthy, active, success, within limits | SSL valid, service running, test passed |
"warn" | "amber" | Warning, expiring soon, degraded, approaching limit | SSL expiring, disk at 75%, service restarting |
"err" | "red" | Error, expired, failed, exceeded limit | SSL expired, service stopped, config test failed |
"alert" | — | Critical — unavailable, activation failed. Rendered as bold red with a ✗ glyph prefix (✗ unavailable) so it reads via shape + colour, not background colour. Use only for states that must be impossible to miss. | Module unavailable |
"blue" | "info" | Metric, count, numeric measurement | Requests/s, port number, worker count |
"purple" | "accent" | Memory, resident resource usage | Memory MB, swap |
"cyan" | "tip" | Throughput, rate, speed | Req/s, MB/s, connections/s |
"dim" | "ghost", "muted" | Path, secondary info, placeholder, muted label | File path, config path, version string |
null | — | Default text — no color emphasis | Generic string values |
An unknown token renders in the neutral body color — never an error.
Color tokens apply to values only — survey columns and focus fields. Action
buttons are not module-colored: the face derives their appearance from the
destructive flag (danger vs safe) and selection state.