ObjectStackObjectStack

Metadata Plugin — Kernel Protocol reference

Defines the specification for the Metadata Plugin — the central authority responsible for managing ALL metadata across the ObjectStack platform.

Metadata Plugin Protocol

Defines the specification for the Metadata Plugin — the central authority responsible for managing ALL metadata across the ObjectStack platform.

Architecture

The Metadata Plugin consolidates all scattered metadata operations into a single, cohesive plugin that "takes over" the entire platform's metadata management:

┌──────────────────────────────────────────────────────────────────┐
│                     Metadata Plugin                             │
│                                                                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ Type Registry │  │  Loader      │  │ Customization Layer  │  │
│  │ (all types)   │  │  (file/db/s3)│  │ (overlay / merge)    │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
│                                                                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ Persistence  │  │  Query       │  │ Lifecycle            │  │
│  │ (db records) │  │  (search)    │  │ (validate/deploy)    │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
└──────────────────────────────────────────────────────────────────┘

Alignment

  • Salesforce: Metadata API (deploy, retrieve, describe)
  • ServiceNow: System Dictionary + Metadata API
  • Kubernetes: API Server + CRD Registry

References

Source: packages/spec/src/kernel/metadata-plugin.zod.ts

TypeScript Usage

import { MetadataBulkResultSchema, MetadataDependencySchema, MetadataPluginConfigSchema, MetadataPluginManifestSchema, MetadataQuerySchema, MetadataQueryResultSchema, MetadataTypeSchema, MetadataTypeRegistryEntrySchema, MetadataValidationResultSchema } from '@objectstack/spec/kernel';
import type { MetadataBulkResult, MetadataDependency, MetadataPluginConfig, MetadataPluginManifest, MetadataQuery, MetadataQueryResult, MetadataType, MetadataTypeRegistryEntry, MetadataValidationResult } from '@objectstack/spec/kernel';

// Validate data
const result = MetadataBulkResultSchema.parse(data);

MetadataBulkResult

Properties

PropertyTypeRequiredDescription
totalinteger✅Total items processed
succeededinteger✅Successfully processed
failedinteger✅Failed items
errors{ type: string; name: string; error: string }[]optionalPer-item errors

Nested Shape: MetadataBulkResult.errors[number]

PropertyTypeRequiredDescription
typestring✅Metadata type
namestring✅Item name
errorstring✅Error message

MetadataDependency

Properties

PropertyTypeRequiredDescription
sourceTypestring✅Dependent metadata type
sourceNamestring✅Dependent metadata name
targetTypestring✅Referenced metadata type
targetNamestring✅Referenced metadata name
kindEnum<'reference' | 'extends' | 'includes' | 'triggers'>✅How the dependency is formed

MetadataPluginConfig

Properties

PropertyTypeRequiredDescription
storage{ datasource?: string; tableName?: string; fallback?: Enum<'filesystem' | 'memory' | 'none'>; rootDir?: string; … }✅Storage backend configuration
customizationPoliciesneveroptional[REMOVED] config.customizationPolicies was removed from MetadataPluginConfig in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: no code ever read a customization policy, and the overlay protocol it configured was itself unreachable from any served surface (ADR-0126 supersedes it on the record). Delete the key. What a customization may touch is governed by the real mechanisms: ADR-0005's environment overlay (opt-in via allowEnvironmentOverlay on DEFAULT_METADATA_TYPE_REGISTRY, enforced at the metadata protocol's write doors and its served read) and ADR-0126's packaged-metadata model (clone + ledger disable).
mergeStrategyneveroptional[REMOVED] config.mergeStrategy was removed from MetadataPluginConfig in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: no 3-way merge engine ever existed to read it, and package upgrades do not merge customizations (ADR-0126: upgrades rewrite the packaged base; customer choices live in the ledger and are never merged). Delete the key. There is no replacement — upgrade-vs-customization separation is the model, not a configurable strategy.
additionalTypesneveroptional[REMOVED] config.additionalTypes was removed from MetadataPluginConfig in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: the only production writer of the metadata type registry is setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY), which replaces the array outright, so nothing ever merged these entries and the live type set was exactly the built-in registry whatever you declared here. Delete the key. There is no declared-kind channel: a kind enters the live metadata-type set as a side effect of registering an ITEM of that kind (SchemaRegistry.registerItem during app/manifest registration, or MetadataManager.register at runtime); bind its schema with registerMetadataTypeSchema(type, schema) from your plugin's init(ctx) so GET /api/v1/meta serves a real JSON Schema for it.
enableEventsbooleanoptional (default: true)Emit metadata change events
validateOnWritebooleanoptional (default: true)Validate metadata on write
enableVersioningbooleanoptional (default: false)Track metadata version history
cacheMaxItemsintegeroptional (default: 10000)Max items in memory cache
bootstrapEnum<'eager' | 'lazy' | 'artifact-only'>optional (default: "eager")How metadata is primed at plugin start (eager / lazy / artifact-only)

Nested Shape: MetadataPluginConfig.storage

PropertyTypeRequiredDescription
datasourcestringoptionalDatasource name reference for database persistence
tableNamestringoptional (default: "sys_metadata")Database table name for metadata storage
fallbackEnum<'filesystem' | 'memory' | 'none'>optional (default: "none")Fallback strategy when datasource is unavailable
rootDirstringoptionalRoot directory path
formatsEnum<'yaml' | 'json' | 'typescript' | 'javascript'>[]optional (default: ["typescript","json","yaml"])Enabled formats
cache{ databaseLoader?: object }optionalCache settings — only databaseLoader is read at runtime; the outer keys are retired
watchbooleanoptional (default: false)Enable file watching
watchOptions{ ignored?: string[]; persistent?: boolean; ignoreInitial?: boolean }optionalFile watcher options
validation{ strict?: boolean; throwOnError?: boolean }optionalValidation settings
loaderOptionsRecord<string, any>optionalLoader-specific configuration
persistence{ writable?: boolean }optionalPersistence write gates

MetadataPluginManifest

Properties

PropertyTypeRequiredDescription
id'com.objectstack.metadata'✅Metadata plugin ID
name'ObjectStack Metadata Service'✅Plugin name
versionstring✅Plugin version (SemVer 2.0.0 — e.g. 1.2.3, 2.0.0-beta.1)
type'standard'✅Plugin type
descriptionstringoptional (default: "Core metadata management service for ObjectStack platform")Plugin description
capabilities{ crud?: boolean; query?: boolean; overlay?: boolean; watch?: boolean; … }✅Plugin capabilities
config{ storage: object; enableEvents?: boolean; validateOnWrite?: boolean; enableVersioning?: boolean; … }optionalPlugin configuration

Nested Shape: MetadataPluginManifest.capabilities

PropertyTypeRequiredDescription
crudbooleanoptional (default: true)Supports metadata CRUD
querybooleanoptional (default: true)Supports metadata query
overlaybooleanoptional (default: true)Supports customization overlays
watchbooleanoptional (default: false)Supports file watching
importExportbooleanoptional (default: true)Supports import/export
validationbooleanoptional (default: true)Supports schema validation
versioningbooleanoptional (default: false)Supports version history
eventsbooleanoptional (default: true)Emits metadata events

Nested Shape: MetadataPluginManifest.config

PropertyTypeRequiredDescription
storage{ datasource?: string; tableName?: string; fallback?: Enum<'filesystem' | 'memory' | 'none'>; rootDir?: string; … }✅Storage backend configuration
customizationPoliciesneveroptional[REMOVED] config.customizationPolicies was removed from MetadataPluginConfig in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: no code ever read a customization policy, and the overlay protocol it configured was itself unreachable from any served surface (ADR-0126 supersedes it on the record). Delete the key. What a customization may touch is governed by the real mechanisms: ADR-0005's environment overlay (opt-in via allowEnvironmentOverlay on DEFAULT_METADATA_TYPE_REGISTRY, enforced at the metadata protocol's write doors and its served read) and ADR-0126's packaged-metadata model (clone + ledger disable).
mergeStrategyneveroptional[REMOVED] config.mergeStrategy was removed from MetadataPluginConfig in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: no 3-way merge engine ever existed to read it, and package upgrades do not merge customizations (ADR-0126: upgrades rewrite the packaged base; customer choices live in the ledger and are never merged). Delete the key. There is no replacement — upgrade-vs-customization separation is the model, not a configurable strategy.
additionalTypesneveroptional[REMOVED] config.additionalTypes was removed from MetadataPluginConfig in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: the only production writer of the metadata type registry is setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY), which replaces the array outright, so nothing ever merged these entries and the live type set was exactly the built-in registry whatever you declared here. Delete the key. There is no declared-kind channel: a kind enters the live metadata-type set as a side effect of registering an ITEM of that kind (SchemaRegistry.registerItem during app/manifest registration, or MetadataManager.register at runtime); bind its schema with registerMetadataTypeSchema(type, schema) from your plugin's init(ctx) so GET /api/v1/meta serves a real JSON Schema for it.
enableEventsbooleanoptional (default: true)Emit metadata change events
validateOnWritebooleanoptional (default: true)Validate metadata on write
enableVersioningbooleanoptional (default: false)Track metadata version history
cacheMaxItemsintegeroptional (default: 10000)Max items in memory cache
bootstrapEnum<'eager' | 'lazy' | 'artifact-only'>optional (default: "eager")How metadata is primed at plugin start (eager / lazy / artifact-only)

MetadataQuery

Properties

PropertyTypeRequiredDescription
typesEnum<'object' | 'object_extension' | 'field' | 'hook' | 'seed' | 'mapping' | 'picklist' | 'view' | 'page' | 'dashboard' | 'app' | 'action' | 'report' | 'dataset' | 'flow' | 'job' | 'datasource' | 'external_catalog' | 'translation' | 'api' | 'email_template' | 'doc' | 'book' | 'permission' | 'position' | 'capability' | 'sharing_rule' | 'queue' | 'assignment_rule' | 'agent' | 'tool' | 'skill'>[]optionalFilter by metadata types
namespacesstring[]optionalFilter by namespaces
packageIdstringoptionalFilter by owning package
searchstringoptionalFull-text search query
scopeEnum<'system' | 'platform' | 'user'>optionalFilter by scope
stateEnum<'draft' | 'active' | 'archived' | 'deprecated'>optionalFilter by lifecycle state
tagsstring[]optionalFilter by tags
sortByEnum<'name' | 'type' | 'updatedAt' | 'createdAt'>optional (default: "name")Sort field
sortOrderEnum<'asc' | 'desc'>optional (default: "asc")Sort direction
pageintegeroptional (default: 1)Page number
pageSizeintegeroptional (default: 50)Items per page

MetadataQueryResult

Properties

PropertyTypeRequiredDescription
items{ type: string; name: string; namespace?: string; label?: string; … }[]✅Matched metadata items
totalinteger✅Total matching items
pageinteger✅Current page
pageSizeinteger✅Page size

Nested Shape: MetadataQueryResult.items[number]

PropertyTypeRequiredDescription
typestring✅Metadata type
namestring✅Item name
namespacestringoptionalNamespace
labelstringoptionalDisplay label
scopeEnum<'system' | 'platform' | 'user'>optional
stateEnum<'draft' | 'active' | 'archived' | 'deprecated'>optional
packageIdstringoptional
updatedAtstringoptional

MetadataType

Allowed Values

  • object
  • object_extension
  • field
  • hook
  • seed
  • mapping
  • picklist
  • view
  • page
  • dashboard
  • app
  • action
  • report
  • dataset
  • flow
  • job
  • datasource
  • external_catalog
  • translation
  • api
  • email_template
  • doc
  • book
  • permission
  • position
  • capability
  • sharing_rule
  • queue
  • assignment_rule
  • agent
  • tool
  • skill

MetadataTypeRegistryEntry

Properties

PropertyTypeRequiredDescription
typeEnum<'object' | 'object_extension' | 'field' | 'hook' | 'seed' | 'mapping' | 'picklist' | 'view' | 'page' | 'dashboard' | 'app' | 'action' | 'report' | 'dataset' | … +18 more>✅Metadata type identifier
labelstring✅Display label for the metadata type
descriptionstringoptionalDescription of the metadata type
filePatternsstring[]✅Glob patterns to discover files of this type
supportsOverlaybooleanoptional (default: true)Whether a stored overlay row of an item a managed package ships may be removed through the ordinary delete door without the type's environment-overlay permission: the removal removes no managed definition, and changes what is served for permission alone; it opens no save and it decides no read
allowEnvironmentOverlaybooleanoptional (default: false)Whether an environment may overlay an item of this type that a managed package ships (ADR-0131 D6 regime O). Decides the save door, the managed-content seal on write and on read, and Studio's write gate; the OS_METADATA_WRITABLE hatch does not reach a managed item. Default false.
allowOrgOverrideneveroptional[REMOVED] MetadataTypeRegistryEntry.allowOrgOverride was renamed to allowEnvironmentOverlay in @objectstack/spec 18 (ADR-0131 D6) — the per-organization overlay axis was retired, and the key now names the one scope it governs: whether an environment may overlay an item of this type that a managed package ships. Rename the key to allowEnvironmentOverlay; the value is unchanged.
allowRuntimeCreatebooleanoptional (default: true)Allow runtime creation via API
supportsVersioningbooleanoptional (default: false)Whether version history is tracked
executionPinnedbooleanoptional (default: false)Transaction rows reference a specific version_hash; history GC is disabled and getByHash() MUST resolve old hashes (ADR-0009)
loadOrderintegeroptional (default: 100)Loading priority (lower = earlier)
domainEnum<'data' | 'ui' | 'automation' | 'system' | 'security' | 'ai'>✅Protocol domain
actions{ name: string; label: string | Record<string, string>; description?: string | object | string | Record<string, string>; objectName?: string; … }[]optionalDeclarative type-level actions (e.g. datasource "Test connection"), reusing ActionSchema; merged with plugin-registered actions when emitted

Allowed Values: MetadataTypeRegistryEntry.type

  • object
  • object_extension
  • field
  • hook
  • seed
  • mapping
  • picklist
  • view
  • page
  • dashboard
  • app
  • action
  • report
  • dataset
  • flow
  • job
  • datasource
  • external_catalog
  • translation
  • api
  • email_template
  • doc
  • book
  • permission
  • position
  • capability
  • sharing_rule
  • queue
  • assignment_rule
  • agent
  • tool
  • skill

Nested Shape: MetadataTypeRegistryEntry.actions[number]

PropertyTypeRequiredDescription
namestring✅Machine name (lowercase snake_case)
labelstring | Record<string, string>✅Display label
descriptionstring | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string>optionalExplanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing ai.description. It is a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over record, the record the action runs against (the clicked row on a list, else the page's record); the values the dialog collects are not bound, since the line shows before they exist. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Choose the new role for {{ record.name }}.'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence.
objectNamestringoptionalTarget object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack().
iconstringoptionalIcon name
locationsEnum<'list_toolbar' | 'list_item' | 'record_header' | 'record_more' | …>[]optionalLocations where this action is visible
componentEnum<'action:button' | 'action:icon' | 'action:menu' | 'action:group'>optionalVisual component override
typeEnum<'script' | 'url' | 'modal' | 'flow' | 'api' | 'form'>optional (default: "script")Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is operation: 'update' + patch on the default script route.
targetstringoptionalURL, Script Name, Flow ID, or API Endpoint. Supports ${param.X} and ${ctx.X} interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (a console route; a record is /apps/APP/OBJECT/record/ID, APP being the app's route segment — see onSuccess.navigate) — every other scheme and a protocol-relative //host are refused.
openInEnum<'self' | 'new-tab'>optionalFor type:'url' — where to open target. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of params (which is user-input-collection only).
body{ language: 'expression'; source: string } | { language: 'js'; source: string; capabilities?: Enum<'api.read' | 'api.write' | 'api.transaction' | 'crypto.uuid' | 'log'>[]; timeoutMs?: integer; … }optionalAction body — expression (L1) or sandboxed JS (L2). Only used when type is script.
operationEnum<'update'>optionalThe declarative single-record field write, mirroring a list view's bulkActionDefs: 'update' applies patch (merged under the collected params) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. type stays at its default 'script' (the platform action route the write is performed on); target/body/method/bodyExtra are refused beside it. 'delete' and 'custom' have no row-level form.
patchRecord<string, any>optionalFor operation: 'update' — static field values written to the current record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without operation: 'update' (it would be silently dropped).
executionEnum<'perRecord' | 'aggregate'>optionalThe bulk dispatch contract this action's BODY is written for, in bulkActionDefs' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's recordId (the view's bulkActions: ['<name>'] bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in params._selectedIds (a bulkActionDefs entry with execution: 'aggregate'). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by @objectstack/lint (action-dispatch-contract-mismatch).
params{ name?: string; field?: string; objectOverride?: string; label?: string | Record<string, string>; … }[]optionalInput parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in bodyExtra). A param name (its name, or the field a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in visible / requiresFeature, e.g. one optional and one required); the params check treats the name as required only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (type, multiple, options), so keep that shape identical across them.
submitTextstring | Record<string, string>optionalLabel of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" — the default-language string, or an inline locale map; translated at objects.OBJECT._actions.ACTION.submitText (globalActions.ACTION.submitText for an object-less action). Only on an action with a non-empty params: without one no param dialog opens, so it is refused. Not confirmText, which is the message of a param-less action's confirm dialog. A blank label is refused.
cancelTextstring | Record<string, string>optionalLabel of the dismiss button on the dialog the action opens before it runs — the confirm dialog of a param-less action that declares confirmText, or the param dialog of an action with a non-empty params — in place of the default "Cancel". Name what dismissing keeps where the action's own verb is "Cancel …" or "Close …" (e.g. "Keep invitation" beside "Cancel Invitation"). The default-language string, or an inline locale map; translated at objects.OBJECT._actions.ACTION.cancelText (globalActions.ACTION.cancelText for an object-less action). Only on an action that opens one of those dialogs: with neither confirmText nor a non-empty params no dialog opens, so it is refused. The dismiss twin of submitText, as on object-form. A blank label is refused.
variantEnum<'primary' | 'secondary' | 'danger' | 'ghost' | 'link'>optionalButton visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent)
ordernumberoptionalSort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without order keep their registration order.
confirmTextstring | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string>optionalConfirmation message before execution — a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over record, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's params is an object of static values — params ({{ params.tier }}); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete {{ record.name }}?'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. Pairing this with a non-empty params is refused — that opens a second dialog for one decision; put the question on description instead. Correct on a param-LESS action, where the confirm is the only dialog there is.
successMessagestring | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string>optionalSuccess message shown after the action succeeds — a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over result, the payload the action's handler answered ({{ result.email }}; for an api or script action the server response), and record, the record the action ran against. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to {{ result.email }}'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. On a registered action that declares outcomeMessages, the entry named by the response's outcome is shown instead, and this message is the fallback.
outcomeMessagesRecord<string, string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string>>optionalSuccess copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case outcome values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message is a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over result, the payload the action's handler answered ({{ result.email }}), and record, the record the action ran against — the roots successMessage reads. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation resent to {{ result.email }}'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. An outcome with no entry here falls back to successMessage, then to the default text. Not allowed beside resultDialog (which suppresses the success toast) or operation: 'update' (no handler, so no outcome).
errorMessagestring | Record<string, string>optionalError message to show when the action fails (overrides the raw error).
refreshAfterbooleanoptional (default: false)Refresh view after execution
undoablebooleanoptionalOffer an Undo affordance after this single-record update action succeeds. operation: 'update' is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, patch UNDER the collected params, not patch alone. An action with no operation declares no write set here, but type: 'api' is fulfilled by the console, which builds the undo envelope from undoable alone. Those two shapes — operation: 'update' and type: 'api' — are the whole fulfillable set; on a registered action undoable beside any other shape is refused, because nothing would anchor the capture.
resultDialog{ title?: string | Record<string, string>; description?: string | Record<string, string>; acknowledge?: string | Record<string, string>; format?: Enum<'qrcode' | 'code-list' | 'secret' | 'text' | 'json'>; … }optionalRender API response in a one-shot reveal dialog (suppresses successMessage when set).
visibleboolean | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalVisibility predicate — true/false literal, CEL string, or {dialect, source} envelope. The action is offered when it evaluates TRUE. Omit = always visible.
requiresFeatureEnum<'twoFactor' | 'organization' | 'multiOrgEnabled' | 'degradedTenancy' | …>optionalPublic auth feature flag gating this action; lowered into visible at parse time.
requiresMembershipReachEnum<'invite_member' | 'cancel_invitation' | 'update_member_role' | …>optionalOrganization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into visible over current_user.positions at parse time.
disabledboolean | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalDisabled predicate — true/false literal, CEL string, or {dialect, source} envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled.
disabledReasonstring | Record<string, string>optionalDisplay-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while disabled evaluates TRUE — the default-language string, or an inline locale map; translated at objects.OBJECT._actions.ACTION.disabledReason (globalActions.ACTION.disabledReason for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, requiredPermissions), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused.
pressedstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPressed-state predicate (CEL string or {dialect, source} envelope) over the same scope as visible: the action surface draws the button as already pressed for this reader (a toggle face, aria-pressed) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under attachedOnRead, as has(record.viewer) && record.viewer.vote == 'helpful'. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed.
requiredPermissionsstring[]optional[ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (POST /actions/:object/:action and MCP run_action) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a type: api action whose declared target is a same-origin platform path (exactly one leading /, followed by neither / nor a backslash, with a path after it) under that door; its target endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A type: api action with an off-origin target is browser-only and not gated server-side.
ai{ exposed?: boolean; description?: string; category?: Enum<'data' | 'action' | 'flow' | 'integration' | 'vector_search' | 'analytics' | 'utility'>; paramHints?: Record<string, object>; … }optionalAI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents.
recordIdParamstringoptionalBody key to inject the row id into when running from a list_item context.
recordIdFieldstringoptionalRow field whose value seeds recordIdParam. Defaults to "id".
bodyShape'flat' | { wrap: string }optionalBody wrapping: flat (default) or { wrap: key } to nest user-collected params under a key.
methodEnum<'POST' | 'PATCH' | 'PUT' | 'DELETE'>optionalHTTP method for type:"api" actions. Defaults to POST.
bodyExtraRecord<string, any>optionalStatic request-body fields for a type:"api" action, merged last (overrides user params). {{page.<var>}} tokens are resolved by the runtime. This — not params — is where a payload goes.
modeEnum<'create' | 'edit' | 'delete' | 'custom'>optionalSemantic mode of the action.
opensInNewTabbooleanoptionalOpen the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl.
newTabUrlstringoptionalNew-tab path template on this backend, starting with / ({recordId} placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab to the backend origin + this path immediately — no action POST. The endpoint must enforce auth itself. An absolute URL is refused; an external page is a type:'url' action with openIn: 'new-tab'.
onSuccess{ navigate: string; openIn?: Enum<'self' | 'newTab'> }optionalPost-success navigation for type:'api', type:'script' and type:'flow' actions. navigate is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response; for a flow, the completed run's answer); openIn defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior.
arianeveroptional[REMOVED] action.aria was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's label and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required label — the visible button or menu-item text, and the aria-label of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author ariaLabel / ariaDescribedBy / role in the aria block of the placing node: page.components[].aria (the component that renders the actions) or the list view aria. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
_lockEnum<'none' | 'no-overlay' | 'no-delete' | 'full'>optionalItem-level lock — controls overlay & delete (ADR-0010).
_lockReasonstringoptionalHuman-readable reason shown when a write is refused by _lock.
_lockSourceEnum<'artifact' | 'package' | 'env-forced'>optionalLayer that set _lock (artifact | package | env-forced).
_provenanceEnum<'package' | 'org' | 'env-forced'>optionalOrigin of the item (package | org | env-forced).
_packageIdstringoptionalOwning package machine id.
_packageVersionstringoptionalOwning package version.
_lockDocsUrlstringoptionalOptional documentation link surfaced next to _lockReason.
overrides{ package: string; basedOn: string }optionalOverride declaration (ADR-0146 D2): { package, basedOn } — this item of "<base>.custom" replaces the base package's same-named item whole. basedOn is recorded and never judged.

MetadataValidationResult

Properties

PropertyTypeRequiredDescription
validboolean✅Whether the metadata is valid
errors{ path: string; message: string; code?: string }[]optionalValidation errors
warnings{ path: string; message: string }[]optionalValidation warnings

Nested Shape: MetadataValidationResult.errors[number]

PropertyTypeRequiredDescription
pathstring✅JSON path to the invalid field
messagestring✅Error description
codestringoptionalError code

Nested Shape: MetadataValidationResult.warnings[number]

PropertyTypeRequiredDescription
pathstring✅JSON path to the field
messagestring✅Warning description

On this page