diff --git a/.changeset/wild-poems-repeat.md b/.changeset/wild-poems-repeat.md new file mode 100644 index 0000000000..d3de8bd322 --- /dev/null +++ b/.changeset/wild-poems-repeat.md @@ -0,0 +1,30 @@ +--- +"@objectstack/spec": patch +--- + +Strip internal issue-tracker ids from the customer-facing text in `packages/spec` + +Refusal prose, unknown-key guidance, tombstone prescriptions and `.describe()` +docs no longer carry `#NNNN` references. A customer reading a rejected-metadata +error — or the generated reference docs — has no access to this repo's tracker, +so an id there is a citation-shaped token that resolves to nothing in the one +place the sentence most needs to be actionable. + +**FROM → TO, at bucket level** (584 strings across 88 spec sources): + +| bucket | strings | FROM | TO | +|---|---:|---|---| +| `strictObject` guidance / history / aliases | 262 | ``Until #4001 closed this shape these were dropped silently`` | ``Until this shape was closed these were dropped silently`` | +| `retiredKey()` tombstones + `*_RETIRED_KEY_GUIDANCE` | 130 | ``removed in @objectstack/spec 17 (#3894) — use `skills`.`` | ``removed in @objectstack/spec 17 — use `skills`.`` | +| `.describe()` docs prose | 187 | ``Parsed but no runtime consumer yet (liveness #1878/#1893).`` | ``Parsed but no runtime consumer yet.`` | +| zod `message:` (hoisted spelling) | 5 | ``a pair that cannot work as written (#9041).`` | ``a pair that cannot work as written.`` | + +**Kept, deliberately:** ADR ids, protocol and package versions, error codes, and +the `os migrate meta --from ` migration commands. AGENTS.md requires a +tombstone prescription to carry a durable reference, and those are the forms a +customer can actually resolve — the issue id riding beside them was the +strippable half. + +No behaviour changes: no schema accepts or rejects anything it did not before, +and no key, default or error `code` moved. This is the wording of messages and +generated docs only. diff --git a/content/docs/references/ai/agent.mdx b/content/docs/references/ai/agent.mdx index bc4a5f12ea..5664bd01bb 100644 --- a/content/docs/references/ai/agent.mdx +++ b/content/docs/references/ai/agent.mdx @@ -50,18 +50,18 @@ const result = AIModelConfigSchema.parse(data); | **role** | `string` | ✅ | The persona/role (e.g. "Senior Support Engineer") | | **instructions** | `string` | ✅ | System Prompt / Prime Directives | | **model** | `{ provider: Enum<'openai' \| 'azure_openai' \| 'anthropic' \| 'local'>; model: string; temperature: number; maxTokens?: number; … }` | optional | | -| **lifecycle** | `{ id: string; description?: string; contextSchema?: Record; initial: string; … }` | optional | [EXPERIMENTAL — not enforced] State machine defining the agent conversation flow and constraints. Parsed but no runtime consumer yet (liveness #1878/#1893). | +| **lifecycle** | `{ id: string; description?: string; contextSchema?: Record; initial: string; … }` | optional | [EXPERIMENTAL — not enforced] State machine defining the agent conversation flow and constraints. Parsed but no runtime consumer yet. | | **surface** | `Enum<'ask' \| 'build'>` | optional (default: `"ask"`) | Product surface this agent binds ('ask' \| 'build') — ADR-0063 §1 | | **skills** | `string[]` | optional | Skill names to attach (Agent→Skill→Tool architecture) | -| **tools** | `never` | optional | [REMOVED] `agent.tools` was removed in @objectstack/spec 17 (#3894) — use `skills`. An agent reaches exactly the tools its surface-compatible skills declare (ADR-0064), so move each reference into a skill: a platform tool by its registered name, or `action_` for one of your own AI-exposed Actions. This is NOT a rename — there is no key the value moves to: the migration DELETES the key and emits a notice naming each tool that was listed, and you re-declare each one in a skill by hand. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **knowledge** | `never` | optional | [REMOVED] `agent.knowledge` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — declaring knowledge sources/indexes on an agent never scoped retrieval: the `search_knowledge` tool takes `sourceIds` from the LLM's tool-call arguments, not from the agent record. Delete the block. Restrict retrieval at the knowledge-service / source level (per-source permissions), and describe intended grounding in `instructions` so the model asks for the right sources. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **tools** | `never` | optional | [REMOVED] `agent.tools` was removed in @objectstack/spec 17 — use `skills`. An agent reaches exactly the tools its surface-compatible skills declare (ADR-0064), so move each reference into a skill: a platform tool by its registered name, or `action_` for one of your own AI-exposed Actions. This is NOT a rename — there is no key the value moves to: the migration DELETES the key and emits a notice naming each tool that was listed, and you re-declare each one in a skill by hand. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **knowledge** | `never` | optional | [REMOVED] `agent.knowledge` was removed in @objectstack/spec 17.0.0 (audit close-out) — declaring knowledge sources/indexes on an agent never scoped retrieval: the `search_knowledge` tool takes `sourceIds` from the LLM's tool-call arguments, not from the agent record. Delete the block. Restrict retrieval at the knowledge-service / source level (per-source permissions), and describe intended grounding in `instructions` so the model asks for the right sources. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **active** | `boolean` | optional (default: `true`) | | | **access** | `string[]` | optional | Who can chat with this agent | | **permissions** | `string[]` | optional | Required permission-set capabilities | | **planning** | `{ maxIterations: integer }` | optional | Autonomous reasoning and planning configuration | -| **memory** | `{ longTerm?: object; reflectionInterval?: integer }` | optional | [EXPERIMENTAL — not enforced] Agent memory management. Parsed but no runtime consumer yet (liveness #1878/#1893). | -| **guardrails** | `{ maxTokensPerInvocation?: integer; maxExecutionTimeSec?: integer; blockedTopics?: string[] }` | optional | [EXPERIMENTAL — not enforced] Safety guardrails for the agent. Parsed but not enforced — real limits come from the quota service (liveness #1878/#1893). | -| **structuredOutput** | `{ format: Enum<'json_object' \| 'json_schema' \| 'regex' \| 'grammar' \| 'xml'>; schema?: Record; strict: boolean; retryOnValidationFailure: boolean; … }` | optional | [EXPERIMENTAL — not enforced] Structured output format and validation configuration. Parsed but no runtime consumer yet (liveness #1878/#1893). | +| **memory** | `{ longTerm?: object; reflectionInterval?: integer }` | optional | [EXPERIMENTAL — not enforced] Agent memory management. Parsed but no runtime consumer yet. | +| **guardrails** | `{ maxTokensPerInvocation?: integer; maxExecutionTimeSec?: integer; blockedTopics?: string[] }` | optional | [EXPERIMENTAL — not enforced] Safety guardrails for the agent. Parsed but not enforced — real limits come from the quota service. | +| **structuredOutput** | `{ format: Enum<'json_object' \| 'json_schema' \| 'regex' \| 'grammar' \| 'xml'>; schema?: Record; strict: boolean; retryOnValidationFailure: boolean; … }` | optional | [EXPERIMENTAL — not enforced] Structured output format and validation configuration. Parsed but no runtime consumer yet. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this agent. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/ai/skill.mdx b/content/docs/references/ai/skill.mdx index b6a4978367..2ad8013e8d 100644 --- a/content/docs/references/ai/skill.mdx +++ b/content/docs/references/ai/skill.mdx @@ -36,9 +36,9 @@ const result = SkillSchema.parse(data); | **label** | `string` | ✅ | Skill display name | | **description** | `string` | optional | Skill description | | **surface** | `Enum<'ask' \| 'build' \| 'both'>` | optional (default: `"ask"`) | Agent surface this skill binds to ('ask' \| 'build' \| 'both') — ADR-0063 §3; read by the cloud agent runtime only | -| **instructions** | `string` | optional | LLM instructions when skill is active — also served as an MCP prompt (#3905) | +| **instructions** | `string` | optional | LLM instructions when skill is active — also served as an MCP prompt | | **tools** | `string[]` | ✅ | Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) — bound by the cloud agent runtime only | -| **triggerPhrases** | `never` | optional | [REMOVED] `skill.triggerPhrases` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — phrases were never matched against the user's message; skill activation is `triggerConditions` (AND of context field/operator/value) intersected with the agent's `skills[]`, plus explicit /skill-name pinning. Delete the key. Put routing intent in `triggerConditions`; describe intent in `description`/`instructions` for the LLM. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **triggerPhrases** | `never` | optional | [REMOVED] `skill.triggerPhrases` was removed in @objectstack/spec 17.0.0 (audit close-out) — phrases were never matched against the user's message; skill activation is `triggerConditions` (AND of context field/operator/value) intersected with the agent's `skills[]`, plus explicit /skill-name pinning. Delete the key. Put routing intent in `triggerConditions`; describe intent in `description`/`instructions` for the LLM. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **triggerConditions** | `{ field: string; operator: Enum<'eq' \| 'neq' \| 'in' \| 'not_in' \| 'contains'>; value: string \| string[] }[]` | optional | Programmatic activation conditions — evaluated by the cloud agent runtime only | | **active** | `boolean` | optional (default: `true`) | Whether the skill is enabled | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this skill. | diff --git a/content/docs/references/ai/tool.mdx b/content/docs/references/ai/tool.mdx index 59c334975f..bcbf45cfa7 100644 --- a/content/docs/references/ai/tool.mdx +++ b/content/docs/references/ai/tool.mdx @@ -23,7 +23,7 @@ const result = ToolSchema.parse(data); ## Tool -AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Authoring a tool as metadata does NOT make it runnable: this schema has no `implementation`/`handler` field and no framework executor loads a metadata-authored tool. The runtime executes a separately-registered `AIToolDefinition` (cloud `@objectstack/service-ai`); tool metadata is a one-way projection for Studio/discovery. Do not expect a hand-authored tool to run in the open edition (liveness audit #1878/#1892). +AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Authoring a tool as metadata does NOT make it runnable: this schema has no `implementation`/`handler` field and no framework executor loads a metadata-authored tool. The runtime executes a separately-registered `AIToolDefinition` (cloud `@objectstack/service-ai`); tool metadata is a one-way projection for Studio/discovery. Do not expect a hand-authored tool to run in the open edition. ### Properties @@ -33,7 +33,7 @@ AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Auth | **label** | `string` | ✅ | Tool display name | | **description** | `string` | ✅ | Tool description for LLM function calling | | **parameters** | `Record` | ✅ | JSON Schema for tool parameters | -| **outputSchema** | `Record` | optional | [EXPERIMENTAL — not enforced] JSON Schema for tool output. Keys are folded into the tool description only; outputs are not validated (liveness #1878/#1893). | +| **outputSchema** | `Record` | optional | [EXPERIMENTAL — not enforced] JSON Schema for tool output. Keys are folded into the tool description only; outputs are not validated. | | **objectName** | `string` | optional | Target object name (snake_case) | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this tool. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 1aba5dbfac..3fc17cc513 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -53,9 +53,9 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -88,8 +88,8 @@ const result = AnalyticsEndpoint.parse(data); | **limit** | `number` | optional | | | **offset** | `number` | optional | | | **timezone** | `string` | optional | | -| **query** | `never` | optional | [REMOVED] `query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0 (#3878). The `{ cube, query: {...} }` envelope was the dialect of the retired degraded analytics shim (#3891) — the real engine never understood it. Move the query.* fields to the body top level: `{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }`. | -| **format** | `never` | optional | [REMOVED] `format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0 (#3878). It was never implemented — every response is the JSON envelope. Delete the key; for CSV/XLSX use the export surface instead. | +| **query** | `never` | optional | [REMOVED] `query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. The `{ cube, query: {...} }` envelope was the dialect of the retired degraded analytics shim — the real engine never understood it. Move the query.* fields to the body top level: `{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }`. | +| **format** | `never` | optional | [REMOVED] `format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. It was never implemented — every response is the JSON envelope. Delete the key; for CSV/XLSX use the export surface instead. | --- @@ -110,9 +110,9 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -145,9 +145,9 @@ const result = AnalyticsEndpoint.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index e84e79cce6..14cb4740b0 100644 --- a/content/docs/references/api/auth-endpoints.mdx +++ b/content/docs/references/api/auth-endpoints.mdx @@ -55,12 +55,12 @@ const result = AuthEndpointSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **twoFactor** | `boolean` | optional (default: `false`) | Two-factor authentication enabled | -| **passkeys** | `never` | optional | [REMOVED] `features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (#7481, ADR-0049) — it was served from introduction and consumed by nothing: no login UI in any client reads it, and no better-auth passkey plugin is wired behind it, so a deployer who set `plugins.passkeys: true` flipped a switch that changed no behaviour anywhere. Delete the key. There is no replacement flag to read: passkey sign-in is not a capability this platform offers yet. It returns to this payload in the change that ships the login UI (objectui#4179), classified in PUBLIC_AUTH_FEATURES again at that point — do not re-add it ahead of a consumer. | -| **magicLink** | `never` | optional | [REMOVED] `features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (#7481, ADR-0049) — the ADVERTISEMENT was inert, not the capability: no client renders a magic-link sign-in affordance off this flag, so it only told a deployer that a UI existed when none did. Delete the key. The server side is unchanged and still yours to call: `AuthPluginConfig.plugins.magicLink` wires better-auth's magic-link plugin, and `/api/v1/auth/magic-link/send` + `/magic-link/verify` answer exactly as before — drive them from your own UI, or wait for objectui#4179, which restores this flag along with the login UI that reads it. | +| **passkeys** | `never` | optional | [REMOVED] `features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (ADR-0049) — it was served from introduction and consumed by nothing: no login UI in any client reads it, and no better-auth passkey plugin is wired behind it, so a deployer who set `plugins.passkeys: true` flipped a switch that changed no behaviour anywhere. Delete the key. There is no replacement flag to read: passkey sign-in is not a capability this platform offers yet. It returns to this payload in the change that ships the login UI, classified in PUBLIC_AUTH_FEATURES again at that point — do not re-add it ahead of a consumer. | +| **magicLink** | `never` | optional | [REMOVED] `features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (ADR-0049) — the ADVERTISEMENT was inert, not the capability: no client renders a magic-link sign-in affordance off this flag, so it only told a deployer that a UI existed when none did. Delete the key. The server side is unchanged and still yours to call: `AuthPluginConfig.plugins.magicLink` wires better-auth's magic-link plugin, and `/api/v1/auth/magic-link/send` + `/magic-link/verify` answer exactly as before — drive them from your own UI, or wait for the console release that restores this flag along with the login UI that reads it. | | **organization** | `boolean` | optional (default: `false`) | Multi-tenant organization support enabled | | **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) | -| **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, #2766 V1.5) | -| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service (#2780) | +| **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) | +| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service | --- @@ -179,12 +179,12 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **twoFactor** | `boolean` | optional (default: `false`) | Two-factor authentication enabled | -| **passkeys** | `never` | optional | [REMOVED] `features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (#7481, ADR-0049) — it was served from introduction and consumed by nothing: no login UI in any client reads it, and no better-auth passkey plugin is wired behind it, so a deployer who set `plugins.passkeys: true` flipped a switch that changed no behaviour anywhere. Delete the key. There is no replacement flag to read: passkey sign-in is not a capability this platform offers yet. It returns to this payload in the change that ships the login UI (objectui#4179), classified in PUBLIC_AUTH_FEATURES again at that point — do not re-add it ahead of a consumer. | -| **magicLink** | `never` | optional | [REMOVED] `features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (#7481, ADR-0049) — the ADVERTISEMENT was inert, not the capability: no client renders a magic-link sign-in affordance off this flag, so it only told a deployer that a UI existed when none did. Delete the key. The server side is unchanged and still yours to call: `AuthPluginConfig.plugins.magicLink` wires better-auth's magic-link plugin, and `/api/v1/auth/magic-link/send` + `/magic-link/verify` answer exactly as before — drive them from your own UI, or wait for objectui#4179, which restores this flag along with the login UI that reads it. | +| **passkeys** | `never` | optional | [REMOVED] `features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (ADR-0049) — it was served from introduction and consumed by nothing: no login UI in any client reads it, and no better-auth passkey plugin is wired behind it, so a deployer who set `plugins.passkeys: true` flipped a switch that changed no behaviour anywhere. Delete the key. There is no replacement flag to read: passkey sign-in is not a capability this platform offers yet. It returns to this payload in the change that ships the login UI, classified in PUBLIC_AUTH_FEATURES again at that point — do not re-add it ahead of a consumer. | +| **magicLink** | `never` | optional | [REMOVED] `features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (ADR-0049) — the ADVERTISEMENT was inert, not the capability: no client renders a magic-link sign-in affordance off this flag, so it only told a deployer that a UI existed when none did. Delete the key. The server side is unchanged and still yours to call: `AuthPluginConfig.plugins.magicLink` wires better-auth's magic-link plugin, and `/api/v1/auth/magic-link/send` + `/magic-link/verify` answer exactly as before — drive them from your own UI, or wait for the console release that restores this flag along with the login UI that reads it. | | **organization** | `boolean` | optional (default: `false`) | Multi-tenant organization support enabled | | **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) | -| **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, #2766 V1.5) | -| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service (#2780) | +| **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) | +| **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service | --- diff --git a/content/docs/references/api/auth.mdx b/content/docs/references/api/auth.mdx index edf49e334f..0efcbe7669 100644 --- a/content/docs/references/api/auth.mdx +++ b/content/docs/references/api/auth.mdx @@ -126,9 +126,9 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -183,9 +183,9 @@ const result = AuthProvider.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 9f04cbcc8e..d18748fd20 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -92,12 +92,12 @@ const result = AutomationApiErrorCode.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | @@ -131,7 +131,7 @@ const result = AutomationApiErrorCode.parse(data); | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node | -| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes | @@ -157,8 +157,8 @@ const result = AutomationApiErrorCode.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **fallbackNodeId** | `never` | optional | [REMOVED] `flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — the engine routes unrecoverable node errors via per-node fault edges (an edge with type: 'fault'), and never read this key: a fallback configured here silently did not exist. Delete the key and draw a fault edge from the failing node to the handler node instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **fallbackNodeId** | `never` | optional | [REMOVED] `flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (audit close-out) — the engine routes unrecoverable node errors via per-node fault edges (an edge with type: 'fault'), and never read this key: a fallback configured here silently did not exist. Delete the key and draw a fault edge from the failing node to the handler node instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `CreateFlowRequest.protection` @@ -187,9 +187,9 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -206,12 +206,12 @@ const result = AutomationApiErrorCode.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | @@ -253,9 +253,9 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -316,9 +316,9 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -335,12 +335,12 @@ const result = AutomationApiErrorCode.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | @@ -383,9 +383,9 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -442,9 +442,9 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -492,9 +492,9 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -540,9 +540,9 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -590,9 +590,9 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -630,12 +630,12 @@ const result = AutomationApiErrorCode.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | optional | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | optional | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | optional | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | @@ -666,9 +666,9 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -685,12 +685,12 @@ const result = AutomationApiErrorCode.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx index c8f3acc547..aad1018b1d 100644 --- a/content/docs/references/api/batch.mdx +++ b/content/docs/references/api/batch.mdx @@ -50,8 +50,8 @@ const result = BatchConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **atomic** | `boolean` | optional (default: `false`) | Opt-in all-or-nothing. When explicitly true the whole batch runs inside ONE engine transaction: the first failure rolls back every prior write, and the response reports zero successes — each row carries `errors[0].code` ROLLED_BACK (written, then undone), the causal row its own error, and rows never reached NOT_ATTEMPTED. A runtime that cannot roll back REFUSES the request (501 NOT_IMPLEMENTED) rather than silently degrading to best-effort — probe `capabilities.transactionalBatch` on /discovery first. Takes precedence over continueOnError. Default false: sequential best-effort. | | **returnRecords** | `boolean` | optional (default: `false`) | If true, return full record data in response | -| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total` (#7539). | -| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | +| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total`. | +| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec. It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | --- @@ -64,19 +64,19 @@ const result = BatchConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back. | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | ### Nested Shape: `BatchOperationResult.errors[number]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -84,13 +84,13 @@ const result = BatchConfigSchema.parse(data); ### Nested Shape: `BatchOperationResult.droppedFields[number]` -A write-path strip event: caller-supplied fields legally dropped from the payload (#3407) +A write-path strip event: caller-supplied fields legally dropped from the payload | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the write targeted (resolved object name) | | **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | -| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly (#2948), a TRUE readonlyWhen predicate (#3042), or the primary-key strip of a payload id the engine ruled is not an identifier (#6437) | +| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, or the primary-key strip of a payload id the engine ruled is not an identifier | --- @@ -115,8 +115,8 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **atomic** | `boolean` | optional (default: `false`) | Opt-in all-or-nothing. When explicitly true the whole batch runs inside ONE engine transaction: the first failure rolls back every prior write, and the response reports zero successes — each row carries `errors[0].code` ROLLED_BACK (written, then undone), the causal row its own error, and rows never reached NOT_ATTEMPTED. A runtime that cannot roll back REFUSES the request (501 NOT_IMPLEMENTED) rather than silently degrading to best-effort — probe `capabilities.transactionalBatch` on /discovery first. Takes precedence over continueOnError. Default false: sequential best-effort. | | **returnRecords** | `boolean` | optional (default: `false`) | If true, return full record data in response | -| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total` (#7539). | -| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | +| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total`. | +| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec. It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | --- @@ -158,8 +158,8 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **atomic** | `boolean` | optional (default: `false`) | Opt-in all-or-nothing. When explicitly true the whole batch runs inside ONE engine transaction: the first failure rolls back every prior write, and the response reports zero successes — each row carries `errors[0].code` ROLLED_BACK (written, then undone), the causal row its own error, and rows never reached NOT_ATTEMPTED. A runtime that cannot roll back REFUSES the request (501 NOT_IMPLEMENTED) rather than silently degrading to best-effort — probe `capabilities.transactionalBatch` on /discovery first. Takes precedence over continueOnError. Default false: sequential best-effort. | | **returnRecords** | `boolean` | optional (default: `false`) | If true, return full record data in response | -| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total` (#7539). | -| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | +| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total`. | +| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec. It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | --- @@ -184,9 +184,9 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -198,10 +198,10 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back. | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | --- @@ -216,7 +216,7 @@ A cross-object batch strip event: dropped fields plus the operation index | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the write targeted (resolved object name) | | **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | -| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly (#2948), a TRUE readonlyWhen predicate (#3042), or the primary-key strip of a payload id the engine ruled is not an identifier (#6437) | +| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, or the primary-key strip of a payload id the engine ruled is not an identifier | | **index** | `integer` | ✅ | Index of the operation in the request `operations` array | @@ -264,7 +264,7 @@ A cross-object batch strip event: dropped fields plus the operation index | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **results** | `any[]` | ✅ | Per-operation result, index-aligned with the request operations | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'>; index: integer }[]` | optional | Write-observability (#3407/#3431/#3455/#3794): caller-supplied fields the engine LEGALLY stripped from an operation before it was written — static `readonly` (#2948) or a TRUE `readonlyWhen` predicate (#3042). This endpoint is the console record form's save path (master-detail writes parent + children in one transaction), so without it the ONE surface where a user edits a `readonlyWhen` field reported plain success while the value never landed. Each event carries the `index` of its operation. Present ONLY when ≥1 field was dropped; the batch still committed without them (results/success semantics unchanged). Optional — omit-when-empty keeps the shape backward-compatible. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'>; index: integer }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY stripped from an operation before it was written — static `readonly` or a TRUE `readonlyWhen` predicate. This endpoint is the console record form's save path (master-detail writes parent + children in one transaction), so without it the ONE surface where a user edits a `readonlyWhen` field reported plain success while the value never landed. Each event carries the `index` of its operation. Present ONLY when ≥1 field was dropped; the batch still committed without them (results/success semantics unchanged). Optional — omit-when-empty keeps the shape backward-compatible. | ### Nested Shape: `CrossObjectBatchResponse.droppedFields[number]` @@ -274,7 +274,7 @@ A cross-object batch strip event: dropped fields plus the operation index | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the write targeted (resolved object name) | | **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | -| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly (#2948), a TRUE readonlyWhen predicate (#3042), or the primary-key strip of a payload id the engine ruled is not an identifier (#6437) | +| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, or the primary-key strip of a payload id the engine ruled is not an identifier | | **index** | `integer` | ✅ | Index of the operation in the request `operations` array | @@ -295,8 +295,8 @@ A cross-object batch strip event: dropped fields plus the operation index | :--- | :--- | :--- | :--- | | **atomic** | `boolean` | optional (default: `false`) | Opt-in all-or-nothing. When explicitly true the whole batch runs inside ONE engine transaction: the first failure rolls back every prior write, and the response reports zero successes — each row carries `errors[0].code` ROLLED_BACK (written, then undone), the causal row its own error, and rows never reached NOT_ATTEMPTED. A runtime that cannot roll back REFUSES the request (501 NOT_IMPLEMENTED) rather than silently degrading to best-effort — probe `capabilities.transactionalBatch` on /discovery first. Takes precedence over continueOnError. Default false: sequential best-effort. | | **returnRecords** | `boolean` | optional (default: `false`) | If true, return full record data in response | -| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total` (#7539). | -| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | +| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total`. | +| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec. It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | --- @@ -335,8 +335,8 @@ A cross-object batch strip event: dropped fields plus the operation index | :--- | :--- | :--- | :--- | | **atomic** | `boolean` | optional (default: `false`) | Opt-in all-or-nothing. When explicitly true the whole batch runs inside ONE engine transaction: the first failure rolls back every prior write, and the response reports zero successes — each row carries `errors[0].code` ROLLED_BACK (written, then undone), the causal row its own error, and rows never reached NOT_ATTEMPTED. A runtime that cannot roll back REFUSES the request (501 NOT_IMPLEMENTED) rather than silently degrading to best-effort — probe `capabilities.transactionalBatch` on /discovery first. Takes precedence over continueOnError. Default false: sequential best-effort. | | **returnRecords** | `boolean` | optional (default: `false`) | If true, return full record data in response | -| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total` (#7539). | -| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | +| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total`. | +| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec. It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | --- diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index c31b3fdf99..1b5f9f85e8 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,9 +28,9 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +290 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -354,9 +354,9 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -407,9 +407,9 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -472,9 +472,9 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -490,7 +490,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD` (#7532). Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes (#6924). | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -498,13 +498,13 @@ const result = ApiErrorSchema.parse(data); | **limit** | `number` | optional | Max records to return (LIMIT) | | **offset** | `number` | optional | Records to skip (OFFSET) | | **top** | `number` | optional | Alias for limit (OData compatibility) | -| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | -| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and whose nested query selects the related record's own columns. Keep the foreign key in your own projection (`fields: ['title', 'owner_id']`): the relation is carried by that column, so projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever resolved one and the ingress refuses it (`400 INVALID_FIELD`, #7532). | +| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and whose nested query selects the related record's own columns. Keep the foreign key in your own projection (`fields: ['title', 'owner_id']`): the relation is carried by that column, so projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever resolved one and the ingress refuses it (`400 INVALID_FIELD`). | | **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; filter?: any }[]` | optional | Aggregation functions | | **groupBy** | `(string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string })[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | -| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **expand** | `Record` | optional | Recursive relation loading map. Keys are lookup/master_detail field names; values are nested QueryAST objects that control select (`fields`) and filter (`where`, AND-merged with the batch $in), plus further expansion on the related object. The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3; per-parent `limit`/`offset`/`orderBy` are NOT applied on this path. | ### Nested Shape: `ExportRequest.search` @@ -513,12 +513,12 @@ const result = ApiErrorSchema.parse(data); | :--- | :--- | :--- | :--- | | **query** | `string` | ✅ | Search query text | | **fields** | `string[]` | optional | Fields to search in (if not specified, searches all text fields) | -| **fuzzy** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag (#4286). | -| **operator** | `Enum<'and' \| 'or'>` | optional (default: `"or"`) | [EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag (#4286). | -| **boost** | `Record` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results (#4286). | -| **minScore** | `number` | optional | [EXPERIMENTAL — not enforced] Minimum relevance score threshold. No executor scores results (#4286). | -| **language** | `string` | optional | [EXPERIMENTAL — not enforced] Language for text analysis (e.g., "en", "zh", "es"). No executor selects an analyzer (#4286). | -| **highlight** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Search result highlighting. No executor emits highlights (#4286). | +| **fuzzy** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag. | +| **operator** | `Enum<'and' \| 'or'>` | optional (default: `"or"`) | [EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag. | +| **boost** | `Record` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | +| **minScore** | `number` | optional | [EXPERIMENTAL — not enforced] Minimum relevance score threshold. No executor scores results. | +| **language** | `string` | optional | [EXPERIMENTAL — not enforced] Language for text analysis (e.g., "en", "zh", "es"). No executor selects an analyzer. | +| **highlight** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Search result highlighting. No executor emits highlights. | ### Nested Shape: `ExportRequest.aggregations[number]` @@ -527,8 +527,8 @@ const result = ApiErrorSchema.parse(data); | **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | Aggregation function | | **field** | `string` | optional | Field to aggregate (optional for COUNT(*)) | | **alias** | `string` | ✅ | Result column alias | -| **distinct** | `never` | optional | [REMOVED] `query.aggregations[].distinct` was removed in @objectstack/spec 17 (#6815, ADR-0049) — exactly ONE of the six faces that read an aggregation honoured it. The objectql in-memory fallback deduplicated the values before applying the function, while `driver-sql`, `driver-turso`, `driver-mongodb`, `driver-memory` and the service-analytics SQL builder all ignored it — so `{ function: 'sum', field: 'amount', distinct: true }` answered a DEDUPLICATED sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it. Both answers are plausible, so nothing surfaced the divergence. Delete the key. For a deduplicated COUNT the live spelling is the `count_distinct` aggregation function, which every SQL face compiles to `COUNT(DISTINCT field)` (#6409) and the in-memory fallback computes identically. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating is a modelling problem to fix in the data, not a flag on the read. | -| **filter** | `any` | optional | Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate (#10576): lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it. | +| **distinct** | `never` | optional | [REMOVED] `query.aggregations[].distinct` was removed in @objectstack/spec 17 (ADR-0049) — exactly ONE of the six faces that read an aggregation honoured it. The objectql in-memory fallback deduplicated the values before applying the function, while `driver-sql`, `driver-turso`, `driver-mongodb`, `driver-memory` and the service-analytics SQL builder all ignored it — so `{ function: 'sum', field: 'amount', distinct: true }` answered a DEDUPLICATED sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it. Both answers are plausible, so nothing surfaced the divergence. Delete the key. For a deduplicated COUNT the live spelling is the `count_distinct` aggregation function, which every SQL face compiles to `COUNT(DISTINCT field)` and the in-memory fallback computes identically. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating is a modelling problem to fix in the data, not a flag on the read. | +| **filter** | `any` | optional | Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate: lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it. | ### Nested Shape: `ExportRequest.groupBy[number]` @@ -543,7 +543,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD` (#7532). Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes (#6924). | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -551,13 +551,13 @@ const result = ApiErrorSchema.parse(data); | **limit** | `number` | optional | Max records to return (LIMIT) | | **offset** | `number` | optional | Records to skip (OFFSET) | | **top** | `number` | optional | Alias for limit (OData compatibility) | -| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | -| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and whose nested query selects the related record's own columns. Keep the foreign key in your own projection (`fields: ['title', 'owner_id']`): the relation is carried by that column, so projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever resolved one and the ingress refuses it (`400 INVALID_FIELD`, #7532). | +| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and whose nested query selects the related record's own columns. Keep the foreign key in your own projection (`fields: ['title', 'owner_id']`): the relation is carried by that column, so projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever resolved one and the ingress refuses it (`400 INVALID_FIELD`). | | **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; filter?: any }[]` | optional | Aggregation functions | | **groupBy** | `(string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string })[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | -| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **expand** | `Record` | optional | Recursive relation loading map. Keys are lookup/master_detail field names; values are nested QueryAST objects that control select (`fields`) and filter (`where`, AND-merged with the batch $in), plus further expansion on the related object. The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3; per-parent `limit`/`offset`/`orderBy` are NOT applied on this path. | @@ -591,9 +591,9 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -630,9 +630,9 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -703,9 +703,9 @@ Key-value map of record data | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx index 24b7191c58..717b1f8681 100644 --- a/content/docs/references/api/discovery.mdx +++ b/content/docs/references/api/discovery.mdx @@ -133,8 +133,8 @@ const result = ApiRoutesSchema.parse(data); | **search** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend supports full-text search | | **export** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend supports async export | | **chunkedUpload** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend supports chunked (multipart) uploads | -| **transactionalBatch** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, #1604/ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. | -| **websockets** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12, #2462). | +| **transactionalBatch** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, /ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. | +| **websockets** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12). | | **files** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether a file-storage surface (upload/download/attachments) is served | | **analytics** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend serves the analytics / BI query surface | | **ai** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend serves the AI surface (NLQ, chat, agents, suggest) | @@ -287,8 +287,8 @@ Well-known capability flags for frontend intelligent adaptation | **search** | `boolean` | ✅ | Whether the backend supports full-text search | | **export** | `boolean` | ✅ | Whether the backend supports async export | | **chunkedUpload** | `boolean` | ✅ | Whether the backend supports chunked (multipart) uploads | -| **transactionalBatch** | `boolean` | ✅ | Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, #1604/ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. | -| **websockets** | `boolean` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12, #2462). | +| **transactionalBatch** | `boolean` | ✅ | Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, /ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. | +| **websockets** | `boolean` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12). | | **files** | `boolean` | ✅ | Whether a file-storage surface (upload/download/attachments) is served | | **analytics** | `boolean` | ✅ | Whether the backend serves the analytics / BI query surface | | **ai** | `boolean` | ✅ | Whether the backend serves the AI surface (NLQ, chat, agents, suggest) | diff --git a/content/docs/references/api/endpoint.mdx b/content/docs/references/api/endpoint.mdx index 92439d1342..5bdfc27191 100644 --- a/content/docs/references/api/endpoint.mdx +++ b/content/docs/references/api/endpoint.mdx @@ -35,8 +35,8 @@ const result = ApiEndpointSchema.parse(data); | **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP Method | | **summary** | `string` | optional | | | **description** | `string` | optional | | -| **type** | `Enum<'flow' \| 'script' \| 'object_operation' \| 'proxy'>` | ✅ | Implementation type — only 'object_operation' and 'flow' EXECUTE in 17.x. 'script' and 'proxy' stay in the frozen vocabulary (#5040) and are rejected at publish, not parsed and ignored: express script logic as a flow whose script node runs your registered function, and an outbound call as a flow using a declared connector | -| **target** | `string` | optional | Target Flow ID, per `type` — REQUIRED at publish for `type: 'flow'` (the gate refuses a flow endpoint that names no target flow) and UNREAD for `type: 'object_operation'`, so do not write it there: that endpoint is addressed by `objectParams.object` / `.operation`, and a `target` beside them is a dead string nothing checks against `objectParams.object` (#10338 made the key optional for exactly that reason). The vocabulary's other spellings — a Script Name or Proxy URL — stay unreachable in 17.x, since publish rejects `type: 'script'` and `type: 'proxy'` | +| **type** | `Enum<'flow' \| 'script' \| 'object_operation' \| 'proxy'>` | ✅ | Implementation type — only 'object_operation' and 'flow' EXECUTE in 17.x. 'script' and 'proxy' stay in the frozen vocabulary and are rejected at publish, not parsed and ignored: express script logic as a flow whose script node runs your registered function, and an outbound call as a flow using a declared connector | +| **target** | `string` | optional | Target Flow ID, per `type` — REQUIRED at publish for `type: 'flow'` (the gate refuses a flow endpoint that names no target flow) and UNREAD for `type: 'object_operation'`, so do not write it there: that endpoint is addressed by `objectParams.object` / `.operation`, and a `target` beside them is a dead string nothing checks against `objectParams.object` (made the key optional for exactly that reason). The vocabulary's other spellings — a Script Name or Proxy URL — stay unreachable in 17.x, since publish rejects `type: 'script'` and `type: 'proxy'` | | **objectParams** | `{ object?: string; operation?: Enum<'find' \| 'get' \| 'create' \| 'update' \| 'delete'> }` | optional | For object_operation type | | **inputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Request Body to Internal Params | | **outputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Internal Result to Response Body | @@ -57,7 +57,7 @@ const result = ApiEndpointSchema.parse(data); | :--- | :--- | :--- | :--- | | **source** | `string` | ✅ | Source field/path | | **target** | `string` | ✅ | Target field/path | -| **transform** | `string` | optional | Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (#5040 E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object) | +| **transform** | `string` | optional | Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object) | ### Nested Shape: `ApiEndpoint.outputMapping[number]` @@ -65,7 +65,7 @@ const result = ApiEndpointSchema.parse(data); | :--- | :--- | :--- | :--- | | **source** | `string` | ✅ | Source field/path | | **target** | `string` | ✅ | Target field/path | -| **transform** | `string` | optional | Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (#5040 E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object) | +| **transform** | `string` | optional | Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object) | ### Nested Shape: `ApiEndpoint.rateLimit` @@ -86,7 +86,7 @@ const result = ApiEndpointSchema.parse(data); | :--- | :--- | :--- | :--- | | **source** | `string` | ✅ | Source field/path | | **target** | `string` | ✅ | Target field/path | -| **transform** | `string` | optional | Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (#5040 E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object) | +| **transform** | `string` | optional | Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object) | --- diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index f90d782dd8..2c53c3e445 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -42,7 +42,7 @@ const result = EnhancedApiErrorSchema.parse(data); | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +43 more>` | ✅ | Machine-readable error code | | **message** | `string` | ✅ | Human-readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934) — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution (#3821). | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution. | | **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>` | optional | Error category | | **httpStatus** | `number` | optional | HTTP status code | | **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | @@ -50,7 +50,7 @@ const result = EnhancedApiErrorSchema.parse(data); | **retryAfter** | `number` | optional | Seconds to wait before retrying | | **details** | `any` | optional | Additional error context | | **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | -| **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4, #3977) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | +| **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | | **timestamp** | `string` | optional | When the error occurred | | **requestId** | `string` | optional | Request ID for tracking | | **traceId** | `string` | optional | Distributed trace ID | @@ -157,7 +157,7 @@ const result = EnhancedApiErrorSchema.parse(data); | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Machine-readable error code | | **message** | `string` | ✅ | Human-readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934) — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution (#3821). | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution. | | **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| …>` | optional | Error category | | **httpStatus** | `number` | optional | HTTP status code | | **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | @@ -165,7 +165,7 @@ const result = EnhancedApiErrorSchema.parse(data); | **retryAfter** | `number` | optional | Seconds to wait before retrying | | **details** | `any` | optional | Additional error context | | **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | -| **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4, #3977) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | +| **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | | **timestamp** | `string` | optional | When the error occurred | | **requestId** | `string` | optional | Request ID for tracking | | **traceId** | `string` | optional | Distributed trace ID | diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 610ae527f8..cac214c442 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -73,9 +73,9 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -106,11 +106,11 @@ const result = CreateExportJobRequestSchema.parse(data); | **sheet** | `string \| integer` | optional | Worksheet name or 1-based index to read (xlsx; defaults to the first sheet) | | **mapping** | `Record \| { sourceField: string; targetField: string; targetLabel?: string; transform: Enum<'none' \| 'uppercase' \| 'lowercase' \| 'trim' \| 'date_format' \| 'lookup'>; … }[]` | optional | Source column → target field mapping | | **mappingName** | `string` | optional | Name of a registered `mapping` metadata artifact to apply; the server resolves it (org-scoped rows first, then env-wide) and projects columns through it. Mutually exclusive with an inline `mapping` — supplying both is refused (400 CONFLICTING_MAPPING). | -| **dryRun** | `boolean` | optional (default: `false`) | Validate + coerce every row without persisting. The verdict is the engine's own write-path validation, with one boundary an author should know: a preview runs NO automations. Hooks never fire in a dry run (#6037) — a preview that executed user-authored side effects (mail, outbound calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would populate during the real import; for hook-derived fields the real write is authoritative. | +| **dryRun** | `boolean` | optional (default: `false`) | Validate + coerce every row without persisting. The verdict is the engine's own write-path validation, with one boundary an author should know: a preview runs NO automations. Hooks never fire in a dry run — a preview that executed user-authored side effects (mail, outbound calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would populate during the real import; for hook-derived fields the real write is authoritative. | | **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | optional (default: `"insert"`) | insert / update / upsert semantics | | **matchFields** | `string[]` | optional | Fields that identify an existing record (required for update/upsert) | -| **runAutomations** | `boolean` | optional (default: `true`) | Fire triggers/hooks for each imported row. ON by default, and opting out must be explicit: automations always ran on import historically (the engine ignored this flag until #2922), so a caller that wants a silent bulk load sends `runAutomations: false` — omitting the key runs them. This matches platform convention (Salesforce fires triggers on import by default). One boundary: a `dryRun` preview runs NO automations whatever this flag says (#6037). | -| **treatAsHistorical** | `boolean` | optional (default: `false`) | Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates (#3479); and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them (#3493). Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped (#3556). | +| **runAutomations** | `boolean` | optional (default: `true`) | Fire triggers/hooks for each imported row. ON by default, and opting out must be explicit: automations always ran on import historically (the engine ignored this flag until this flag was honoured), so a caller that wants a silent bulk load sends `runAutomations: false` — omitting the key runs them. This matches platform convention (Salesforce fires triggers on import by default). One boundary: a `dryRun` preview runs NO automations whatever this flag says. | +| **treatAsHistorical** | `boolean` | optional (default: `false`) | Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped. | | **trimWhitespace** | `boolean` | optional (default: `true`) | Trim leading/trailing whitespace from string cells | | **nullValues** | `string[]` | optional | Strings treated as null/blank (besides empty string) | | **createMissingOptions** | `boolean` | optional (default: `false`) | Keep unmatched select values instead of failing the row | @@ -218,9 +218,9 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -322,9 +322,9 @@ const result = CreateExportJobRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -485,11 +485,11 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | **sheet** | `string \| integer` | optional | Worksheet name or 1-based index to read (xlsx; defaults to the first sheet) | | **mapping** | `Record \| { sourceField: string; targetField: string; targetLabel?: string; transform: Enum<'none' \| 'uppercase' \| 'lowercase' \| 'trim' \| 'date_format' \| 'lookup'>; … }[]` | optional | Source column → target field mapping | | **mappingName** | `string` | optional | Name of a registered `mapping` metadata artifact to apply; the server resolves it (org-scoped rows first, then env-wide) and projects columns through it. Mutually exclusive with an inline `mapping` — supplying both is refused (400 CONFLICTING_MAPPING). | -| **dryRun** | `boolean` | optional (default: `false`) | Validate + coerce every row without persisting. The verdict is the engine's own write-path validation, with one boundary an author should know: a preview runs NO automations. Hooks never fire in a dry run (#6037) — a preview that executed user-authored side effects (mail, outbound calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would populate during the real import; for hook-derived fields the real write is authoritative. | +| **dryRun** | `boolean` | optional (default: `false`) | Validate + coerce every row without persisting. The verdict is the engine's own write-path validation, with one boundary an author should know: a preview runs NO automations. Hooks never fire in a dry run — a preview that executed user-authored side effects (mail, outbound calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would populate during the real import; for hook-derived fields the real write is authoritative. | | **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | optional (default: `"insert"`) | insert / update / upsert semantics | | **matchFields** | `string[]` | optional | Fields that identify an existing record (required for update/upsert) | -| **runAutomations** | `boolean` | optional (default: `true`) | Fire triggers/hooks for each imported row. ON by default, and opting out must be explicit: automations always ran on import historically (the engine ignored this flag until #2922), so a caller that wants a silent bulk load sends `runAutomations: false` — omitting the key runs them. This matches platform convention (Salesforce fires triggers on import by default). One boundary: a `dryRun` preview runs NO automations whatever this flag says (#6037). | -| **treatAsHistorical** | `boolean` | optional (default: `false`) | Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates (#3479); and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them (#3493). Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped (#3556). | +| **runAutomations** | `boolean` | optional (default: `true`) | Fire triggers/hooks for each imported row. ON by default, and opting out must be explicit: automations always ran on import historically (the engine ignored this flag until this flag was honoured), so a caller that wants a silent bulk load sends `runAutomations: false` — omitting the key runs them. This matches platform convention (Salesforce fires triggers on import by default). One boundary: a `dryRun` preview runs NO automations whatever this flag says. | +| **treatAsHistorical** | `boolean` | optional (default: `false`) | Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped. | | **trimWhitespace** | `boolean` | optional (default: `true`) | Trim leading/trailing whitespace from string cells | | **nullValues** | `string[]` | optional | Strings treated as null/blank (besides empty string) | | **createMissingOptions** | `boolean` | optional (default: `false`) | Keep unmatched select values instead of failing the row | @@ -608,9 +608,9 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -671,9 +671,9 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -784,9 +784,9 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index bf00da501d..7a4817e720 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -60,9 +60,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -85,10 +85,10 @@ const result = AppDefinitionResponseSchema.parse(data); | **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | -| **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **objects** | `never` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **apis** | `never` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key and declare the endpoint one level up, on the STACK: `defineStack({ apis })`. That surface EXECUTES from protocol 17 (#5040). Between #4936 and the executor landing it was refused wholesale — nothing mounted a declared path, so every key including `authRequired` parsed and gated nothing — and that blanket refusal is now narrowed to five per-endpoint publish gates (namespace, supported target, mapping, policy, uniqueness): an endpoint that passes them is mounted and serves traffic as soon as the stack is published. Two things to get right when you move it: the path must sit inside your own carve-out, `/api/v1/apps//` with an explicit `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the only thing that opens anonymous access, and ADR-0121 D6 then requires an armed `rateLimit: { enabled: true, windowMs, maxRequests }`. Read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a security review, not a rename. A route that genuinely needs handler CODE is mounted imperatively instead: resolve the `http.server` service from your plugin context and register the route on `kernel:ready` (NOT the manifest `contributes.routes` key — removed in @objectstack/spec 17, #10726: nothing ever read it, and authoring it is now rejected with its own prescription). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **apis** | `never` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key and declare the endpoint one level up, on the STACK: `defineStack({ apis })`. That surface EXECUTES from protocol 17. Before the executor landed it was refused wholesale — nothing mounted a declared path, so every key including `authRequired` parsed and gated nothing — and that blanket refusal is now narrowed to five per-endpoint publish gates (namespace, supported target, mapping, policy, uniqueness): an endpoint that passes them is mounted and serves traffic as soon as the stack is published. Two things to get right when you move it: the path must sit inside your own carve-out, `/api/v1/apps//` with an explicit `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the only thing that opens anonymous access, and ADR-0121 D6 then requires an armed `rateLimit: { enabled: true, windowMs, maxRequests }`. Read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a security review, not a rename. A route that genuinely needs handler CODE is mounted imperatively instead: resolve the `http.server` service from your plugin context and register the route on `kernel:ready` (NOT the manifest `contributes.routes` key — removed in @objectstack/spec 17: nothing ever read it, and authoring it is now rejected with its own prescription). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `never` | optional | [REMOVED] `App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared sharing that did not exist. Public access is granted per FORM VIEW (`FormView.sharing`, the public-data-collection surface). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **embed** | `never` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **mobileNavigation** | `never` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | @@ -122,9 +122,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -170,9 +170,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -224,9 +224,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -258,9 +258,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -295,9 +295,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -332,9 +332,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -359,9 +359,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -405,9 +405,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -446,9 +446,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -473,9 +473,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -508,9 +508,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -535,9 +535,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -562,9 +562,9 @@ const result = AppDefinitionResponseSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -669,9 +669,9 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -749,9 +749,9 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -788,9 +788,9 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -827,9 +827,9 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -862,9 +862,9 @@ Metadata query with filtering, sorting, and pagination | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -901,9 +901,9 @@ Metadata query with filtering, sorting, and pagination | **titleFormat** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | -| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (#11408, #10144 family). | +| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | -| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused (#6674) — mirror the value onto a stored text field and declare that. | +| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be <= sharingModel in openness. | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 8d2259d99b..a56046e597 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -66,9 +66,9 @@ Get installed package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -128,9 +128,9 @@ List installed packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -205,7 +205,7 @@ Install package request | **capabilities** | `{ implements?: object[]; provides?: object[]; requires?: object[]; extensionPoints?: object[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (#4914, ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier (ADR-0025 §3.6) | @@ -243,9 +243,9 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -327,7 +327,7 @@ Upgrade package request | **capabilities** | `{ implements?: object[]; provides?: object[]; requires?: object[]; extensionPoints?: object[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (#4914, ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier (ADR-0025 §3.6) | @@ -355,9 +355,9 @@ Upgrade package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -411,7 +411,7 @@ Resolve dependencies request | **capabilities** | `{ implements?: object[]; provides?: object[]; requires?: object[]; extensionPoints?: object[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (#4914, ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier (ADR-0025 §3.6) | @@ -439,9 +439,9 @@ Resolve dependencies response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -489,9 +489,9 @@ Uninstall package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -558,9 +558,9 @@ Upload artifact response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 4c6ab840a3..7f219b52e7 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -290,7 +290,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | -| **organizationId** | `string \| null` | optional | Organization (tenant) scope for the read (#8747). With an organization, the trail includes that org's rows AND the env-wide (`organization_id IS NULL`) rows — the env-wide limb is load-bearing, because env-level writes are stamped org-less. `null` and absent are equivalent and both mean the env-wide rows only — the fail-closed direction: an unresolved organization reads env-wide rows, never every tenant's. | +| **organizationId** | `string \| null` | optional | Organization (tenant) scope for the read. With an organization, the trail includes that org's rows AND the env-wide (`organization_id IS NULL`) rows — the env-wide limb is load-bearing, because env-level writes are stamped org-less. `null` and absent are equivalent and both mean the env-wide rows only — the fail-closed direction: an unresolved organization reads env-wide rows, never every tenant's. | | **limit** | `number` | optional | Maximum events to return, newest first. The implementation clamps to [1, 500] and defaults to 100 — out-of-range values are clamped, never refused. | @@ -350,9 +350,9 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **supportsCancellation** | `boolean` | optional (default: `false`) | Supports cancellation | | **supportsRetry** | `boolean` | optional (default: `true`) | Supports retry on failure | | **needsOutbox** | `boolean` | optional (default: `false`) | Dispatch via service-messaging outbox (retry/idempotency/dead-letter) | -| **isAsync** | `never` | optional | [REMOVED] `ActionDescriptor.isAsync` was removed in @objectstack/spec 17 (#6748, ADR-0049) — no execution path ever read it, so declaring it never made a node suspend and omitting it never stopped one. Delete the key. The live mechanism is two-part: an executor suspends by RETURNING `suspend: true` from `execute()`, and its descriptor must declare `supportsPause: true` (plus the `resumeAuthority` its pauses need) or the engine refuses that suspension (#6667). Declaring `isAsync: true` alongside `supportsPause: true` was always redundant; declaring it alone was always inert. | +| **isAsync** | `never` | optional | [REMOVED] `ActionDescriptor.isAsync` was removed in @objectstack/spec 17 (ADR-0049) — no execution path ever read it, so declaring it never made a node suspend and omitting it never stopped one. Delete the key. The live mechanism is two-part: an executor suspends by RETURNING `suspend: true` from `execute()`, and its descriptor must declare `supportsPause: true` (plus the `resumeAuthority` its pauses need) or the engine refuses that suspension. Declaring `isAsync: true` alongside `supportsPause: true` was always redundant; declaring it alone was always inert. | | **handlerContract** | `Enum<'none' \| 'pure'>` | optional (default: `"none"`) | Effect contract for author-supplied code this action invokes: 'none' (invokes none) or 'pure' (must not write — it returns a value and the flow graph persists it) | -| **resumeAuthority** | `Enum<'any' \| 'service'>` | optional | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Carries no schema default so an omission stays observable — and an omission is fail-CLOSED at run time, equivalent to 'service': a pausing node whose pause is open to the generic route must declare 'any' explicitly (#5561) | +| **resumeAuthority** | `Enum<'any' \| 'service'>` | optional | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Carries no schema default so an omission stays observable — and an omission is fail-CLOSED at run time, equivalent to 'service': a pausing node whose pause is open to the generic route must declare 'any' explicitly | | **maturity** | `Enum<'ga' \| 'beta' \| 'reserved'>` | optional (default: `"ga"`) | Runtime maturity: ga (shipped), beta, or reserved (contract only — designers grey this out) | | **source** | `Enum<'builtin' \| 'plugin'>` | optional (default: `"plugin"`) | builtin = platform baseline; plugin = third-party contributed | | **deprecated** | `boolean` | optional (default: `false`) | Deprecated alias kept for back-compat | @@ -426,9 +426,9 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -440,10 +440,10 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back. | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | --- @@ -522,17 +522,17 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **object** | `string` | ✅ | The object name. | | **id** | `string` | ✅ | The ID of the newly created record. | | **record** | `Record` | ✅ | The created record, including server-generated fields (created_at, owner). | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431): caller-supplied fields that were LEGALLY stripped before the record was written — a non-system create cannot seed a static `readonly` column (#3043 ingress strip), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the create still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied fields that were LEGALLY stripped before the record was written — a non-system create cannot seed a static `readonly` column (ingress strip), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the create still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | ### Nested Shape: `CreateDataResponse.droppedFields[number]` -A write-path strip event: caller-supplied fields legally dropped from the payload (#3407) +A write-path strip event: caller-supplied fields legally dropped from the payload | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the write targeted (resolved object name) | | **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | -| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly (#2948), a TRUE readonlyWhen predicate (#3042), or the primary-key strip of a payload id the engine ruled is not an identifier (#6437) | +| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, or the primary-key strip of a payload id the engine ruled is not an identifier | --- @@ -558,17 +558,17 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **object** | `string` | ✅ | Object name | | **records** | `Record[]` | ✅ | Created records | | **count** | `number` | ✅ | Number of records created | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied `readonly` fields the #3043 create-ingress strip removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the union of dropped field names) rather than per-row, because the insert-time strip is static-`readonly` only — schema-uniform, so every row drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the create-ingress strip removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the union of dropped field names) rather than per-row, because the insert-time strip is static-`readonly` only — schema-uniform, so every row drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | ### Nested Shape: `CreateManyDataResponse.droppedFields[number]` -A write-path strip event: caller-supplied fields legally dropped from the payload (#3407) +A write-path strip event: caller-supplied fields legally dropped from the payload | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the write targeted (resolved object name) | | **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | -| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly (#2948), a TRUE readonlyWhen predicate (#3042), or the primary-key strip of a payload id the engine ruled is not an identifier (#6437) | +| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, or the primary-key strip of a payload id the engine ruled is not an identifier | --- @@ -615,8 +615,8 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **atomic** | `boolean` | optional (default: `false`) | Opt-in all-or-nothing. When explicitly true the whole batch runs inside ONE engine transaction: the first failure rolls back every prior write, and the response reports zero successes — each row carries `errors[0].code` ROLLED_BACK (written, then undone), the causal row its own error, and rows never reached NOT_ATTEMPTED. A runtime that cannot roll back REFUSES the request (501 NOT_IMPLEMENTED) rather than silently degrading to best-effort — probe `capabilities.transactionalBatch` on /discovery first. Takes precedence over continueOnError. Default false: sequential best-effort. | | **returnRecords** | `boolean` | optional (default: `false`) | If true, return full record data in response | -| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total` (#7539). | -| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | +| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total`. | +| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec. It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | --- @@ -641,9 +641,9 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -655,10 +655,10 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back. | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | --- @@ -671,9 +671,9 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | -| **organizationId** | `string` | optional | Organization (tenant) scope for the reset (#8805). Load-bearing, not advisory: it selects the ADR-0005 overlay partition, so it decides WHICH row the reset destroys — an org-scoped delete removes that tenant's own overlay, while an org-less delete reaches the environment-wide row and would blank the item for every tenant. Absent = environment-wide. | +| **organizationId** | `string` | optional | Organization (tenant) scope for the reset. Load-bearing, not advisory: it selects the ADR-0005 overlay partition, so it decides WHICH row the reset destroys — an org-scoped delete removes that tenant's own overlay, while an org-less delete reaches the environment-wide row and would blank the item for every tenant. Absent = environment-wide. | | **parentVersion** | `string` | optional | ADR-0008 optimistic-concurrency pin: the version token the caller believes is current (on the REST door, the `If-Match` request header). Present, a concurrent edit is reported as a 409 conflict instead of silently reset; absent = last-write-wins against the current row (Studio's "Reset" button is unpinned). | -| **actor** | `string` | optional | Identity recorded on the delete's history tombstone row. On the REST door this is the request's authenticated identity (one producer, #7749) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system" (#4556). | +| **actor** | `string` | optional | Identity recorded on the delete's history tombstone row. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system". | | **state** | `Enum<'active' \| 'draft'>` | optional | Which lifecycle row to discard: `draft` discards the pending draft overlay only (the still-active overlay, if any, keeps serving); `active` or absent resets the live row. Absent defaults to `active`. | | **dropStorage** | `boolean` | optional | Destructive opt-in, default false: also drop the object's physical table after the metadata row is removed (`object` type + `active` state only; never `sys_` tables). Used by the "discard a previewed object" flow so a publish-to-preview leaves no orphan table. | @@ -834,7 +834,7 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD` (#7532). Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes (#6924). | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -842,13 +842,13 @@ Enable package response | **limit** | `number` | optional | Max records to return (LIMIT) | | **offset** | `number` | optional | Records to skip (OFFSET) | | **top** | `number` | optional | Alias for limit (OData compatibility) | -| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | -| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and whose nested query selects the related record's own columns. Keep the foreign key in your own projection (`fields: ['title', 'owner_id']`): the relation is carried by that column, so projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever resolved one and the ingress refuses it (`400 INVALID_FIELD`, #7532). | +| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and whose nested query selects the related record's own columns. Keep the foreign key in your own projection (`fields: ['title', 'owner_id']`): the relation is carried by that column, so projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever resolved one and the ingress refuses it (`400 INVALID_FIELD`). | | **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; filter?: any }[]` | optional | Aggregation functions | | **groupBy** | `(string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string })[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | -| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **expand** | `Record` | optional | Recursive relation loading map. Keys are lookup/master_detail field names; values are nested QueryAST objects that control select (`fields`) and filter (`where`, AND-merged with the batch $in), plus further expansion on the related object. The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3; per-parent `limit`/`offset`/`orderBy` are NOT applied on this path. | @@ -990,8 +990,8 @@ Enable package response | **search** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend supports full-text search | | **export** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend supports async export | | **chunkedUpload** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend supports chunked (multipart) uploads | -| **transactionalBatch** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, #1604/ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. | -| **websockets** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12, #2462). | +| **transactionalBatch** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, /ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. | +| **websockets** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12). | | **files** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether a file-storage surface (upload/download/attachments) is served | | **analytics** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend serves the analytics / BI query surface | | **ai** | `{ enabled: boolean; features?: Record; description?: string }` | ✅ | Whether the backend serves the AI surface (NLQ, chat, agents, suggest) | @@ -1044,15 +1044,15 @@ Enable package response | **allowRead** | `boolean` | optional (default: `false`) | Read permission | | **allowEdit** | `boolean` | optional (default: `false`) | Edit permission | | **allowDelete** | `boolean` | optional (default: `false`) | Delete permission | -| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | -| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | -| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | -| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | +| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) | +| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) | -| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). | +| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies. | | **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org | | **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org | -| **apiOperations** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| …>[]` | optional | Server-resolved effective API operations for this object (#3391). Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs, #3543), not the authored six-value ApiMethod enum. | +| **apiOperations** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| …>[]` | optional | Server-resolved effective API operations for this object. Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs), not the authored six-value ApiMethod enum. | --- @@ -1113,7 +1113,7 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `string` | ✅ | BCP-47 locale code (e.g., en-US, zh-CN) | -| **label** | `string` | ✅ | Locale label. Equals `code` on every serving surface today — the client names locales for its UI (#7634) | +| **label** | `string` | ✅ | Locale label. Equals `code` on every serving surface today — the client names locales for its UI | | **isDefault** | `boolean` | optional (default: `false`) | Whether this is the default locale | @@ -1159,8 +1159,8 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | | **cacheRequest** | `{ ifNoneMatch?: string; ifModifiedSince?: string; cacheControl?: object }` | optional | Cache validation parameters | -| **locale** | `string` | optional | Resolved response locale. Folded into the ETag so a language switch never returns a stale-locale 304 — metadata is translated *after* the cache validator check (issue #1319). | -| **organizationId** | `string` | optional | Organization (tenant) scope for the read. Selects the org partition in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — exactly as on the uncached read (#9454). Also folded into the ETag, so a scope switch never returns a stale 304 from another scope's cached representation. Absent = environment-wide read. | +| **locale** | `string` | optional | Resolved response locale. Folded into the ETag so a language switch never returns a stale-locale 304 — metadata is translated *after* the cache validator check (issue). | +| **organizationId** | `string` | optional | Organization (tenant) scope for the read. Selects the org partition in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — exactly as on the uncached read. Also folded into the ETag, so a scope switch never returns a stale 304 from another scope's cached representation. Absent = environment-wide read. | ### Nested Shape: `GetMetaItemCachedRequest.cacheRequest` @@ -1279,7 +1279,7 @@ Enable package response | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | | **item** | `any` | ✅ | Metadata item definition | -| **sortability** | `{ fields: Record }` | optional | Per-column sortability projection (#10235) — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | +| **sortability** | `{ fields: Record }` | optional | Per-column sortability projection — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. | | **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` refuse the corresponding write with 403 `ITEM_LOCKED`. Resolved from the document's `_lock`, with the packaged artifact winning over any org overlay. | | **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. | | **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. | @@ -1426,12 +1426,12 @@ Enable package response | **allowRead** | `boolean` | optional (default: `false`) | Read permission | | **allowEdit** | `boolean` | optional (default: `false`) | Edit permission | | **allowDelete** | `boolean` | optional (default: `false`) | Delete permission | -| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | -| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | -| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | -| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | +| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) | +| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) | -| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). | +| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies. | | **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org | | **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org | @@ -1522,7 +1522,7 @@ Get package response ## GetPublishedMetaItemResponse -The published metadata item body, opaque by ruling (#12038 1C). Shape is the item's own metadata-type schema, resolved at read time — never frozen into this contract. +The published metadata item body, opaque by ruling (1C). Shape is the item's own metadata-type schema, resolved at read time — never frozen into this contract. --- @@ -1634,7 +1634,7 @@ The published metadata item body, opaque by ruling (#12038 1C). Shape is the ite | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | @@ -1646,11 +1646,11 @@ The published metadata item body, opaque by ruling (#12038 1C). Shape is the ite | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `GetUiViewResponse.form` @@ -1676,12 +1676,12 @@ The published metadata item body, opaque by ruling (#12038 1C). Shape is the ite | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | -| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11, #7496). | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm (framework#1894 / #2998). | -| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | +| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | +| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `GetUiViewResponse.listViews[string]` @@ -1718,7 +1718,7 @@ The published metadata item body, opaque by ruling (#12038 1C). Shape is the ite | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | @@ -1730,11 +1730,11 @@ The published metadata item body, opaque by ruling (#12038 1C). Shape is the ite | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `GetUiViewResponse.formViews[string]` @@ -1760,12 +1760,12 @@ The published metadata item body, opaque by ruling (#12038 1C). Shape is the ite | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | -| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11, #7496). | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm (framework#1894 / #2998). | -| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | +| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | +| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `GetUiViewResponse.protection` @@ -1793,7 +1793,7 @@ The published metadata item body, opaque by ruling (#12038 1C). Shape is the ite | **skip** | `number` | optional | Records to skip (offset). | | **expand** | `string` | optional | Comma-separated list of lookup/master_detail field names to expand. Resolved to populate array and passed to the engine for batch $in expansion. | | **search** | `string` | optional | Full-text search query. | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **count** | `boolean` | optional | Include total count in response. | @@ -1834,7 +1834,7 @@ Install package request | **capabilities** | `{ implements?: object[]; provides?: object[]; requires?: object[]; extensionPoints?: object[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (#4914, ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier (ADR-0025 §3.6) | @@ -1999,7 +1999,7 @@ Install package response | **read** | `boolean` | optional | Filter by read status | | **type** | `string` | optional | Filter by notification type | | **limit** | `number` | optional | Maximum number of notifications to return — the newest N. Omitted leaves the window to the server, which is not a fixed part of this contract: the platform inbox answers 50 and clamps any requested value into 1..200 rather than refusing it. This endpoint is not paginated — there is no continuation token, so a larger window is the only way to see more. | -| **cursor** | `never` | optional | [REMOVED] `cursor` was removed from GET /api/v1/notifications in @objectstack/spec 17 (#6361, ADR-0049) — it was declared on the request AND the response and honoured on neither: the server reads only `read`/`type`/`limit`, and no emit site ever wrote the response key, so a caller paginating by it re-read the first window forever with no error and no 400. Delete the key; the `cursor` argument of `client.notifications.list()` was removed with it. This route is NOT paginated — it answers the newest `limit` notifications and stops, so ask for a bigger window (`limit`, clamped by the server into 1..200) instead of a next page. A first-class inbox cursor, if ever built, will be a response-minted opaque token, not this key. | +| **cursor** | `never` | optional | [REMOVED] `cursor` was removed from GET /api/v1/notifications in @objectstack/spec 17 (ADR-0049) — it was declared on the request AND the response and honoured on neither: the server reads only `read`/`type`/`limit`, and no emit site ever wrote the response key, so a caller paginating by it re-read the first window forever with no error and no 400. Delete the key; the `cursor` argument of `client.notifications.list()` was removed with it. This route is NOT paginated — it answers the newest `limit` notifications and stops, so ask for a bigger window (`limit`, clamped by the server into 1..200) instead of a next page. A first-class inbox cursor, if ever built, will be a response-minted opaque token, not this key. | --- @@ -2012,7 +2012,7 @@ Install package response | :--- | :--- | :--- | :--- | | **notifications** | `{ id: string; type: string; title: string; body: string; … }[]` | ✅ | List of notifications — the newest window, not a page | | **unreadCount** | `number` | ✅ | Total number of unread notifications | -| **cursor** | `never` | optional | [REMOVED] `cursor` was removed from GET /api/v1/notifications in @objectstack/spec 17 (#6361, ADR-0049) — it was declared on the request AND the response and honoured on neither: the server reads only `read`/`type`/`limit`, and no emit site ever wrote the response key, so a caller paginating by it re-read the first window forever with no error and no 400. Delete the key; the `cursor` argument of `client.notifications.list()` was removed with it. This route is NOT paginated — it answers the newest `limit` notifications and stops, so ask for a bigger window (`limit`, clamped by the server into 1..200) instead of a next page. A first-class inbox cursor, if ever built, will be a response-minted opaque token, not this key. | +| **cursor** | `never` | optional | [REMOVED] `cursor` was removed from GET /api/v1/notifications in @objectstack/spec 17 (ADR-0049) — it was declared on the request AND the response and honoured on neither: the server reads only `read`/`type`/`limit`, and no emit site ever wrote the response key, so a caller paginating by it re-read the first window forever with no error and no 400. Delete the key; the `cursor` argument of `client.notifications.list()` was removed with it. This route is NOT paginated — it answers the newest `limit` notifications and stops, so ask for a bigger window (`limit`, clamped by the server into 1..200) instead of a next page. A first-class inbox cursor, if ever built, will be a response-minted opaque token, not this key. | ### Nested Shape: `ListNotificationsResponse.notifications[number]` @@ -2171,11 +2171,11 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | -| **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem` (#12194). | -| **organizationId** | `string` | optional | Organization (tenant) scope for the promotion. The implementation resolves the draft through the org partition (ADR-0005, #8805), so a draft authored org-scoped must be published under the same scope or the lookup answers 404 `[no_draft]`. Absent = environment-wide. | -| **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer, #7749) — never a caller-supplied header. | +| **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem`. | +| **organizationId** | `string` | optional | Organization (tenant) scope for the promotion. The implementation resolves the draft through the org partition (ADR-0005), so a draft authored org-scoped must be published under the same scope or the lookup answers 404 `[no_draft]`. Absent = environment-wide. | +| **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. | | **message** | `string` | optional | Optional human-readable note recorded with the publish history event. | -| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=` on the REST door; #10063 / #10350). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft` on the untouched path. | +| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=` on the REST door). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft` on the untouched path. | --- @@ -2192,8 +2192,8 @@ Installed package with runtime lifecycle state | **seedApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string; … }` | optional | Outcome of materializing a published `seed` body into data rows. Present ONLY when the published type is `seed` — publishing a seed is what makes its rows live, so the load rides along with the metadata promotion. Best-effort: a seed-load problem is surfaced here, never thrown, so a caller must check `seedApplied.success` instead of assuming the 200 covered the data. Absent on the batch path, which suppresses the per-item apply and loads every seed body in one later pass. | | **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; error?: string }` | optional | Outcome of the ADR-0086 P2 publish-time materializer — the step that projects the published body into its data-plane row (e.g. `permission` → `sys_permission_set`, under the owning package). Present ONLY when a materializer is registered for this metadata type, which is why it is optional: its absence means "no materializer ran", never "it failed". Best-effort, same contract as `seedApplied`. | | **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model. The same receipt `{@link SaveMetaItemResponseSchema}` carries, because the projector runs on BOTH write doors: a direct active save and this draft→active promotion. Present ONLY when a projector is registered for this metadata type. Best-effort — a projector failure is reported here and logged, never thrown. | -| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the #4463 runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to the DRAFT body this promotion carried to `active` (#9176, the same key `SaveMetaItemResponseSchema` carries, because the gate runs on both write doors by #4463 D1). The promotion SUCCEEDED; these are what the gate has to say about it anyway. Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean publish's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the promotion and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. This door is the one Studio's designer takes on every edit (draft save, then publish), and a Studio / MCP / AI author has no CLI at all — which is the gap #4463 exists to close. | -| **message** | `string` | optional | Human-readable receipt, e.g. `Published draft — type=view, name=cases [seq=3]`. The producer sets it on every publish today; it stays optional to match the producer's own signature and its `SaveMetaItemResponse` twin, and because an absent human-readable string strips no data — the failure mode #5745 exists to prevent. | +| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to the DRAFT body this promotion carried to `active` (the same key `SaveMetaItemResponseSchema` carries, because the gate runs on both write doors, D1). The promotion SUCCEEDED; these are what the gate has to say about it anyway. Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean publish's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the promotion and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. This door is the one Studio's designer takes on every edit (draft save, then publish), and a Studio / MCP / AI author has no CLI at all — which is the gap this key exists to close. | +| **message** | `string` | optional | Human-readable receipt, e.g. `Published draft — type=view, name=cases [seq=3]`. The producer sets it on every publish today; it stays optional to match the producer's own signature and its `SaveMetaItemResponse` twin, and because an absent human-readable string strips no data — the failure mode this key exists to prevent. | ### Nested Shape: `PublishMetaItemResponse.seedApplied` @@ -2226,7 +2226,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **rule** | `string` | ✅ | Stable diagnostic rule id (`flow-multi-write-unfiltered`, `approval-expression-invalid`, …). Machine-readable and stable across releases — the key a renderer groups or suppresses by. | -| **path** | `string` | ✅ | Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), so an editor can jump to the offending key. May be empty when the finding is about the item as a whole. For the collection-resident write types (`object` / `permission` / `book`) the TOP-LEVEL collection entry is keyed by NAME (`objects.acme_invoice.sharingModel`), never by an array index — the gate evaluates against a private per-write snapshot whose indexes no caller can resolve (#10064). Every other write type is the sole member of its own collection, so its `[0]` is trivially stable and stays positional (`flows[0]...`), as do nested positions inside one named item (`objects.acme_invoice.indexes[1]`), which index the author's own document. | +| **path** | `string` | ✅ | Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), so an editor can jump to the offending key. May be empty when the finding is about the item as a whole. For the collection-resident write types (`object` / `permission` / `book`) the TOP-LEVEL collection entry is keyed by NAME (`objects.acme_invoice.sharingModel`), never by an array index — the gate evaluates against a private per-write snapshot whose indexes no caller can resolve. Every other write type is the sole member of its own collection, so its `[0]` is trivially stable and stays positional (`flows[0]...`), as do nested positions inside one named item (`objects.acme_invoice.indexes[1]`), which index the author's own document. | | **where** | `string` | ✅ | Human-readable location — `flow "leave_approval" · node "approve"`. Prose for a person; use `path` for anything mechanical. | | **message** | `string` | ✅ | What is wrong, in the rule author's own words. | | **hint** | `string` | ✅ | How to fix it. | @@ -2241,19 +2241,19 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **success** | `boolean` | ✅ | True only when every pending draft promoted (`failed` empty) AND at least one item published. A pre-flight refusal or an ADR-0067 D2 rollback answers false on a 200 — but so does a publish with nothing to promote, so false alone is NOT a refusal: read `outcome` (#10462), not this boolean or the HTTP status. Always equal to `outcome === 'published'` (pinned). It does NOT cover the best-effort receipts below, each of which reports its own `success`. | -| **outcome** | `Enum<'published' \| 'refused' \| 'nothing_to_publish'>` | ✅ | First-class discriminant for WHICH exit answered (#10462) — the fact `success` compresses into one boolean. `published`: at least one draft promoted and none refused. `refused`: the batch was refused — a pre-flight violation or the ADR-0067 D2 all-or-nothing rollback; the per-item story is in `failed[]`, which is non-empty exactly on this outcome (the invariant consumers previously had to reverse-engineer, now stated by the producer). `nothing_to_publish`: the package had no pending drafts — nothing landed AND nothing was refused; `success` stays false (a no-op is not a successful publish), which before this field made that answer indistinguishable from a refusal. Producer invariants, pinned in the conformance suites: `success === (outcome === 'published')`; `refused` if and only if `failed.length > 0`; `nothing_to_publish` if and only if `published.length === 0 && failed.length === 0`. Values are lowercase snake, matching the `sys_metadata_audit` outcome vocabulary. | +| **success** | `boolean` | ✅ | True only when every pending draft promoted (`failed` empty) AND at least one item published. A pre-flight refusal or an ADR-0067 D2 rollback answers false on a 200 — but so does a publish with nothing to promote, so false alone is NOT a refusal: read `outcome`, not this boolean or the HTTP status. Always equal to `outcome === 'published'` (pinned). It does NOT cover the best-effort receipts below, each of which reports its own `success`. | +| **outcome** | `Enum<'published' \| 'refused' \| 'nothing_to_publish'>` | ✅ | First-class discriminant for WHICH exit answered — the fact `success` compresses into one boolean. `published`: at least one draft promoted and none refused. `refused`: the batch was refused — a pre-flight violation or the ADR-0067 D2 all-or-nothing rollback; the per-item story is in `failed[]`, which is non-empty exactly on this outcome (the invariant consumers previously had to reverse-engineer, now stated by the producer). `nothing_to_publish`: the package had no pending drafts — nothing landed AND nothing was refused; `success` stays false (a no-op is not a successful publish), which before this field made that answer indistinguishable from a refusal. Producer invariants, pinned in the conformance suites: `success === (outcome === 'published')`; `refused` if and only if `failed.length > 0`; `nothing_to_publish` if and only if `published.length === 0 && failed.length === 0`. Values are lowercase snake, matching the `sys_metadata_audit` outcome vocabulary. | | **publishedCount** | `integer` | ✅ | Number of drafts promoted to active — `published.length`. 0 on every refusal path (the batch is all-or-nothing, ADR-0067 D2). | | **failedCount** | `integer` | ✅ | Number of items that did not publish — `failed.length`. On a rollback this counts the WHOLE batch: the causal item plus every sibling marked BATCH_ABORTED. | | **published** | `{ type: string; name: string; version: string; advisories?: object[] }[]` | ✅ | Every draft promoted to active, in publish order. Empty on every refusal path. | | **failed** | `{ type: string; name: string; error: string; code?: string; … }[]` | ✅ | Items that did not publish. Because the batch is all-or-nothing (ADR-0067 D2), a non-empty list means NOTHING landed: `published: []`, `publishedCount: 0`. | | **seedApplied** | `{ success: boolean; inserted?: integer; updated?: integer; error?: string; … }` | optional | Aggregate outcome of materializing EVERY published `seed` body in one multi-pass loader run (cross-seed references need the whole set). Present ONLY when the batch published at least one seed. Two producers, one key: the batch itself self-applies (`applySeedBodies`), and the REST door back-fills the same key for custom protocols that do not — never both (an externalId-less seed would double-insert). Best-effort: a seed problem is surfaced here, never thrown. | | **materializeApplied** | `{ success: boolean; inserted: integer; updated: integer; failures: object[] }` | optional | ADR-0086 P2 — aggregate result of publish-time materializers across the batch (e.g. `permission` → `sys_permission_set`), including side-effect failures surfaced by the per-item effects loop. Present ONLY when at least one published item had a registered materializer or a side-effect failure. Best-effort, same contract as `seedApplied`. | -| **probes** | `any` | optional | ADR-0038 L3 post-publish runtime probe report — one real read per published artifact (seeded objects have rows, views are readable, widget dataset selections execute). DELIBERATELY OPAQUE in this contract (#9406): the key is declared and carried through verbatim, but its inner shape is intentionally not modeled until a consumer needs a field of it. Present only when something was publishable; probes never fail the publish. | +| **probes** | `any` | optional | ADR-0038 L3 post-publish runtime probe report — one real read per published artifact (seeded objects have rows, views are readable, widget dataset selections execute). DELIBERATELY OPAQUE in this contract: the key is declared and carried through verbatim, but its inner shape is intentionally not modeled until a consumer needs a field of it. Present only when something was publishable; probes never fail the publish. | | **commitId** | `string` | optional | ADR-0067 — id of the commit this publish recorded. Absent when nothing published. | -| **unhiddenApps** | `string[]` | optional | ADR-0045 §3 — names of the apps whose `_unpublished` gate this publish cleared (publish = live AND visible; a materialized additive build has no drafts, only this flip). Attached by the REST door, not the protocol helper. Present ONLY when at least one app flipped — on a mid-loop failure it names the apps that DID persist, beside `unhideError` (#5242's split report). Spelling is a permanent wire contract (see the door's #6955 note). | -| **unhideError** | `string` | optional | Present when the ADR-0045 visibility flip failed (wholly or partway): the drafts ARE published, but apps still stored `_unpublished: true` stay externally unobservable. Client-facing text only — undeclared driver text is withheld per ADR-0112 (#8516); the full cause is in the server log. The route is idempotent: re-run it once the cause is resolved. | -| **rebindError** | `string` | optional | Present when the post-publish `metadata:reloaded` announce failed: everything is published and stored, but boot-cached consumers keep the pre-publish view until re-run or restart (a newly published record-triggered flow does not bind its trigger). Client-facing text only, same ADR-0112 withhold as `unhideError` (#8516). | +| **unhiddenApps** | `string[]` | optional | ADR-0045 §3 — names of the apps whose `_unpublished` gate this publish cleared (publish = live AND visible; a materialized additive build has no drafts, only this flip). Attached by the REST door, not the protocol helper. Present ONLY when at least one app flipped — on a mid-loop failure it names the apps that DID persist, beside `unhideError` ('s split report). Spelling is a permanent wire contract (see the door's note). | +| **unhideError** | `string` | optional | Present when the ADR-0045 visibility flip failed (wholly or partway): the drafts ARE published, but apps still stored `_unpublished: true` stay externally unobservable. Client-facing text only — undeclared driver text is withheld per ADR-0112; the full cause is in the server log. The route is idempotent: re-run it once the cause is resolved. | +| **rebindError** | `string` | optional | Present when the post-publish `metadata:reloaded` announce failed: everything is published and stored, but boot-cached consumers keep the pre-publish view until re-run or restart (a newly published record-triggered flow does not bind its trigger). Client-facing text only, same ADR-0112 withhold as `unhideError`. | ### Nested Shape: `PublishPackageDraftsResponse.published[number]` @@ -2262,7 +2262,7 @@ Installed package with runtime lifecycle state | **type** | `string` | ✅ | Metadata type of the promoted draft (canonical singular). | | **name** | `string` | ✅ | Item name of the promoted draft. | | **version** | `string` | ✅ | Content hash of the just-promoted body — the same ADR-0008 optimistic-concurrency token the single-item doors return: echo it back as `If-Match` on the next write to this item. Opaque to callers; currently `sha256:<64 hex chars>`, but the format is not part of this contract. | -| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings the #4463 runtime authoring gate raised against THIS draft's promotion (#9343 — the same element shape and the same omitted-when-empty discipline as `PublishMetaItemResponseSchema.advisories`, riding each element rather than a parallel top-level map). Present ONLY when at least one finding was raised — an empty array is never emitted, so an advisory-free batch's response bytes are unchanged. Advisory by construction: every entry is `warning`/`info`, because an `error` finding refuses the promotion and — the batch being all-or-nothing — aborts the whole batch as `failed[]` instead; `failed[]` elements never carry this key. | +| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings the runtime authoring gate raised against THIS draft's promotion (the same element shape and the same omitted-when-empty discipline as `PublishMetaItemResponseSchema.advisories`, riding each element rather than a parallel top-level map). Present ONLY when at least one finding was raised — an empty array is never emitted, so an advisory-free batch's response bytes are unchanged. Advisory by construction: every entry is `warning`/`info`, because an `error` finding refuses the promotion and — the batch being all-or-nothing — aborts the whole batch as `failed[]` instead; `failed[]` elements never carry this key. | ### Nested Shape: `PublishPackageDraftsResponse.failed[number]` @@ -2270,9 +2270,9 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type of the item that did not publish. | | **name** | `string` | ✅ | Item name. | -| **error** | `string` | ✅ | What refused it. On a rollback, the causal item carries its real error and every sibling carries the all-or-nothing explanation. A refusal that produced structured findings states a one-sentence HEADLINE here (what failed, where, which rules, how many); the per-path detail rides `issues[]` instead of being restated in this string (#10524 — consumers rendering both channels were showing every finding twice). | +| **error** | `string` | ✅ | What refused it. On a rollback, the causal item carries its real error and every sibling carries the all-or-nothing explanation. A refusal that produced structured findings states a one-sentence HEADLINE here (what failed, where, which rules, how many); the per-path detail rides `issues[]` instead of being restated in this string (consumers rendering both channels were showing every finding twice). | | **code** | `string` | optional | Machine code for the refusal class (SCREAMING_SNAKE, ADR-0112 vocabulary) — e.g. a pre-flight violation code, or BATCH_ABORTED on the non-causal items of a rolled-back batch. | -| **issues** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | The structured findings behind the refusal, when the refusing error carried them — today the #4463 author-time gate's INVALID_METADATA refusal on the causal item. The producer has emitted this key since #8333; declaring it (#10524) is what lets a typed consumer read it back, and what lets `error` stay a headline without losing the per-path detail. Same element shape as `published[].advisories` and the single-item 422's `issues[]` (#4717 — one dialect, declared once). Present ONLY on the causal item and only when the refusal produced structured findings; BATCH_ABORTED siblings never carry it. Absent means "this refusal carried no structured findings", never "no problems". | +| **issues** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | The structured findings behind the refusal, when the refusing error carried them — today the author-time gate's INVALID_METADATA refusal on the causal item. The producer has emitted this key; declaring it is what lets a typed consumer read it back, and what lets `error` stay a headline without losing the per-path detail. Same element shape as `published[].advisories` and the single-item 422's `issues[]` (— one dialect, declared once). Present ONLY on the causal item and only when the refusal produced structured findings; BATCH_ABORTED siblings never carry it. Absent means "this refusal carried no structured findings", never "no problems". | ### Nested Shape: `PublishPackageDraftsResponse.seedApplied` @@ -2281,9 +2281,9 @@ Installed package with runtime lifecycle state | **success** | `boolean` | ✅ | False when the seed rows did not fully land. The publish itself still succeeded — check this rather than assuming data went live. | | **inserted** | `integer` | optional | Rows created by the externalId-keyed upsert. Optional ONLY because the route-level fallback producer (custom protocols that do not self-apply) reports early failures without counters; the in-batch producer always emits both counters. | | **updated** | `integer` | optional | Rows updated by the externalId-keyed upsert. Same optionality rationale as `inserted`. | -| **error** | `string` | optional | Single failure message, present when the apply failed before the loader ran (including "no readable seed bodies"). When the failure is the seed bodies' own schema refusal, this is a one-sentence headline and the per-path detail rides `issues[]` (#10524). | +| **error** | `string` | optional | Single failure message, present when the apply failed before the loader ran (including "no readable seed bodies"). When the failure is the seed bodies' own schema refusal, this is a one-sentence headline and the per-path detail rides `issues[]`. | | **errors** | `any[]` | optional | Per-record failures reported by the seed loader, plus any seed-body read failures. May be present and empty on a clean load. | -| **issues** | `{ path: string; message: string; code?: string }[]` | optional | Structured spec-validation findings behind `error`, present when the apply was refused by the seed bodies' own schema — the declared 422 `seedRequestValidationError` mints (#8443). The per-path detail lives HERE, once; `error` stays a one-sentence headline (#10524). Absent on non-validation failures (driver faults, unreadable bodies), whose whole story is `error` / `errors[]`. | +| **issues** | `{ path: string; message: string; code?: string }[]` | optional | Structured spec-validation findings behind `error`, present when the apply was refused by the seed bodies' own schema — the declared 422 `seedRequestValidationError` mints. The per-path detail lives HERE, once; `error` stays a one-sentence headline. Absent on non-validation failures (driver faults, unreadable bodies), whose whole story is `error` / `errors[]`. | ### Nested Shape: `PublishPackageDraftsResponse.materializeApplied` @@ -2452,7 +2452,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **rule** | `string` | ✅ | Stable diagnostic rule id (`flow-multi-write-unfiltered`, `approval-expression-invalid`, …). Machine-readable and stable across releases — the key a renderer groups or suppresses by. | -| **path** | `string` | ✅ | Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), so an editor can jump to the offending key. May be empty when the finding is about the item as a whole. For the collection-resident write types (`object` / `permission` / `book`) the TOP-LEVEL collection entry is keyed by NAME (`objects.acme_invoice.sharingModel`), never by an array index — the gate evaluates against a private per-write snapshot whose indexes no caller can resolve (#10064). Every other write type is the sole member of its own collection, so its `[0]` is trivially stable and stays positional (`flows[0]...`), as do nested positions inside one named item (`objects.acme_invoice.indexes[1]`), which index the author's own document. | +| **path** | `string` | ✅ | Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), so an editor can jump to the offending key. May be empty when the finding is about the item as a whole. For the collection-resident write types (`object` / `permission` / `book`) the TOP-LEVEL collection entry is keyed by NAME (`objects.acme_invoice.sharingModel`), never by an array index — the gate evaluates against a private per-write snapshot whose indexes no caller can resolve. Every other write type is the sole member of its own collection, so its `[0]` is trivially stable and stays positional (`flows[0]...`), as do nested positions inside one named item (`objects.acme_invoice.indexes[1]`), which index the author's own document. | | **where** | `string` | ✅ | Human-readable location — `flow "leave_approval" · node "approve"`. Prose for a person; use `path` for anything mechanical. | | **message** | `string` | ✅ | What is wrong, in the rule author's own words. | | **hint** | `string` | ✅ | How to fix it. | @@ -2468,7 +2468,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | -| **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). Slash-compound names are refused at the publish door (#12176). | +| **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). Slash-compound names are refused at the publish door. | | **item** | `any` | ✅ | Metadata item definition | @@ -2485,7 +2485,7 @@ Installed package with runtime lifecycle state | **seq** | `integer` | ✅ | Monotonic sequence number of the metadata event this write appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. | | **state** | `Enum<'draft' \| 'active'>` | ✅ | Lifecycle the body was written into: "draft" when the request asked for draft mode (`?mode=draft`), otherwise "active" (published and live). A draft is staged only — it is not served to the runtime until published. | | **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `permission` → `sys_permission_set`). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. | -| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the #4463 runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (#4717, closing #4463 D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap #4463 exists to close. The gate runs on both write doors (#4463 D1), and both report: `POST /meta/:type/:name/publish` carries the same key on `PublishMetaItemResponseSchema` (#9176). | +| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (closing D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap this key exists to close. The gate runs on both write doors (D1), and both report: `POST /meta/:type/:name/publish` carries the same key on `PublishMetaItemResponseSchema`. | | **message** | `string` | optional | | ### Nested Shape: `SaveMetaItemResponse.projectionApplied` @@ -2500,7 +2500,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **rule** | `string` | ✅ | Stable diagnostic rule id (`flow-multi-write-unfiltered`, `approval-expression-invalid`, …). Machine-readable and stable across releases — the key a renderer groups or suppresses by. | -| **path** | `string` | ✅ | Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), so an editor can jump to the offending key. May be empty when the finding is about the item as a whole. For the collection-resident write types (`object` / `permission` / `book`) the TOP-LEVEL collection entry is keyed by NAME (`objects.acme_invoice.sharingModel`), never by an array index — the gate evaluates against a private per-write snapshot whose indexes no caller can resolve (#10064). Every other write type is the sole member of its own collection, so its `[0]` is trivially stable and stays positional (`flows[0]...`), as do nested positions inside one named item (`objects.acme_invoice.indexes[1]`), which index the author's own document. | +| **path** | `string` | ✅ | Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), so an editor can jump to the offending key. May be empty when the finding is about the item as a whole. For the collection-resident write types (`object` / `permission` / `book`) the TOP-LEVEL collection entry is keyed by NAME (`objects.acme_invoice.sharingModel`), never by an array index — the gate evaluates against a private per-write snapshot whose indexes no caller can resolve. Every other write type is the sole member of its own collection, so its `[0]` is trivially stable and stays positional (`flows[0]...`), as do nested positions inside one named item (`objects.acme_invoice.indexes[1]`), which index the author's own document. | | **where** | `string` | ✅ | Human-readable location — `flow "leave_approval" · node "approve"`. Prose for a person; use `path` for anything mechanical. | | **message** | `string` | ✅ | What is wrong, in the rule author's own words. | | **hint** | `string` | ✅ | How to fix it. | @@ -2518,7 +2518,7 @@ Installed package with runtime lifecycle state | **object** | `string` | ✅ | Name of the object the hit belongs to. | | **id** | `string` | ✅ | ID of the matched record. | | **title** | `string` | ✅ | Display title for the hit, resolved in order: the object's `titleFormat` template → the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → conventional name fields → first/last name → the record ID as a string. | -| **snippet** | `string` | optional | Excerpt cut around the first matched term in a searchable text column, ellipsized at both ends when truncated. ABSENT when no source column literally contains a term (e.g. a pinyin companion match, #7643) — absence is a correct answer, not a miss. | +| **snippet** | `string` | optional | Excerpt cut around the first matched term in a searchable text column, ellipsized at both ends when truncated. ABSENT when no source column literally contains a term (e.g. a pinyin companion match) — absence is a correct answer, not a miss. | | **record** | `Record` | ✅ | The matched record as the engine's find path returns it (row-level security applied, internal fields already stripped). Object-specific — no cross-object field shape is promised beyond "a record of the named object". | @@ -2532,7 +2532,7 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **query** | `string` | ✅ | The TRIMMED query text the sweep ran with — empty string when the request carried none (the no-scan short-circuit). | | **hits** | `{ object: string; id: string; title: string; snippet?: string; … }[]` | ✅ | Matched records across objects, in scan order, capped at the overall `limit` (default 20, max 100) with at most `perObject` (default 5, max 25) per object. | -| **totalObjects** | `number` | ✅ | Number of objects the sweep actually SCANNED (searchable, API-enabled, with a resolvable search-field set) — not the number of objects with hits. An object whose table was never provisioned is skipped and not counted (#8896). | +| **totalObjects** | `number` | ✅ | Number of objects the sweep actually SCANNED (searchable, API-enabled, with a resolvable search-field set) — not the number of objects with hits. An object whose table was never provisioned is skipped and not counted. | | **totalHits** | `number` | ✅ | Number of hits returned — equals `hits.length`. NOT a deployment-wide total-match count: matches beyond `limit` / `perObject` are not counted. | | **truncated** | `boolean` | ✅ | True when the sweep stopped at the overall `limit` — more matches may exist beyond the returned set. | @@ -2543,7 +2543,7 @@ Installed package with runtime lifecycle state | **object** | `string` | ✅ | Name of the object the hit belongs to. | | **id** | `string` | ✅ | ID of the matched record. | | **title** | `string` | ✅ | Display title for the hit, resolved in order: the object's `titleFormat` template → the declared primary-title pointer (`nameField`, ADR-0079; deprecated alias `displayNameField` still honored) → conventional name fields → first/last name → the record ID as a string. | -| **snippet** | `string` | optional | Excerpt cut around the first matched term in a searchable text column, ellipsized at both ends when truncated. ABSENT when no source column literally contains a term (e.g. a pinyin companion match, #7643) — absence is a correct answer, not a miss. | +| **snippet** | `string` | optional | Excerpt cut around the first matched term in a searchable text column, ellipsized at both ends when truncated. ABSENT when no source column literally contains a term (e.g. a pinyin companion match) — absence is a correct answer, not a miss. | | **record** | `Record` | ✅ | The matched record as the engine's find path returns it (row-level security applied, internal fields already stripped). Object-specific — no cross-object field shape is promised beyond "a record of the named object". | @@ -2666,17 +2666,17 @@ Uninstall package response | **object** | `string` | ✅ | Object name | | **id** | `string` | ✅ | Updated record ID | | **record** | `Record` | ✅ | Updated record | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431): caller-supplied fields the engine LEGALLY stripped from the write before persisting — static `readonly` (#2948) or a TRUE `readonlyWhen` predicate (#3042). Present ONLY when ≥1 field was dropped; the update still succeeded without them (status/success semantics unchanged — stripping is legitimate, not an error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients that only read `record`. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY stripped from the write before persisting — static `readonly` or a TRUE `readonlyWhen` predicate. Present ONLY when ≥1 field was dropped; the update still succeeded without them (status/success semantics unchanged — stripping is legitimate, not an error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients that only read `record`. | ### Nested Shape: `UpdateDataResponse.droppedFields[number]` -A write-path strip event: caller-supplied fields legally dropped from the payload (#3407) +A write-path strip event: caller-supplied fields legally dropped from the payload | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the write targeted (resolved object name) | | **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | -| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly (#2948), a TRUE readonlyWhen predicate (#3042), or the primary-key strip of a payload id the engine ruled is not an identifier (#6437) | +| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, or the primary-key strip of a payload id the engine ruled is not an identifier | --- @@ -2704,8 +2704,8 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **atomic** | `boolean` | optional (default: `false`) | Opt-in all-or-nothing. When explicitly true the whole batch runs inside ONE engine transaction: the first failure rolls back every prior write, and the response reports zero successes — each row carries `errors[0].code` ROLLED_BACK (written, then undone), the causal row its own error, and rows never reached NOT_ATTEMPTED. A runtime that cannot roll back REFUSES the request (501 NOT_IMPLEMENTED) rather than silently degrading to best-effort — probe `capabilities.transactionalBatch` on /discovery first. Takes precedence over continueOnError. Default false: sequential best-effort. | | **returnRecords** | `boolean` | optional (default: `false`) | If true, return full record data in response | -| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total` (#7539). | -| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | +| **continueOnError** | `boolean` | optional (default: `false`) | If true (and atomic=false), continue processing remaining records after errors. Default false: the first failure ENDS the run — records before it stay written (nothing is rolled back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than omitted, so `results` always covers all `total` records and `succeeded + failed === total`. | +| **validateOnly** | `never` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec. It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. | --- @@ -2730,9 +2730,9 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -2744,10 +2744,10 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Record ID if operation succeeded | | **success** | `boolean` | ✅ | Whether this record was processed successfully | -| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back (#7539). | +| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back. | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | --- @@ -2830,7 +2830,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **mode** | `Enum<'insert' \| 'update'>` | ✅ | The write mode the verdict was reached for. | | **valid** | `boolean` | ✅ | True when EVERY row is valid — the whole-set answer. | | **results** | `{ valid: boolean; errors: object[]; warnings: object[] }[]` | ✅ | Per-row verdicts, in submission order. | -| **posture** | `{ valueShapeStrict: boolean; mediaValueShapeStrict: boolean }` | ✅ | The ADR-0104 posture the verdict was reached under — reported because it is the difference between "this row is fine" and "this row is fine HERE". The same row can be an error on a self-certified deployment and an admitted warning on an un-migrated one, and a caller explaining a verdict needs to know which it got. An unconditionally-strict preview was considered and rejected (#4633 option B): it would fail rows on every un-migrated deployment that the write would have accepted. | +| **posture** | `{ valueShapeStrict: boolean; mediaValueShapeStrict: boolean }` | ✅ | The ADR-0104 posture the verdict was reached under — reported because it is the difference between "this row is fine" and "this row is fine HERE". The same row can be an error on a self-certified deployment and an admitted warning on an un-migrated one, and a caller explaining a verdict needs to know which it got. An unconditionally-strict preview was considered and rejected (option B): it would fail rows on every un-migrated deployment that the write would have accepted. | ### Nested Shape: `ValidateDataResponse.results[number]` diff --git a/content/docs/references/api/realtime.mdx b/content/docs/references/api/realtime.mdx index 79581bc7ae..d7abd6bd0d 100644 --- a/content/docs/references/api/realtime.mdx +++ b/content/docs/references/api/realtime.mdx @@ -63,7 +63,7 @@ const result = RealtimeConfigSchema.parse(data); ## RealtimeEventType -Realtime event type (not yet enforced — the runtime emits data.record.* event names instead, and field.changed is never emitted; see #3197) +Realtime event type ### Allowed Values diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index c43706a5de..9682aa6f2c 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -230,7 +230,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (deployment-wide search opt-out) | | **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy | -| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **documentation** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config | | **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options | @@ -268,7 +268,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **metadata** | `{ prefix: string; enableCache: boolean; cacheTtl: integer; maskObjectFields: boolean; … }` | optional | Metadata endpoints configuration | | **batch** | `{ maxBatchSize: integer; enableBatchEndpoint: boolean; operations?: object; defaultAtomic: boolean }` | optional | Batch endpoints configuration | | **routes** | `{ includeObjects?: string[]; excludeObjects?: string[]; nameTransform: Enum<'none' \| 'plural' \| 'kebab-case' \| 'camelCase'>; overrides?: Record }` | optional | Route generation configuration | -| **openApi31** | `never` | optional | [REMOVED] `RestServerConfig.openApi31` was removed in @objectstack/spec 17 (#4579, ADR-0049) — no runtime ever read it: the REST server forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated contract enriched with the live server URL and the registered objects, so webhook/callback definitions declared here never appeared in it. Delete the key. Config-driven OpenAPI 3.1 webhooks/callbacks documentation is a new capability and must arrive via the enforce route of ADR-0049 (a new ADR), not by re-declaring the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. | +| **openApi31** | `never` | optional | [REMOVED] `RestServerConfig.openApi31` was removed in @objectstack/spec 17 (ADR-0049) — no runtime ever read it: the REST server forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated contract enriched with the live server URL and the registered objects, so webhook/callback definitions declared here never appeared in it. Delete the key. Config-driven OpenAPI 3.1 webhooks/callbacks documentation is a new capability and must arrive via the enforce route of ADR-0049 (a new ADR), not by re-declaring the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. | ### Nested Shape: `RestServerConfig.api` @@ -286,7 +286,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (deployment-wide search opt-out) | | **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy | -| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **documentation** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config | | **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options | diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index 5996ef0f14..8405609097 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -62,9 +62,9 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -112,9 +112,9 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -160,9 +160,9 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -232,9 +232,9 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -270,9 +270,9 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -308,9 +308,9 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -354,9 +354,9 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | @@ -389,9 +389,9 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | -| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106) | +| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | -| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`. | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | | **category** | `string` | optional | Error category (e.g. validation, authorization) | | **httpStatus** | `integer` | optional | HTTP status of the response carrying this error | | **details** | `any` | optional | Additional error context (e.g. field validation errors) | diff --git a/content/docs/references/automation/control-flow.mdx b/content/docs/references/automation/control-flow.mdx index c94ec88840..072636a4f4 100644 --- a/content/docs/references/automation/control-flow.mdx +++ b/content/docs/references/automation/control-flow.mdx @@ -108,7 +108,7 @@ const result = FlowRegionSchema.parse(data); | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node | -| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes | @@ -171,7 +171,7 @@ const result = FlowRegionSchema.parse(data); | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node | -| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes | @@ -220,7 +220,7 @@ const result = FlowRegionSchema.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | --- @@ -259,7 +259,7 @@ const result = FlowRegionSchema.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | --- diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index c968db93d1..2db25b42bb 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -46,12 +46,12 @@ const result = FlowSchema.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | @@ -85,7 +85,7 @@ const result = FlowSchema.parse(data); | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node | -| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes | @@ -111,8 +111,8 @@ const result = FlowSchema.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **fallbackNodeId** | `never` | optional | [REMOVED] `flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — the engine routes unrecoverable node errors via per-node fault edges (an edge with type: 'fault'), and never read this key: a fallback configured here silently did not exist. Delete the key and draw a fault edge from the failing node to the handler node instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **fallbackNodeId** | `never` | optional | [REMOVED] `flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (audit close-out) — the engine routes unrecoverable node errors via per-node fault edges (an edge with type: 'fault'), and never read this key: a fallback configured here silently did not exist. Delete the key and draw a fault edge from the failing node to the handler node instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `Flow.protection` @@ -156,7 +156,7 @@ const result = FlowSchema.parse(data); | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node | -| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes | @@ -183,8 +183,8 @@ const result = FlowSchema.parse(data); | **eventType** | `Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>` | ✅ | What kind of event resumes the execution | | **timerDuration** | `string` | optional | ISO 8601 duration (e.g., "PT1H") or wait time for timer events | | **signalName** | `string` | optional | Named signal or webhook event to wait for | -| **timeoutMs** | `never` | optional | [REMOVED] `waitEventConfig.timeoutMs` was removed in @objectstack/spec 17 (#4158). It documented a timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its only reader treated it as the timer DURATION when `timerDuration` was absent, so use `timerDuration` — but QUOTE the number: the key is a string, and a bare numeric string is read as milliseconds, making `timeoutMs: 60000` and `timerDuration: '60000'` the same wait (`timerDuration: 'PT1M'` is the ISO 8601 spelling of that same 60s). Stored flows are converted automatically — the conversion does the quoting for you. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **onTimeout** | `never` | optional | [REMOVED] `waitEventConfig.onTimeout` was removed in @objectstack/spec 17 (#4158). It had no readers at all — no code path ever inspected it, so neither `fail` nor `continue` ever happened. Delete the key. There is no replacement: `wait` has no timeout, and a wait node resumes only when its timer elapses or its signal arrives. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **timeoutMs** | `never` | optional | [REMOVED] `waitEventConfig.timeoutMs` was removed in @objectstack/spec 17. It documented a timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its only reader treated it as the timer DURATION when `timerDuration` was absent, so use `timerDuration` — but QUOTE the number: the key is a string, and a bare numeric string is read as milliseconds, making `timeoutMs: 60000` and `timerDuration: '60000'` the same wait (`timerDuration: 'PT1M'` is the ISO 8601 spelling of that same 60s). Stored flows are converted automatically — the conversion does the quoting for you. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **onTimeout** | `never` | optional | [REMOVED] `waitEventConfig.onTimeout` was removed in @objectstack/spec 17. It had no readers at all — no code path ever inspected it, so neither `fail` nor `continue` ever happened. Delete the key. There is no replacement: `wait` has no timeout, and a wait node resumes only when its timer elapses or its signal arrives. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `FlowNode.boundaryConfig` @@ -267,12 +267,12 @@ const result = FlowSchema.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | diff --git a/content/docs/references/automation/node-executor.mdx b/content/docs/references/automation/node-executor.mdx index a594c8662c..b253293eab 100644 --- a/content/docs/references/automation/node-executor.mdx +++ b/content/docs/references/automation/node-executor.mdx @@ -70,9 +70,9 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **supportsCancellation** | `boolean` | optional (default: `false`) | Supports cancellation | | **supportsRetry** | `boolean` | optional (default: `true`) | Supports retry on failure | | **needsOutbox** | `boolean` | optional (default: `false`) | Dispatch via service-messaging outbox (retry/idempotency/dead-letter) | -| **isAsync** | `never` | optional | [REMOVED] `ActionDescriptor.isAsync` was removed in @objectstack/spec 17 (#6748, ADR-0049) — no execution path ever read it, so declaring it never made a node suspend and omitting it never stopped one. Delete the key. The live mechanism is two-part: an executor suspends by RETURNING `suspend: true` from `execute()`, and its descriptor must declare `supportsPause: true` (plus the `resumeAuthority` its pauses need) or the engine refuses that suspension (#6667). Declaring `isAsync: true` alongside `supportsPause: true` was always redundant; declaring it alone was always inert. | +| **isAsync** | `never` | optional | [REMOVED] `ActionDescriptor.isAsync` was removed in @objectstack/spec 17 (ADR-0049) — no execution path ever read it, so declaring it never made a node suspend and omitting it never stopped one. Delete the key. The live mechanism is two-part: an executor suspends by RETURNING `suspend: true` from `execute()`, and its descriptor must declare `supportsPause: true` (plus the `resumeAuthority` its pauses need) or the engine refuses that suspension. Declaring `isAsync: true` alongside `supportsPause: true` was always redundant; declaring it alone was always inert. | | **handlerContract** | `Enum<'none' \| 'pure'>` | optional (default: `"none"`) | Effect contract for author-supplied code this action invokes: 'none' (invokes none) or 'pure' (must not write — it returns a value and the flow graph persists it) | -| **resumeAuthority** | `Enum<'any' \| 'service'>` | optional | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Carries no schema default so an omission stays observable — and an omission is fail-CLOSED at run time, equivalent to 'service': a pausing node whose pause is open to the generic route must declare 'any' explicitly (#5561) | +| **resumeAuthority** | `Enum<'any' \| 'service'>` | optional | Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Carries no schema default so an omission stays observable — and an omission is fail-CLOSED at run time, equivalent to 'service': a pausing node whose pause is open to the generic route must declare 'any' explicitly | | **maturity** | `Enum<'ga' \| 'beta' \| 'reserved'>` | optional (default: `"ga"`) | Runtime maturity: ga (shipped), beta, or reserved (contract only — designers grey this out) | | **source** | `Enum<'builtin' \| 'plugin'>` | optional (default: `"plugin"`) | builtin = platform baseline; plugin = third-party contributed | | **deprecated** | `boolean` | optional (default: `false`) | Deprecated alias kept for back-compat | diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx index 53cb4aabce..214db16a8d 100644 --- a/content/docs/references/automation/schemaless-node-config.mdx +++ b/content/docs/references/automation/schemaless-node-config.mdx @@ -157,11 +157,11 @@ const result = DecisionConditionSchema.parse(data); | **function** | `string` | ✅ | Registered function to call (defineStack(`{ functions }`)). Contractually pure — it returns a value a later declarative node persists | | **inputs** | `Record` | optional | Inputs passed to the function (values interpolate `{token}` templates) | | **outputVariable** | `string` | optional | Flow variable the function's return value is bound to | -| **actionType** | `never` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 (#4343) — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of `config.function`. Replace it per branch: for `email` use a `notify` node (it delivers through the messaging service — the in-app inbox by default, real email once `@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with the Slack connector, or an `http` node posting to a webhook; for anything else, move the name into `config.function`. Run `os migrate meta --from 16` to list the mechanical edits for the shorthand case into `config.function`; the stub and marker values are removed. | -| **template** | `never` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 (#4343) — it fed only the logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and stored templates live in the messaging service (`sys_notification_template`), not on the node. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **recipients** | `never` | optional | [REMOVED] `script.config.recipients` was removed in @objectstack/spec 17 (#4343) — the addresses were logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a `notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging service for real. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **variables** | `never` | optional | [REMOVED] `script.config.variables` was removed in @objectstack/spec 17 (#4343) — it injected values into a template no side effect ever rendered. Delete the key. A `notify` node carries structured data in `payload`; a registered function takes it in `config.inputs`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **script** | `never` | optional | [REMOVED] `script.config.script` was removed in @objectstack/spec 17 (#4343) — the built-in runtime has no server-side JS sandbox, so an inline body was recognized and never executed: the node warned and completed as a no-op. Move the logic into a registered function (`defineStack({ functions })`) and name it in `config.function`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **actionType** | `never` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of `config.function`. Replace it per branch: for `email` use a `notify` node (it delivers through the messaging service — the in-app inbox by default, real email once `@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with the Slack connector, or an `http` node posting to a webhook; for anything else, move the name into `config.function`. Run `os migrate meta --from 16` to list the mechanical edits for the shorthand case into `config.function`; the stub and marker values are removed. | +| **template** | `never` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 — it fed only the logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and stored templates live in the messaging service (`sys_notification_template`), not on the node. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **recipients** | `never` | optional | [REMOVED] `script.config.recipients` was removed in @objectstack/spec 17 — the addresses were logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a `notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging service for real. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **variables** | `never` | optional | [REMOVED] `script.config.variables` was removed in @objectstack/spec 17 — it injected values into a template no side effect ever rendered. Delete the key. A `notify` node carries structured data in `payload`; a registered function takes it in `config.inputs`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **script** | `never` | optional | [REMOVED] `script.config.script` was removed in @objectstack/spec 17 — the built-in runtime has no server-side JS sandbox, so an inline body was recognized and never executed: the node warned and completed as a no-op. Move the logic into a registered function (`defineStack({ functions })`) and name it in `config.function`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | --- diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index ff3d23bef1..725db503ba 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -68,7 +68,7 @@ const result = BaseEngineOptionsSchema.parse(data); | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -120,7 +120,7 @@ Options for DataEngine.aggregate operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -195,7 +195,7 @@ Options for DataEngine.count operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -267,7 +267,7 @@ Options for DataEngine.delete operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -356,11 +356,11 @@ Reference: any | **limit** | `number` | optional | | | **offset** | `number` | optional | | | **top** | `number` | optional | | -| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | | | **searchFields** | `string[]` | optional | | | **expand** | `Record` | optional | | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **filter** | `Record \| any` | optional | Data Engine query filter conditions | | **select** | `string[]` | optional | | | **sort** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sort order definition | @@ -391,11 +391,11 @@ Reference: any | **limit** | `number` | optional | | | **offset** | `number` | optional | | | **top** | `number` | optional | | -| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | | | **searchFields** | `string[]` | optional | | | **expand** | `Record` | optional | | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **filter** | `Record \| any` | optional | Data Engine query filter conditions | | **select** | `string[]` | optional | | | **sort** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sort order definition | @@ -446,7 +446,7 @@ Options for DataEngine.insert operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -516,7 +516,7 @@ Query options for IDataEngine.find() operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -554,11 +554,11 @@ This schema accepts one of the following structures: | **limit** | `number` | optional | | | **offset** | `number` | optional | | | **top** | `number` | optional | | -| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | | | **searchFields** | `string[]` | optional | | | **expand** | `Record` | optional | | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **filter** | `Record \| any` | optional | Data Engine query filter conditions | | **select** | `string[]` | optional | | | **sort** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sort order definition | @@ -588,11 +588,11 @@ This schema accepts one of the following structures: | **limit** | `number` | optional | | | **offset** | `number` | optional | | | **top** | `number` | optional | | -| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | | **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | | | **searchFields** | `string[]` | optional | | | **expand** | `Record` | optional | | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **filter** | `Record \| any` | optional | Data Engine query filter conditions | | **select** | `string[]` | optional | | | **sort** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sort order definition | @@ -632,7 +632,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | | | **where** | `Record \| any` | optional | | -| **upsert** | `never` | optional | [REMOVED] `update.options.upsert` was removed in @objectstack/spec 17 (#8057, ADR-0049) — it was declared and allowlisted but never implemented: no engine or driver path ever read it, so `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update. Delete the key. Express create-if-absent explicitly: a by-id update whose id names no row throws RECORD_NOT_FOUND (#7867's not-found gate) rather than inserting, so read the row first (`findOne`) and call `insert` or `update` on what you find. A first-class upsert, if ever built, must reconcile with that gate by design rather than through this silent flag. | +| **upsert** | `never` | optional | [REMOVED] `update.options.upsert` was removed in @objectstack/spec 17 (ADR-0049) — it was declared and allowlisted but never implemented: no engine or driver path ever read it, so `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update. Delete the key. Express create-if-absent explicitly: a by-id update whose id names no row throws RECORD_NOT_FOUND (the by-id not-found gate) rather than inserting, so read the row first (`findOne`) and call `insert` or `update` on what you find. A first-class upsert, if ever built, must reconcile with that gate by design rather than through this silent flag. | | **multi** | `boolean` | optional (default: `false`) | | | **returning** | `boolean` | optional (default: `false`) | | | **filter** | `Record \| any` | optional | Data Engine query filter conditions | @@ -775,7 +775,7 @@ Options for DataEngine.update operations | :--- | :--- | :--- | :--- | | **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | | | **filter** | `Record \| any` | optional | Data Engine query filter conditions | -| **upsert** | `never` | optional | [REMOVED] `update.options.upsert` was removed in @objectstack/spec 17 (#8057, ADR-0049) — it was declared and allowlisted but never implemented: no engine or driver path ever read it, so `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update. Delete the key. Express create-if-absent explicitly: a by-id update whose id names no row throws RECORD_NOT_FOUND (#7867's not-found gate) rather than inserting, so read the row first (`findOne`) and call `insert` or `update` on what you find. A first-class upsert, if ever built, must reconcile with that gate by design rather than through this silent flag. | +| **upsert** | `never` | optional | [REMOVED] `update.options.upsert` was removed in @objectstack/spec 17 (ADR-0049) — it was declared and allowlisted but never implemented: no engine or driver path ever read it, so `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update. Delete the key. Express create-if-absent explicitly: a by-id update whose id names no row throws RECORD_NOT_FOUND (the by-id not-found gate) rather than inserting, so read the row first (`findOne`) and call `insert` or `update` on what you find. A first-class upsert, if ever built, must reconcile with that gate by design rather than through this silent flag. | | **multi** | `boolean` | optional (default: `false`) | | | **returning** | `boolean` | optional (default: `false`) | | @@ -809,7 +809,7 @@ Options for DataEngine.update operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -836,7 +836,7 @@ Options for DataEngine.update operations | :--- | :--- | :--- | :--- | | **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | | | **where** | `Record \| any` | optional | | -| **upsert** | `never` | optional | [REMOVED] `update.options.upsert` was removed in @objectstack/spec 17 (#8057, ADR-0049) — it was declared and allowlisted but never implemented: no engine or driver path ever read it, so `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update. Delete the key. Express create-if-absent explicitly: a by-id update whose id names no row throws RECORD_NOT_FOUND (#7867's not-found gate) rather than inserting, so read the row first (`findOne`) and call `insert` or `update` on what you find. A first-class upsert, if ever built, must reconcile with that gate by design rather than through this silent flag. | +| **upsert** | `never` | optional | [REMOVED] `update.options.upsert` was removed in @objectstack/spec 17 (ADR-0049) — it was declared and allowlisted but never implemented: no engine or driver path ever read it, so `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update. Delete the key. Express create-if-absent explicitly: a by-id update whose id names no row throws RECORD_NOT_FOUND (the by-id not-found gate) rather than inserting, so read the row first (`findOne`) and call `insert` or `update` on what you find. A first-class upsert, if ever built, must reconcile with that gate by design rather than through this silent flag. | | **multi** | `boolean` | optional (default: `false`) | | | **returning** | `boolean` | optional (default: `false`) | | | **filter** | `Record \| any` | optional | Data Engine query filter conditions | @@ -863,7 +863,7 @@ Options for DataEngine.update operations ## DroppedFieldsEvent -A write-path strip event: caller-supplied fields legally dropped from the payload (#3407) +A write-path strip event: caller-supplied fields legally dropped from the payload ### Properties @@ -871,7 +871,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object the write targeted (resolved object name) | | **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload | -| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly (#2948), a TRUE readonlyWhen predicate (#3042), or the primary-key strip of a payload id the engine ruled is not an identifier (#6437) | +| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, or the primary-key strip of a payload id the engine ruled is not an identifier | --- @@ -921,7 +921,7 @@ QueryAST-aligned options for DataEngine.aggregate operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -942,8 +942,8 @@ QueryAST-aligned options for DataEngine.aggregate operations | **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | Aggregation function | | **field** | `string` | optional | Field to aggregate (optional for COUNT(*)) | | **alias** | `string` | ✅ | Result column alias | -| **distinct** | `never` | optional | [REMOVED] `query.aggregations[].distinct` was removed in @objectstack/spec 17 (#6815, ADR-0049) — exactly ONE of the six faces that read an aggregation honoured it. The objectql in-memory fallback deduplicated the values before applying the function, while `driver-sql`, `driver-turso`, `driver-mongodb`, `driver-memory` and the service-analytics SQL builder all ignored it — so `{ function: 'sum', field: 'amount', distinct: true }` answered a DEDUPLICATED sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it. Both answers are plausible, so nothing surfaced the divergence. Delete the key. For a deduplicated COUNT the live spelling is the `count_distinct` aggregation function, which every SQL face compiles to `COUNT(DISTINCT field)` (#6409) and the in-memory fallback computes identically. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating is a modelling problem to fix in the data, not a flag on the read. | -| **filter** | `any` | optional | Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate (#10576): lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it. | +| **distinct** | `never` | optional | [REMOVED] `query.aggregations[].distinct` was removed in @objectstack/spec 17 (ADR-0049) — exactly ONE of the six faces that read an aggregation honoured it. The objectql in-memory fallback deduplicated the values before applying the function, while `driver-sql`, `driver-turso`, `driver-mongodb`, `driver-memory` and the service-analytics SQL builder all ignored it — so `{ function: 'sum', field: 'amount', distinct: true }` answered a DEDUPLICATED sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it. Both answers are plausible, so nothing surfaced the divergence. Delete the key. For a deduplicated COUNT the live spelling is the `count_distinct` aggregation function, which every SQL face compiles to `COUNT(DISTINCT field)` and the in-memory fallback computes identically. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating is a modelling problem to fix in the data, not a flag on the read. | +| **filter** | `any` | optional | Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate: lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it. | --- @@ -989,7 +989,7 @@ QueryAST-aligned options for DataEngine.count operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -1040,7 +1040,7 @@ QueryAST-aligned options for DataEngine.delete operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -1064,11 +1064,11 @@ QueryAST-aligned query options for IDataEngine.find() operations | **limit** | `number` | optional | | | **offset** | `number` | optional | | | **top** | `number` | optional | | -| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | | **search** | `string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | | | **searchFields** | `string[]` | optional | | | **expand** | `Record` | optional | | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | ### Nested Shape: `EngineQueryOptions.context` @@ -1100,7 +1100,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | @@ -1112,19 +1112,19 @@ QueryAST-aligned query options for IDataEngine.find() operations | :--- | :--- | :--- | :--- | | **query** | `string` | ✅ | Search query text | | **fields** | `string[]` | optional | Fields to search in (if not specified, searches all text fields) | -| **fuzzy** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag (#4286). | -| **operator** | `Enum<'and' \| 'or'>` | optional (default: `"or"`) | [EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag (#4286). | -| **boost** | `Record` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results (#4286). | -| **minScore** | `number` | optional | [EXPERIMENTAL — not enforced] Minimum relevance score threshold. No executor scores results (#4286). | -| **language** | `string` | optional | [EXPERIMENTAL — not enforced] Language for text analysis (e.g., "en", "zh", "es"). No executor selects an analyzer (#4286). | -| **highlight** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Search result highlighting. No executor emits highlights (#4286). | +| **fuzzy** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag. | +| **operator** | `Enum<'and' \| 'or'>` | optional (default: `"or"`) | [EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag. | +| **boost** | `Record` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | +| **minScore** | `number` | optional | [EXPERIMENTAL — not enforced] Minimum relevance score threshold. No executor scores results. | +| **language** | `string` | optional | [EXPERIMENTAL — not enforced] Language for text analysis (e.g., "en", "zh", "es"). No executor selects an analyzer. | +| **highlight** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Search result highlighting. No executor emits highlights. | ### Nested Shape: `EngineQueryOptions.expand[string]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD` (#7532). Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes (#6924). | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -1132,13 +1132,13 @@ QueryAST-aligned query options for IDataEngine.find() operations | **limit** | `number` | optional | Max records to return (LIMIT) | | **offset** | `number` | optional | Records to skip (OFFSET) | | **top** | `number` | optional | Alias for limit (OData compatibility) | -| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | -| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and whose nested query selects the related record's own columns. Keep the foreign key in your own projection (`fields: ['title', 'owner_id']`): the relation is carried by that column, so projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever resolved one and the ingress refuses it (`400 INVALID_FIELD`, #7532). | +| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and whose nested query selects the related record's own columns. Keep the foreign key in your own projection (`fields: ['title', 'owner_id']`): the relation is carried by that column, so projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever resolved one and the ingress refuses it (`400 INVALID_FIELD`). | | **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; filter?: any }[]` | optional | Aggregation functions | | **groupBy** | `(string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string })[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | -| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **expand** | `Record` | optional | Recursive relation loading map. Keys are lookup/master_detail field names; values are nested QueryAST objects that control select (`fields`) and filter (`where`, AND-merged with the batch $in), plus further expansion on the related object. The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3; per-parent `limit`/`offset`/`orderBy` are NOT applied on this path. | @@ -1154,7 +1154,7 @@ QueryAST-aligned options for DataEngine.update operations | :--- | :--- | :--- | :--- | | **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | | | **where** | `Record \| any` | optional | | -| **upsert** | `never` | optional | [REMOVED] `update.options.upsert` was removed in @objectstack/spec 17 (#8057, ADR-0049) — it was declared and allowlisted but never implemented: no engine or driver path ever read it, so `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update. Delete the key. Express create-if-absent explicitly: a by-id update whose id names no row throws RECORD_NOT_FOUND (#7867's not-found gate) rather than inserting, so read the row first (`findOne`) and call `insert` or `update` on what you find. A first-class upsert, if ever built, must reconcile with that gate by design rather than through this silent flag. | +| **upsert** | `never` | optional | [REMOVED] `update.options.upsert` was removed in @objectstack/spec 17 (ADR-0049) — it was declared and allowlisted but never implemented: no engine or driver path ever read it, so `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update. Delete the key. Express create-if-absent explicitly: a by-id update whose id names no row throws RECORD_NOT_FOUND (the by-id not-found gate) rather than inserting, so read the row first (`findOne`) and call `insert` or `update` on what you find. A first-class upsert, if ever built, must reconcile with that gate by design rather than through this silent flag. | | **multi** | `boolean` | optional (default: `false`) | | | **returning** | `boolean` | optional (default: `false`) | | @@ -1188,7 +1188,7 @@ QueryAST-aligned options for DataEngine.update operations | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | diff --git a/content/docs/references/data/datasource.mdx b/content/docs/references/data/datasource.mdx index fdced8d694..a346d23ce3 100644 --- a/content/docs/references/data/datasource.mdx +++ b/content/docs/references/data/datasource.mdx @@ -76,7 +76,7 @@ const result = DatasourceSchema.parse(data); | **allowedSchemas** | `string[]` | optional | Whitelist of remote schemas/databases that may be exposed. | | **allowWrites** | `boolean` | optional (default: `false`) | Global write gate. Individual objects must also opt in via object.external.writable. | | **validation** | `{ onMismatch: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot: boolean; checkIntervalMs?: number }` | optional (default: `{"onMismatch":"fail","checkOnBoot":true}`) | Boot/drift validation policy | -| **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry (#8153). | +| **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry. | | **queryTimeoutMs** | `number` | optional (default: `30000`) | Hard cap on per-query execution time. | @@ -117,7 +117,7 @@ External datasource settings: federation policy (schemaMode != "managed") plus t | **allowedSchemas** | `string[]` | optional | Whitelist of remote schemas/databases that may be exposed. | | **allowWrites** | `boolean` | optional (default: `false`) | Global write gate. Individual objects must also opt in via object.external.writable. | | **validation** | `{ onMismatch: Enum<'fail' \| 'warn' \| 'ignore'>; checkOnBoot: boolean; checkIntervalMs?: number }` | optional (default: `{"onMismatch":"fail","checkOnBoot":true}`) | Boot/drift validation policy | -| **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry (#8153). | +| **credentialsRef** | `string` | optional | Reference into the secrets store; never inline credentials. Valid in every schemaMode — the one `external` key a managed datasource may carry. | | **queryTimeoutMs** | `number` | optional (default: `30000`) | Hard cap on per-query execution time. | ### Nested Shape: `ExternalDatasourceSettings.validation` diff --git a/content/docs/references/data/driver-common.mdx b/content/docs/references/data/driver-common.mdx index a115da7752..d6c8a481f7 100644 --- a/content/docs/references/data/driver-common.mdx +++ b/content/docs/references/data/driver-common.mdx @@ -47,7 +47,7 @@ Enable TLS. Certificates go in the datasource-level `ssl` block. ## SqlAutoMigrate -Dev-only non-destructive schema self-heal (#2186) +Dev-only non-destructive schema self-heal ### Allowed Values diff --git a/content/docs/references/data/driver-mongo.mdx b/content/docs/references/data/driver-mongo.mdx index c287cb036f..1bb82a55cd 100644 --- a/content/docs/references/data/driver-mongo.mdx +++ b/content/docs/references/data/driver-mongo.mdx @@ -46,7 +46,7 @@ MongoDB Connection Configuration | **host** | `string` | optional (default: `"localhost"`) | Host address | | **port** | `integer` | optional (default: `27017`) | Port number | | **username** | `string` | optional | Authentication user | -| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) | +| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | | **authSource** | `string` | optional | Authentication database | | **options** | `Record` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …). Only `auth.password` is refused inline — bind it via the connection form / external.credentialsRef. `proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, and `passphrase` are accepted and stored at rest in cleartext; they're redacted only when the datasource is read back, not refused at write. | diff --git a/content/docs/references/data/driver-mysql.mdx b/content/docs/references/data/driver-mysql.mdx index a5a05f7a4d..2956264ef3 100644 --- a/content/docs/references/data/driver-mysql.mdx +++ b/content/docs/references/data/driver-mysql.mdx @@ -47,9 +47,9 @@ MySQL / MariaDB connection configuration | **port** | `integer` | optional (default: `3306`) | Port number | | **database** | `string` | optional | Database name | | **username** | `string` | optional | Authentication user | -| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) | +| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | | **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. | -| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) | +| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal | --- diff --git a/content/docs/references/data/driver-nosql.mdx b/content/docs/references/data/driver-nosql.mdx index af34504454..56179e1bd9 100644 --- a/content/docs/references/data/driver-nosql.mdx +++ b/content/docs/references/data/driver-nosql.mdx @@ -186,7 +186,7 @@ const result = AggregationPipelineSchema.parse(data); | **fullTextSearch** | `never` | optional | [REMOVED] `DriverCapabilities.fullTextSearch` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. `$search` is compiled by the engine into an `$or` of `$contains` predicates over the searchable fields (ADR-0061) and removed from the AST before the driver sees it — no driver-side full-text path exists. Delete the key. | | **jsonQuery** | `never` | optional | [REMOVED] `DriverCapabilities.jsonQuery` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No engine path ever branched on driver-side JSON querying. Delete the key. | | **geospatialQuery** | `never` | optional | [REMOVED] `DriverCapabilities.geospatialQuery` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No geospatial query path exists in the platform — declaring the bit advertised a capability nothing delivers. Delete the key. | -| **streaming** | `never` | optional | [REMOVED] `DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only read this bit could describe, was itself removed in 17.0.0 (#4484): nothing ever called it, and two of its three implementations materialised the entire result set before yielding. The bit carried the same defect one level up (`SqlDriver` implemented `findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a full-table read) — which is what zero readers makes inevitable. Page large reads through `find()` with `limit`/`offset`. Delete the key. | +| **streaming** | `never` | optional | [REMOVED] `DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only read this bit could describe, was itself removed in 17.0.0: nothing ever called it, and two of its three implementations materialised the entire result set before yielding. The bit carried the same defect one level up (`SqlDriver` implemented `findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a full-table read) — which is what zero readers makes inevitable. Page large reads through `find()` with `limit`/`offset`. Delete the key. | | **jsonFields** | `never` | optional | [REMOVED] `DriverCapabilities.jsonFields` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Field-type handling is negotiated per object at `syncSchema` time by the driver itself (e.g. `SqlDriver`'s per-object JSON/date column tracking); no engine path consulted the bit. Delete the key. | | **arrayFields** | `never` | optional | [REMOVED] `DriverCapabilities.arrayFields` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Field-type handling is negotiated per object at `syncSchema` time by the driver itself; no engine path consulted the bit. Delete the key. | | **vectorSearch** | `never` | optional | [REMOVED] `DriverCapabilities.vectorSearch` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No vector read path routes through `IDataDriver`. When one exists it should arrive WITH its caller and its capability bit together (the honest order under enforce-or-remove), not as a dangling boolean. Delete the key. | @@ -262,7 +262,7 @@ const result = AggregationPipelineSchema.parse(data); | **name** | `string` | ✅ | Index name | | **type** | `Enum<'single' \| 'compound' \| 'unique' \| 'text' \| 'geospatial' \| 'hashed' \| 'ttl' \| 'sparse'>` | ✅ | Index type | | **fields** | `{ field: string; order?: Enum<'asc' \| 'desc' \| 'text' \| '2dsphere'> }[]` | ✅ | Fields to index | -| **unique** | `boolean` | optional (default: `false`) | Enforce uniqueness over exactly the listed `fields`. Boolean on purpose — no ADR-0120 scope vocabulary here: this is the raw driver-descriptor layer, below tenancy. The 'organization'/'global' boundary is stated on the authorable surfaces (FieldSchema.unique, IndexSchema.unique) and resolved into physical key columns before a descriptor like this is built, so an organization key part, when there is one, is already a listed field (#11215) | +| **unique** | `boolean` | optional (default: `false`) | Enforce uniqueness over exactly the listed `fields`. Boolean on purpose — no ADR-0120 scope vocabulary here: this is the raw driver-descriptor layer, below tenancy. The 'organization'/'global' boundary is stated on the authorable surfaces (FieldSchema.unique, IndexSchema.unique) and resolved into physical key columns before a descriptor like this is built, so an organization key part, when there is one, is already a listed field | | **sparse** | `boolean` | optional (default: `false`) | Sparse index | | **expireAfterSeconds** | `integer` | optional | TTL in seconds | | **partialFilterExpression** | `Record` | optional | Partial index filter | diff --git a/content/docs/references/data/driver-postgres.mdx b/content/docs/references/data/driver-postgres.mdx index 2b476ed192..806fd72b91 100644 --- a/content/docs/references/data/driver-postgres.mdx +++ b/content/docs/references/data/driver-postgres.mdx @@ -45,12 +45,12 @@ PostgreSQL connection configuration | **port** | `integer` | optional (default: `5432`) | Port number | | **database** | `string` | optional | Database name | | **username** | `string` | optional | Authentication user | -| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) | +| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | | **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. | | **schema** | `string` | optional (default: `"public"`) | Default schema (knex searchPath) | | **applicationName** | `string` | optional | Postgres application_name | | **statementTimeout** | `integer` | optional | Abort statements running longer than this (ms) | -| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) | +| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal | --- diff --git a/content/docs/references/data/driver-sql.mdx b/content/docs/references/data/driver-sql.mdx index 488cb2415a..0b750d12f0 100644 --- a/content/docs/references/data/driver-sql.mdx +++ b/content/docs/references/data/driver-sql.mdx @@ -100,7 +100,7 @@ const result = DataTypeMappingSchema.parse(data); | **fullTextSearch** | `never` | optional | [REMOVED] `DriverCapabilities.fullTextSearch` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. `$search` is compiled by the engine into an `$or` of `$contains` predicates over the searchable fields (ADR-0061) and removed from the AST before the driver sees it — no driver-side full-text path exists. Delete the key. | | **jsonQuery** | `never` | optional | [REMOVED] `DriverCapabilities.jsonQuery` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No engine path ever branched on driver-side JSON querying. Delete the key. | | **geospatialQuery** | `never` | optional | [REMOVED] `DriverCapabilities.geospatialQuery` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No geospatial query path exists in the platform — declaring the bit advertised a capability nothing delivers. Delete the key. | -| **streaming** | `never` | optional | [REMOVED] `DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only read this bit could describe, was itself removed in 17.0.0 (#4484): nothing ever called it, and two of its three implementations materialised the entire result set before yielding. The bit carried the same defect one level up (`SqlDriver` implemented `findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a full-table read) — which is what zero readers makes inevitable. Page large reads through `find()` with `limit`/`offset`. Delete the key. | +| **streaming** | `never` | optional | [REMOVED] `DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only read this bit could describe, was itself removed in 17.0.0: nothing ever called it, and two of its three implementations materialised the entire result set before yielding. The bit carried the same defect one level up (`SqlDriver` implemented `findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a full-table read) — which is what zero readers makes inevitable. Page large reads through `find()` with `limit`/`offset`. Delete the key. | | **jsonFields** | `never` | optional | [REMOVED] `DriverCapabilities.jsonFields` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Field-type handling is negotiated per object at `syncSchema` time by the driver itself (e.g. `SqlDriver`'s per-object JSON/date column tracking); no engine path consulted the bit. Delete the key. | | **arrayFields** | `never` | optional | [REMOVED] `DriverCapabilities.arrayFields` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Field-type handling is negotiated per object at `syncSchema` time by the driver itself; no engine path consulted the bit. Delete the key. | | **vectorSearch** | `never` | optional | [REMOVED] `DriverCapabilities.vectorSearch` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No vector read path routes through `IDataDriver`. When one exists it should arrive WITH its caller and its capability bit together (the honest order under enforce-or-remove), not as a dangling boolean. Delete the key. | diff --git a/content/docs/references/data/driver-sqlite.mdx b/content/docs/references/data/driver-sqlite.mdx index c5fdfb0de7..f63d106a4e 100644 --- a/content/docs/references/data/driver-sqlite.mdx +++ b/content/docs/references/data/driver-sqlite.mdx @@ -45,7 +45,7 @@ SQLite connection configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **filename** | `string` | optional (default: `":memory:"`) | Database file path, or ":memory:" for an ephemeral database | -| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) | +| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal | --- diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx index 7e10def6f0..19ca0d1567 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -66,7 +66,7 @@ Turso / libSQL Connection Configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **url** | `string` | ✅ | libSQL endpoint or local file: a remote libsql/https Turso URL, a file path, or :memory: | -| **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) | +| **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | | **encryptionKey** | `string` | optional | AES-256 encryption key for the local database file (local/replica modes) | | **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database | | **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint | diff --git a/content/docs/references/data/driver.mdx b/content/docs/references/data/driver.mdx index b0ebf332c9..1c2db2862d 100644 --- a/content/docs/references/data/driver.mdx +++ b/content/docs/references/data/driver.mdx @@ -54,7 +54,7 @@ const result = DriverCapabilitiesSchema.parse(data); | **fullTextSearch** | `never` | optional | [REMOVED] `DriverCapabilities.fullTextSearch` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. `$search` is compiled by the engine into an `$or` of `$contains` predicates over the searchable fields (ADR-0061) and removed from the AST before the driver sees it — no driver-side full-text path exists. Delete the key. | | **jsonQuery** | `never` | optional | [REMOVED] `DriverCapabilities.jsonQuery` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No engine path ever branched on driver-side JSON querying. Delete the key. | | **geospatialQuery** | `never` | optional | [REMOVED] `DriverCapabilities.geospatialQuery` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No geospatial query path exists in the platform — declaring the bit advertised a capability nothing delivers. Delete the key. | -| **streaming** | `never` | optional | [REMOVED] `DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only read this bit could describe, was itself removed in 17.0.0 (#4484): nothing ever called it, and two of its three implementations materialised the entire result set before yielding. The bit carried the same defect one level up (`SqlDriver` implemented `findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a full-table read) — which is what zero readers makes inevitable. Page large reads through `find()` with `limit`/`offset`. Delete the key. | +| **streaming** | `never` | optional | [REMOVED] `DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only read this bit could describe, was itself removed in 17.0.0: nothing ever called it, and two of its three implementations materialised the entire result set before yielding. The bit carried the same defect one level up (`SqlDriver` implemented `findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a full-table read) — which is what zero readers makes inevitable. Page large reads through `find()` with `limit`/`offset`. Delete the key. | | **jsonFields** | `never` | optional | [REMOVED] `DriverCapabilities.jsonFields` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Field-type handling is negotiated per object at `syncSchema` time by the driver itself (e.g. `SqlDriver`'s per-object JSON/date column tracking); no engine path consulted the bit. Delete the key. | | **arrayFields** | `never` | optional | [REMOVED] `DriverCapabilities.arrayFields` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Field-type handling is negotiated per object at `syncSchema` time by the driver itself; no engine path consulted the bit. Delete the key. | | **vectorSearch** | `never` | optional | [REMOVED] `DriverCapabilities.vectorSearch` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No vector read path routes through `IDataDriver`. When one exists it should arrive WITH its caller and its capability bit together (the honest order under enforce-or-remove), not as a dangling boolean. Delete the key. | @@ -108,7 +108,7 @@ const result = DriverCapabilitiesSchema.parse(data); | **fullTextSearch** | `never` | optional | [REMOVED] `DriverCapabilities.fullTextSearch` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. `$search` is compiled by the engine into an `$or` of `$contains` predicates over the searchable fields (ADR-0061) and removed from the AST before the driver sees it — no driver-side full-text path exists. Delete the key. | | **jsonQuery** | `never` | optional | [REMOVED] `DriverCapabilities.jsonQuery` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No engine path ever branched on driver-side JSON querying. Delete the key. | | **geospatialQuery** | `never` | optional | [REMOVED] `DriverCapabilities.geospatialQuery` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No geospatial query path exists in the platform — declaring the bit advertised a capability nothing delivers. Delete the key. | -| **streaming** | `never` | optional | [REMOVED] `DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only read this bit could describe, was itself removed in 17.0.0 (#4484): nothing ever called it, and two of its three implementations materialised the entire result set before yielding. The bit carried the same defect one level up (`SqlDriver` implemented `findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a full-table read) — which is what zero readers makes inevitable. Page large reads through `find()` with `limit`/`offset`. Delete the key. | +| **streaming** | `never` | optional | [REMOVED] `DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only read this bit could describe, was itself removed in 17.0.0: nothing ever called it, and two of its three implementations materialised the entire result set before yielding. The bit carried the same defect one level up (`SqlDriver` implemented `findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a full-table read) — which is what zero readers makes inevitable. Page large reads through `find()` with `limit`/`offset`. Delete the key. | | **jsonFields** | `never` | optional | [REMOVED] `DriverCapabilities.jsonFields` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Field-type handling is negotiated per object at `syncSchema` time by the driver itself (e.g. `SqlDriver`'s per-object JSON/date column tracking); no engine path consulted the bit. Delete the key. | | **arrayFields** | `never` | optional | [REMOVED] `DriverCapabilities.arrayFields` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Field-type handling is negotiated per object at `syncSchema` time by the driver itself; no engine path consulted the bit. Delete the key. | | **vectorSearch** | `never` | optional | [REMOVED] `DriverCapabilities.vectorSearch` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No vector read path routes through `IDataDriver`. When one exists it should arrive WITH its caller and its capability bit together (the honest order under enforce-or-remove), not as a dangling boolean. Delete the key. | diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 68318041a6..2117768f19 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -59,19 +59,19 @@ const result = CurrencyConfigSchema.parse(data); | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Format string (e.g. email, phone) | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (#9447, maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | -| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | +| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | | **min** | `number` | optional | Minimum value | | **max** | `number` | optional | Maximum value | -| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | @@ -80,7 +80,7 @@ const result = CurrencyConfigSchema.parse(data); | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | -| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn, #3951); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | +| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | | **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | @@ -104,15 +104,15 @@ const result = CurrencyConfigSchema.parse(data); | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'` | -| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17 (#3855). | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17. | +| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | -| **internal** | `boolean` | optional | [#7728] Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | -| **readonly** | `boolean` | optional (default: `false`) | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE (#2948/#3003) and on INSERT (#3043; a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`, #3493) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply (#6640): a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | +| **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | +| **readonly** | `boolean` | optional (default: `false`) | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE and on INSERT (a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply: a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate). | -| **maskingRule** | `Enum<'phone' \| 'id_card' \| 'bank_account' \| 'email' \| 'name'> \| { keepHead: integer; keepTail: integer }` | optional | [#8993] Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or `{ keepHead, keepTail }`. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. | -| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning (#3420). No effect on non-password fields. | +| **maskingRule** | `Enum<'phone' \| 'id_card' \| 'bank_account' \| 'email' \| 'name'> \| { keepHead: integer; keepTail: integer }` | optional | Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or `{ keepHead, keepTail }`. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. | +| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning. No effect on non-password fields. | | **system** | `boolean` | optional | Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag. | | **sortable** | `boolean` | optional (default: `true`) | Whether field is sortable in list views | | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | @@ -199,7 +199,7 @@ const result = CurrencyConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name, #3951). The retired `field` spelling is refused. | +| **name** | `string` | ✅ | Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name). The retired `field` spelling is refused. | | **label** | `string` | optional | Column header; defaults to the child field's label via hydration. | | **type** | `Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>` | optional | Cell control, derived from the child field's type when omitted. Declaring it opts the column out of schema hydration — supply the extras (options / reference / …) yourself. | | **width** | `number` | optional | Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed). | @@ -349,7 +349,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name, #3951). The retired `field` spelling is refused. | +| **name** | `string` | ✅ | Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name). The retired `field` spelling is refused. | | **label** | `string` | optional | Column header; defaults to the child field's label via hydration. | | **type** | `Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>` | optional | Cell control, derived from the child field's type when omitted. Declaring it opts the column out of schema hydration — supply the extras (options / reference / …) yourself. | | **width** | `number` | optional | Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed). | diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index d7762e21f0..1ebd121234 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -66,7 +66,7 @@ const result = EqualityOperatorSchema.parse(data); ## FilterArray -Input-only authoring sugar for a filter: [field, operator, value], ["and"|"or", ...conditions], or a bare list of those. Lowered to a FilterCondition at the single sink parseFilterAST (@objectstack/spec/data) the moment it arrives; it is never stored and never travels the wire as an array. A query "where" is a FilterCondition and does not accept this shape (#5158). +Input-only authoring sugar for a filter: [field, operator, value], ["and"|"or", ...conditions], or a bare list of those. Lowered to a FilterCondition at the single sink parseFilterAST (@objectstack/spec/data) the moment it arrives; it is never stored and never travels the wire as an array. A query "where" is a FilterCondition and does not accept this shape. ### Union Options @@ -123,8 +123,8 @@ Type: `[FilterArray](#filterarray)[]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list (#7596) — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. | -| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list (#7596) — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. | +| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. | +| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. | --- @@ -151,9 +151,9 @@ Type: `[FilterArray](#filterarray)[]` | **$notContains** | `string` | optional | | | **$startsWith** | `string` | optional | | | **$endsWith** | `string` | optional | | -| **$icontains** | `string` | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. [#5701 declared it; #5702 lowered it on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports); #6520 lowered it on every JS evaluation face, so it is portable across every backend the platform ships.] | -| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (#4706 Q2 = A); $ilike is the case-insensitive twin. [#7536. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist.] | -| **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same #4706 Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. [#7536; staged with $like — see FILTER_OPERATORS.] | +| **$icontains** | `string` | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. Lowered on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports) and on every JS evaluation face, so it is portable across every backend the platform ships. | +| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | +| **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. Staged with $like — see FILTER_OPERATORS. | --- diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 129afa93aa..68b61c4fc1 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -36,7 +36,7 @@ const result = HookContextSchema.parse(data); | **input** | `Record` | ✅ | Mutable input parameters | | **result** | `any` | optional | Operation result (After hooks only) | | **previous** | `Record` | optional | Record state before operation | -| **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record }` | optional | How this hook call relates to the caller's write (engine-produced; #6966) | +| **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record }` | optional | How this hook call relates to the caller's write (engine-produced) | | **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context | | **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) | | **transaction** | `any` | optional | Database transaction handle | @@ -64,15 +64,15 @@ const result = HookContextSchema.parse(data); | **skipTriggers** | `boolean` | optional | True when record-change automation (flow triggers) must be suppressed for this write — e.g. package seed replay. Lifecycle hooks still run. | | **skipAutomations** | `boolean` | optional | True when metadata-bound automation hooks must be suppressed for this write — e.g. data import with "run automations" unchecked, or import undo. Implies skipTriggers; code-registered system hooks (audit, security) still run. | | **positions** | `string[]` | optional | Position names held by the caller (ADR-0090 D3; formerly `roles`), copied from ExecutionContext.positions. For hook READS only — e.g. tailoring a message, or branching a business rule the hook runs through its own `ctx.api` channel. Authorization is decided by the security service on the ExecutionContext (permissions / positions / derived posture); this is NOT an authorization input and a hook must not gate a write by testing it. A hook context carries no `services` key, so the sharing service cannot be called from one either — the sharing gates already ran inside the engine before the hook chain. | -| **preserveAudit** | `boolean` | optional | True when this write is a historical import that must KEEP its caller-supplied updated_at/updated_by (and the readonly audit family) instead of being stamped with the import instant (#3493). Server-set, opt-in, absent on normal writes; read by the built-in audit hook. A stamping policy, not an authorization input. | -| **roles** | `never` | optional | [REMOVED] `HookContext.session.roles` was removed in @objectstack/spec 17.0.0 (#5050, ADR-0049 D2) — it was declared, read by two dead exemption branches (removed in #5049), and never produced: ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, so every read resolved `undefined` and a guard keyed on it was dead code that merely LOOKED like an authorization decision. Delete the key. To gate a hook on the caller, read `ctx.session.userId` / `ctx.session.isSystem`; to judge PRIVILEGE, ask the security service, which evaluates the ADR-0095 vocabulary on the execution context — capability grants (`permissions`), placements (`positions`) and the derived posture — never a role-name string comparison (ADR-0090 D3 bans the `role` spelling outright). Nothing to migrate: a HookContext is built per operation by the engine and never stored, so no metadata source carries this key. NOTE an ACTION body's `ctx.session` is a different object and still carries its own `roles` array today; that surface is tracked separately (#5613) and is not what this key was. | +| **preserveAudit** | `boolean` | optional | True when this write is a historical import that must KEEP its caller-supplied updated_at/updated_by (and the readonly audit family) instead of being stamped with the import instant. Server-set, opt-in, absent on normal writes; read by the built-in audit hook. A stamping policy, not an authorization input. | +| **roles** | `never` | optional | [REMOVED] `HookContext.session.roles` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — it was declared, read by two dead exemption branches, and never produced: ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, so every read resolved `undefined` and a guard keyed on it was dead code that merely LOOKED like an authorization decision. Delete the key. To gate a hook on the caller, read `ctx.session.userId` / `ctx.session.isSystem`; to judge PRIVILEGE, ask the security service, which evaluates the ADR-0095 vocabulary on the execution context — capability grants (`permissions`), placements (`positions`) and the derived posture — never a role-name string comparison (ADR-0090 D3 bans the `role` spelling outright). Nothing to migrate: a HookContext is built per operation by the engine and never stored, so no metadata source carries this key. NOTE an ACTION body's `ctx.session` is a different object and still carries its own `roles` array today; that surface is tracked separately and is not what this key was. | ### Nested Shape: `HookContext.provenance` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **flowRunId** | `string` | optional | Id of the automation flow run performing this write, when it originates from a flow data node. Lets a hook recognize the run that OWNS state that run itself opened — the approvals record lock exempts the run holding the pending request (#3456). | -| **attributedUserId** | `string` | optional | The real human credited for a write whose authorization subject was the SYSTEM — e.g. the admin whose better-auth `update-member-role` call the identity adapter executes as `isSystem` (#4586). ATTRIBUTION ONLY: the audit writer records it as `sys_audit_log.user_id`; no security middleware reads it, and it never becomes the subject the write is authorized as. | +| **flowRunId** | `string` | optional | Id of the automation flow run performing this write, when it originates from a flow data node. Lets a hook recognize the run that OWNS state that run itself opened — the approvals record lock exempts the run holding the pending request. | +| **attributedUserId** | `string` | optional | The real human credited for a write whose authorization subject was the SYSTEM — e.g. the admin whose better-auth `update-member-role` call the identity adapter executes as `isSystem`. ATTRIBUTION ONLY: the audit writer records it as `sys_audit_log.user_id`; no security middleware reads it, and it never becomes the subject the write is authorized as. | ### Nested Shape: `HookContext.user` diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 40252a92bc..8ea613193b 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -67,9 +67,9 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | -| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (#5248, ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (#5248, #4943, ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | +| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | --- @@ -167,9 +167,9 @@ const result = ApiMethod.parse(data); | **titleFormat** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | -| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (#11408, #10144 family). | +| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | -| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused (#6674) — mirror the value onto a stored text field and declare that. | +| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be <= sharingModel in openness. | @@ -221,19 +221,19 @@ const result = ApiMethod.parse(data); | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Format string (e.g. email, phone) | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (#9447, maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | -| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | +| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | | **min** | `number` | optional | Minimum value | | **max** | `number` | optional | Maximum value | -| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | @@ -242,7 +242,7 @@ const result = ApiMethod.parse(data); | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | -| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn, #3951); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | +| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | | **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | @@ -266,15 +266,15 @@ const result = ApiMethod.parse(data); | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'` | -| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17 (#3855). | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17. | +| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | -| **internal** | `boolean` | optional | [#7728] Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | -| **readonly** | `boolean` | optional (default: `false`) | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE (#2948/#3003) and on INSERT (#3043; a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`, #3493) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply (#6640): a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | +| **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | +| **readonly** | `boolean` | optional (default: `false`) | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE and on INSERT (a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply: a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate). | -| **maskingRule** | `Enum<'phone' \| 'id_card' \| 'bank_account' \| 'email' \| 'name'> \| { keepHead: integer; keepTail: integer }` | optional | [#8993] Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or `{ keepHead, keepTail }`. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. | -| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning (#3420). No effect on non-password fields. | +| **maskingRule** | `Enum<'phone' \| 'id_card' \| 'bank_account' \| 'email' \| 'name'> \| { keepHead: integer; keepTail: integer }` | optional | Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or `{ keepHead, keepTail }`. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. | +| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning. No effect on non-password fields. | | **system** | `boolean` | optional | Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag. | | **sortable** | `boolean` | optional (default: `true`) | Whether field is sortable in list views | | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | @@ -295,9 +295,9 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | -| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (#5248, ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (#5248, #4943, ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | +| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `Object.fieldGroups[number]` @@ -317,8 +317,8 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object | -| **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose (#5315). | -| **organizationField** | `string` | optional | STAMP-ONLY (#8778, widened by cloud#1395): column carrying the organization a row is ABOUT, consulted by the three sanctioned platform-row writers — audit stamping, the approval-row writer (`plugin-approvals`), and the automation-run recorder (`service-automation`) — via the shared `resolveRecordOrganizationField` resolver in `@objectstack/metadata-core`. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. | +| **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose. | +| **organizationField** | `string` | optional | STAMP-ONLY: column carrying the organization a row is ABOUT, consulted by the three sanctioned platform-row writers — audit stamping, the approval-row writer (`plugin-approvals`), and the automation-run recorder (`service-automation`) — via the shared `resolveRecordOrganizationField` resolver in `@objectstack/metadata-core`. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. | ### Nested Shape: `Object.access` @@ -390,7 +390,7 @@ const result = ApiMethod.parse(data); | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | @@ -402,11 +402,11 @@ const result = ApiMethod.parse(data); | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `Object.enable` @@ -439,7 +439,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | Display label | -| **description** | `string \| Record` | optional | Explanatory 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 — #7278). Not the LLM-facing `ai.description`. | +| **description** | `string \| Record` | optional | Explanatory 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`. | | **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). | | **icon** | `string` | optional | Icon name | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Locations where this action is visible | @@ -448,11 +448,11 @@ const result = ApiMethod.parse(data); | **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For 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; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. | -| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 (#3855) — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort 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. | -| **confirmText** | `string \| Record` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused (#7428) — 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. | +| **confirmText** | `string \| Record` | optional | Confirmation message before execution. On a registered action, 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. | | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | @@ -462,8 +462,8 @@ const result = ApiMethod.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | -| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | @@ -473,7 +473,7 @@ const result = ApiMethod.parse(data); | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open 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. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions (#9566/#9474). `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -547,19 +547,19 @@ const result = ApiMethod.parse(data); | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Format string (e.g. email, phone) | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (#9447, maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | -| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | +| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | | **min** | `number` | optional | Minimum value | | **max** | `number` | optional | Maximum value | -| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | @@ -568,7 +568,7 @@ const result = ApiMethod.parse(data); | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | -| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn, #3951); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | +| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | | **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | @@ -592,15 +592,15 @@ const result = ApiMethod.parse(data); | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'` | -| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17 (#3855). | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17. | +| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | -| **internal** | `boolean` | optional | [#7728] Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | -| **readonly** | `boolean` | optional (default: `false`) | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE (#2948/#3003) and on INSERT (#3043; a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`, #3493) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply (#6640): a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | +| **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | +| **readonly** | `boolean` | optional (default: `false`) | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE and on INSERT (a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply: a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate). | -| **maskingRule** | `Enum<'phone' \| 'id_card' \| 'bank_account' \| 'email' \| 'name'> \| { keepHead: integer; keepTail: integer }` | optional | [#8993] Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or `{ keepHead, keepTail }`. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. | -| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning (#3420). No effect on non-password fields. | +| **maskingRule** | `Enum<'phone' \| 'id_card' \| 'bank_account' \| 'email' \| 'name'> \| { keepHead: integer; keepTail: integer }` | optional | Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or `{ keepHead, keepTail }`. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. | +| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning. No effect on non-password fields. | | **system** | `boolean` | optional | Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag. | | **sortable** | `boolean` | optional (default: `true`) | Whether field is sortable in list views | | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | @@ -621,9 +621,9 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | -| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (#5248, ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (#5248, #4943, ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | +| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | --- @@ -739,8 +739,8 @@ Boolean-or-predicates override for a built-in CRUD affordance. | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object | -| **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose (#5315). | -| **organizationField** | `string` | optional | STAMP-ONLY (#8778, widened by cloud#1395): column carrying the organization a row is ABOUT, consulted by the three sanctioned platform-row writers — audit stamping, the approval-row writer (`plugin-approvals`), and the automation-run recorder (`service-automation`) — via the shared `resolveRecordOrganizationField` resolver in `@objectstack/metadata-core`. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. | +| **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose. | +| **organizationField** | `string` | optional | STAMP-ONLY: column carrying the organization a row is ABOUT, consulted by the three sanctioned platform-row writers — audit stamping, the approval-row writer (`plugin-approvals`), and the automation-run recorder (`service-automation`) — via the shared `resolveRecordOrganizationField` resolver in `@objectstack/metadata-core`. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. | --- diff --git a/content/docs/references/data/query.mdx b/content/docs/references/data/query.mdx index 9005249418..aa1d0329f4 100644 --- a/content/docs/references/data/query.mdx +++ b/content/docs/references/data/query.mdx @@ -48,8 +48,8 @@ const result = AggregationFunction.parse(data); | **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | Aggregation function | | **field** | `string` | optional | Field to aggregate (optional for COUNT(*)) | | **alias** | `string` | ✅ | Result column alias | -| **distinct** | `never` | optional | [REMOVED] `query.aggregations[].distinct` was removed in @objectstack/spec 17 (#6815, ADR-0049) — exactly ONE of the six faces that read an aggregation honoured it. The objectql in-memory fallback deduplicated the values before applying the function, while `driver-sql`, `driver-turso`, `driver-mongodb`, `driver-memory` and the service-analytics SQL builder all ignored it — so `{ function: 'sum', field: 'amount', distinct: true }` answered a DEDUPLICATED sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it. Both answers are plausible, so nothing surfaced the divergence. Delete the key. For a deduplicated COUNT the live spelling is the `count_distinct` aggregation function, which every SQL face compiles to `COUNT(DISTINCT field)` (#6409) and the in-memory fallback computes identically. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating is a modelling problem to fix in the data, not a flag on the read. | -| **filter** | `any` | optional | Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate (#10576): lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it. | +| **distinct** | `never` | optional | [REMOVED] `query.aggregations[].distinct` was removed in @objectstack/spec 17 (ADR-0049) — exactly ONE of the six faces that read an aggregation honoured it. The objectql in-memory fallback deduplicated the values before applying the function, while `driver-sql`, `driver-turso`, `driver-mongodb`, `driver-memory` and the service-analytics SQL builder all ignored it — so `{ function: 'sum', field: 'amount', distinct: true }` answered a DEDUPLICATED sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it. Both answers are plausible, so nothing surfaced the divergence. Delete the key. For a deduplicated COUNT the live spelling is the `count_distinct` aggregation function, which every SQL face compiles to `COUNT(DISTINCT field)` and the in-memory fallback computes identically. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating is a modelling problem to fix in the data, not a flag on the read. | +| **filter** | `any` | optional | Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate: lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it. | --- @@ -82,12 +82,12 @@ const result = AggregationFunction.parse(data); | :--- | :--- | :--- | :--- | | **query** | `string` | ✅ | Search query text | | **fields** | `string[]` | optional | Fields to search in (if not specified, searches all text fields) | -| **fuzzy** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag (#4286). | -| **operator** | `Enum<'and' \| 'or'>` | optional (default: `"or"`) | [EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag (#4286). | -| **boost** | `Record` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results (#4286). | -| **minScore** | `number` | optional | [EXPERIMENTAL — not enforced] Minimum relevance score threshold. No executor scores results (#4286). | -| **language** | `string` | optional | [EXPERIMENTAL — not enforced] Language for text analysis (e.g., "en", "zh", "es"). No executor selects an analyzer (#4286). | -| **highlight** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Search result highlighting. No executor emits highlights (#4286). | +| **fuzzy** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag. | +| **operator** | `Enum<'and' \| 'or'>` | optional (default: `"or"`) | [EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag. | +| **boost** | `Record` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | +| **minScore** | `number` | optional | [EXPERIMENTAL — not enforced] Minimum relevance score threshold. No executor scores results. | +| **language** | `string` | optional | [EXPERIMENTAL — not enforced] Language for text analysis (e.g., "en", "zh", "es"). No executor selects an analyzer. | +| **highlight** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Search result highlighting. No executor emits highlights. | --- @@ -126,7 +126,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | -| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD` (#7532). Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes (#6924). | +| **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | | **where** | `any` | optional | Filtering criteria (WHERE) | | **search** | `string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | @@ -134,13 +134,13 @@ Type: `string` | **limit** | `number` | optional | Max records to return (LIMIT) | | **offset** | `number` | optional | Records to skip (OFFSET) | | **top** | `number` | optional | Alias for limit (OData compatibility) | -| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | -| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and whose nested query selects the related record's own columns. Keep the foreign key in your own projection (`fields: ['title', 'owner_id']`): the relation is carried by that column, so projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever resolved one and the ingress refuses it (`400 INVALID_FIELD`, #7532). | +| **cursor** | `never` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | +| **joins** | `never` | optional | [REMOVED] `query.joins` was removed in @objectstack/spec 17 (ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name squatted on the reserved REST parameter set. Delete the key. Related records are read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which the engine resolves via batch $in queries, and whose nested query selects the related record's own columns. Keep the foreign key in your own projection (`fields: ['title', 'owner_id']`): the relation is carried by that column, so projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever resolved one and the ingress refuses it (`400 INVALID_FIELD`). | | **aggregations** | `{ function: Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>; field?: string; alias: string; filter?: any }[]` | optional | Aggregation functions | | **groupBy** | `(string \| { field: string; dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; alias?: string })[]` | optional | GROUP BY targets (strings or `{field, dateGranularity?}` objects for date bucketing) | | **having** | `any` | optional | HAVING — filter over the AGGREGATED rows (aggregation aliases + groupBy projections); applied engine-side after aggregation | -| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | -| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | +| **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | +| **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **expand** | `Record` | optional | Recursive relation loading map. Keys are lookup/master_detail field names; values are nested QueryAST objects that control select (`fields`) and filter (`where`, AND-merged with the batch $in), plus further expansion on the related object. The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3; per-parent `limit`/`offset`/`orderBy` are NOT applied on this path. | ### Nested Shape: `Query.search` @@ -149,12 +149,12 @@ Type: `string` | :--- | :--- | :--- | :--- | | **query** | `string` | ✅ | Search query text | | **fields** | `string[]` | optional | Fields to search in (if not specified, searches all text fields) | -| **fuzzy** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag (#4286). | -| **operator** | `Enum<'and' \| 'or'>` | optional (default: `"or"`) | [EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag (#4286). | -| **boost** | `Record` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results (#4286). | -| **minScore** | `number` | optional | [EXPERIMENTAL — not enforced] Minimum relevance score threshold. No executor scores results (#4286). | -| **language** | `string` | optional | [EXPERIMENTAL — not enforced] Language for text analysis (e.g., "en", "zh", "es"). No executor selects an analyzer (#4286). | -| **highlight** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Search result highlighting. No executor emits highlights (#4286). | +| **fuzzy** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag. | +| **operator** | `Enum<'and' \| 'or'>` | optional (default: `"or"`) | [EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag. | +| **boost** | `Record` | optional | [EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results. | +| **minScore** | `number` | optional | [EXPERIMENTAL — not enforced] Minimum relevance score threshold. No executor scores results. | +| **language** | `string` | optional | [EXPERIMENTAL — not enforced] Language for text analysis (e.g., "en", "zh", "es"). No executor selects an analyzer. | +| **highlight** | `boolean` | optional (default: `false`) | [EXPERIMENTAL — not enforced] Search result highlighting. No executor emits highlights. | ### Nested Shape: `Query.aggregations[number]` @@ -163,8 +163,8 @@ Type: `string` | **function** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | ✅ | Aggregation function | | **field** | `string` | optional | Field to aggregate (optional for COUNT(*)) | | **alias** | `string` | ✅ | Result column alias | -| **distinct** | `never` | optional | [REMOVED] `query.aggregations[].distinct` was removed in @objectstack/spec 17 (#6815, ADR-0049) — exactly ONE of the six faces that read an aggregation honoured it. The objectql in-memory fallback deduplicated the values before applying the function, while `driver-sql`, `driver-turso`, `driver-mongodb`, `driver-memory` and the service-analytics SQL builder all ignored it — so `{ function: 'sum', field: 'amount', distinct: true }` answered a DEDUPLICATED sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it. Both answers are plausible, so nothing surfaced the divergence. Delete the key. For a deduplicated COUNT the live spelling is the `count_distinct` aggregation function, which every SQL face compiles to `COUNT(DISTINCT field)` (#6409) and the in-memory fallback computes identically. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating is a modelling problem to fix in the data, not a flag on the read. | -| **filter** | `any` | optional | Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate (#10576): lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it. | +| **distinct** | `never` | optional | [REMOVED] `query.aggregations[].distinct` was removed in @objectstack/spec 17 (ADR-0049) — exactly ONE of the six faces that read an aggregation honoured it. The objectql in-memory fallback deduplicated the values before applying the function, while `driver-sql`, `driver-turso`, `driver-mongodb`, `driver-memory` and the service-analytics SQL builder all ignored it — so `{ function: 'sum', field: 'amount', distinct: true }` answered a DEDUPLICATED sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it. Both answers are plausible, so nothing surfaced the divergence. Delete the key. For a deduplicated COUNT the live spelling is the `count_distinct` aggregation function, which every SQL face compiles to `COUNT(DISTINCT field)` and the in-memory fallback computes identically. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating is a modelling problem to fix in the data, not a flag on the read. | +| **filter** | `any` | optional | Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate: lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it. | ### Nested Shape: `Query.groupBy[number]` diff --git a/content/docs/references/data/validation.mdx b/content/docs/references/data/validation.mdx index 00734203bc..6ec5925568 100644 --- a/content/docs/references/data/validation.mdx +++ b/content/docs/references/data/validation.mdx @@ -164,7 +164,7 @@ const result = ConditionalValidationSchema.parse(data); | **type** | `'state_machine'` | ✅ | | | **field** | `string` | ✅ | State field (e.g. status) | | **transitions** | `Record` | ✅ | Map of `{ OldState: [AllowedNewStates] }` | -| **initialStates** | `string[]` | optional | States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). #3165. | +| **initialStates** | `string[]` | optional | States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). | ### Nested Shape: `ConditionalValidation.then[type='format']` @@ -285,7 +285,7 @@ const result = ConditionalValidationSchema.parse(data); | **type** | `'state_machine'` | ✅ | | | **field** | `string` | ✅ | State field (e.g. status) | | **transitions** | `Record` | ✅ | Map of `{ OldState: [AllowedNewStates] }` | -| **initialStates** | `string[]` | optional | States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). #3165. | +| **initialStates** | `string[]` | optional | States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). | ### Nested Shape: `ConditionalValidation.otherwise[type='format']` @@ -504,7 +504,7 @@ const result = ConditionalValidationSchema.parse(data); | **type** | `'state_machine'` | ✅ | | | **field** | `string` | ✅ | State field (e.g. status) | | **transitions** | `Record` | ✅ | Map of `{ OldState: [AllowedNewStates] }` | -| **initialStates** | `string[]` | optional | States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). #3165. | +| **initialStates** | `string[]` | optional | States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). | --- @@ -571,7 +571,7 @@ This schema accepts one of the following structures: | **type** | `'state_machine'` | ✅ | | | **field** | `string` | ✅ | State field (e.g. status) | | **transitions** | `Record` | ✅ | Map of `{ OldState: [AllowedNewStates] }` | -| **initialStates** | `string[]` | optional | States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). #3165. | +| **initialStates** | `string[]` | optional | States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). | --- diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index e170e12b31..4b5c8b7572 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -165,21 +165,21 @@ Circuit breaker configuration | **type** | `Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>` | ✅ | Connector type | | **description** | `string` | optional | Connector description | | **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets (#7990): use `auth.credentialRef` on a provider-bound instance. | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | | **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). | | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | | **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | | **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | +| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions | | **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| object; realtimeSync?: boolean; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; defaultValue?: any; dataType?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (#4911, ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations | +| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional (default: `30000`) | Connection timeout in ms | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | | **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional (default: `"inactive"`) | Connector status | -| **enabled** | `boolean` | optional (default: `true`) | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | +| **enabled** | `boolean` | optional (default: `true`) | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor. | | **errorMapping** | `{ rules: object[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| …>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | | **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | | **metadata** | `Record` | optional | Custom connector metadata | @@ -294,7 +294,7 @@ Circuit breaker configuration | :--- | :--- | :--- | :--- | | **source** | `string` | ✅ | Source field name | | **target** | `string` | ✅ | Target field name | -| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (#5552, ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired in #3278. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **defaultValue** | `any` | optional | Default if source is null/undefined | | **dataType** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>` | optional | Target data type | | **required** | `boolean` | optional (default: `false`) | Field is required | @@ -323,7 +323,7 @@ Circuit breaker configuration | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -| **events** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'sync.started' \| …>[]` | optional | Connector events to subscribe to (not yet enforced — no runtime dispatches these; see #3197) | +| **events** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'sync.started' \| …>[]` | optional | Connector events to subscribe to | | **signatureAlgorithm** | `Enum<'hmac_sha256' \| 'hmac_sha512' \| 'none'>` | optional (default: `"hmac_sha256"`) | Webhook signature algorithm | ### Nested Shape: `Connector.retryConfig` @@ -426,7 +426,7 @@ Standard error category | :--- | :--- | :--- | :--- | | **source** | `string` | ✅ | Source field name | | **target** | `string` | ✅ | Target field name | -| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (#5552, ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired in #3278. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **defaultValue** | `any` | optional | Default if source is null/undefined | | **dataType** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>` | optional | Target data type | | **required** | `boolean` | optional (default: `false`) | Field is required | @@ -675,21 +675,21 @@ Connector type | **type** | `Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>` | ✅ | Connector type | | **description** | `string` | optional | Connector description | | **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets (#7990): use `auth.credentialRef` on a provider-bound instance. | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | | **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). | | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | | **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | | **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) | +| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions | | **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| object; realtimeSync?: boolean; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; defaultValue?: any; dataType?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) | -| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (#4911, ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations | +| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional (default: `30000`) | Connection timeout in ms | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | | **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional (default: `"inactive"`) | Connector status | -| **enabled** | `boolean` | optional (default: `true`) | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612). | +| **enabled** | `boolean` | optional (default: `true`) | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor. | | **errorMapping** | `{ rules: object[]; defaultCategory?: Enum<'validation' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| …>; unmappedBehavior: Enum<'passthrough' \| 'generic_error' \| 'throw'>; logUnmapped?: boolean }` | optional | Error mapping configuration | | **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | | **metadata** | `Record` | optional | Custom connector metadata | @@ -804,7 +804,7 @@ Connector type | :--- | :--- | :--- | :--- | | **source** | `string` | ✅ | Source field name | | **target** | `string` | ✅ | Target field name | -| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (#5552, ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired in #3278. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **defaultValue** | `any` | optional | Default if source is null/undefined | | **dataType** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>` | optional | Target data type | | **required** | `boolean` | optional (default: `false`) | Field is required | @@ -833,7 +833,7 @@ Connector type | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -| **events** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'sync.started' \| …>[]` | optional | Connector events to subscribe to (not yet enforced — no runtime dispatches these; see #3197) | +| **events** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'sync.started' \| …>[]` | optional | Connector events to subscribe to | | **signatureAlgorithm** | `Enum<'hmac_sha256' \| 'hmac_sha512' \| 'none'>` | optional (default: `"hmac_sha256"`) | Webhook signature algorithm | ### Nested Shape: `DeclarativeConnectorEntry.retryConfig` @@ -994,7 +994,7 @@ Synchronization strategy | **_packageId** | `string` | optional | Owning package machine id. | | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -| **events** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'sync.started' \| 'sync.completed' \| 'sync.failed' \| 'auth.expired' \| 'rate_limit.exceeded'>[]` | optional | Connector events to subscribe to (not yet enforced — no runtime dispatches these; see #3197) | +| **events** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'sync.started' \| 'sync.completed' \| 'sync.failed' \| 'auth.expired' \| 'rate_limit.exceeded'>[]` | optional | Connector events to subscribe to | | **signatureAlgorithm** | `Enum<'hmac_sha256' \| 'hmac_sha512' \| 'none'>` | optional (default: `"hmac_sha256"`) | Webhook signature algorithm | ### Nested Shape: `WebhookConfig.protection` diff --git a/content/docs/references/kernel/context.mdx b/content/docs/references/kernel/context.mdx index 7d6ef892c0..bebb71cc85 100644 --- a/content/docs/references/kernel/context.mdx +++ b/content/docs/references/kernel/context.mdx @@ -35,7 +35,7 @@ const result = KernelContextSchema.parse(data); | **workspaceRoot** | `string` | optional | Workspace root if different from cwd | | **startTime** | `integer` | ✅ | Boot timestamp (ms) | | **features** | `Record` | optional (default: `{}`) | Global feature toggles | -| **previewMode** | `never` | optional | [REMOVED] `context.previewMode` was removed in @objectstack/spec 17 (#11846, ADR-0049 enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, `simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) had a consumer in any repo, so an authored block parsed cleanly and configured NOTHING, while its own docstring promised an auth bypass ("skips authentication screens", "simulates an admin identity") and named a production guard no runtime ever received. Delete the key. Preview/demo deployments belong to the deployment layer, which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); `OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (#11846 ruling record). | +| **previewMode** | `never` | optional | [REMOVED] `context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, `simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) had a consumer in any repo, so an authored block parsed cleanly and configured NOTHING, while its own docstring promised an auth bypass ("skips authentication screens", "simulates an admin identity") and named a production guard no runtime ever received. Delete the key. Preview/demo deployments belong to the deployment layer, which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); `OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (ruling record). | --- @@ -70,7 +70,7 @@ Tenant-aware kernel runtime context | **workspaceRoot** | `string` | optional | Workspace root if different from cwd | | **startTime** | `integer` | ✅ | Boot timestamp (ms) | | **features** | `Record` | optional (default: `{}`) | Global feature toggles | -| **previewMode** | `never` | optional | [REMOVED] `context.previewMode` was removed in @objectstack/spec 17 (#11846, ADR-0049 enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, `simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) had a consumer in any repo, so an authored block parsed cleanly and configured NOTHING, while its own docstring promised an auth bypass ("skips authentication screens", "simulates an admin identity") and named a production guard no runtime ever received. Delete the key. Preview/demo deployments belong to the deployment layer, which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); `OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (#11846 ruling record). | +| **previewMode** | `never` | optional | [REMOVED] `context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, `simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) had a consumer in any repo, so an authored block parsed cleanly and configured NOTHING, while its own docstring promised an auth bypass ("skips authentication screens", "simulates an admin identity") and named a production guard no runtime ever received. Delete the key. Preview/demo deployments belong to the deployment layer, which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); `OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (ruling record). | | **tenantId** | `string` | ✅ | Resolved tenant identifier | | **tenantPlan** | `Enum<'free' \| 'pro' \| 'enterprise'>` | ✅ | Tenant subscription plan | | **tenantRegion** | `string` | optional | Tenant deployment region | diff --git a/content/docs/references/kernel/execution-context.mdx b/content/docs/references/kernel/execution-context.mdx index 0ab8182cd5..c38f9ef8ea 100644 --- a/content/docs/references/kernel/execution-context.mdx +++ b/content/docs/references/kernel/execution-context.mdx @@ -67,7 +67,7 @@ const result = ExecutionContextSchema.parse(data); | **skipAutomations** | `boolean` | optional | | | **seedReplay** | `boolean` | optional | | | **skipStateMachine** | `boolean` | optional | | -| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | +| **preserveAudit** | `boolean` | optional | Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected. | | **oauthScopes** | `string[]` | optional | | | **accessToken** | `string` | optional | | | **transaction** | `any` | optional | | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 5d89fc0fa7..b97062b0f5 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -45,7 +45,7 @@ const result = ManifestSchema.parse(data); | **capabilities** | `{ implements?: object[]; provides?: object[]; requires?: object[]; extensionPoints?: object[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (#4914, ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier (ADR-0025 §3.6) | @@ -75,16 +75,16 @@ Structured plugin permission grants (ADR-0025 §3.2) | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **kinds** | `{ id: string; description?: string }[]` | optional | Metadata kind identifiers this package registers | -| **events** | `never` | optional | [REMOVED] `manifest.contributes.events` was removed in @objectstack/spec 17 (#10724, ADR-0049 enforce-or-remove) — nothing ever read the list: its only in-repo author already subscribed imperatively in plugin code, so the declaration was decorative. Delete the key. Subscribe to system events in the plugin itself — `ctx.hook('kernel:ready', …)` (or the events service) from `init`/`start` is the enforced channel; record lifecycle hooks register on the data engine. | -| **menus** | `never` | optional | [REMOVED] `manifest.contributes.menus` was removed in @objectstack/spec 17 (#10724, ADR-0049 enforce-or-remove) — no renderer ever read it; two alias maps already redirected this spelling to `navigation`. Delete the key. Declare navigation in the app's `navigation` tree, or inject items into another package's app via `manifest.navigationContributions` (ADR-0029 D7), which the engine registers. | -| **themes** | `never` | optional | [REMOVED] `manifest.contributes.themes` was removed in @objectstack/spec 17 (#10724, ADR-0049 enforce-or-remove) — it never had an effect: theme registration reaches the registry only through the stack-level `themes` collection (a `ThemeSchema` surface, unrelated to this `{ id, label, path }` shape), never through `contributes.themes`. Delete the key; declare themes in the stack `themes` collection instead. | -| **translations** | `never` | optional | [REMOVED] `manifest.contributes.translations` was removed in @objectstack/spec 17 (#10724, ADR-0049 enforce-or-remove) — no loader ever read these `{ locale, path }` entries; authoring them registered no translations. Delete the key. Declare translations as `translation` metadata: `defineTranslationBundle({ … })` in the stack's `translations` collection (`defineStack({ translations: […] })`), which the engine registers and the i18n pipeline serves. | -| **actions** | `never` | optional | [REMOVED] `manifest.contributes.actions` was removed in @objectstack/spec 17 (#10724, ADR-0049 enforce-or-remove) — nothing ever read it; actions declared here were never invocable. Delete the key. Declare actions in the stack `actions` collection (registered by the engine) or register imperatively via `engine.registerAction`. | -| **drivers** | `never` | optional | [REMOVED] `manifest.contributes.drivers` was removed in @objectstack/spec 17 (#10724, ADR-0049 enforce-or-remove) — it never had an effect: a storage driver is wired by registering a kernel SERVICE named `driver.*` (the objectql plugin picks it up and calls `registerDriver`), and its only in-repo author was registered that way, not by this declaration. Delete the key. | -| **fieldTypes** | `never` | optional | [REMOVED] `manifest.contributes.fieldTypes` was removed in @objectstack/spec 17 (#10724, ADR-0049 enforce-or-remove) — there is no `registerFieldType` seam anywhere: the declaration advertised an extension point the platform does not have, so authoring it configured nothing. Delete the key. The field-type vocabulary is the spec `FieldType` enum; extending it is a spec change, not a manifest declaration. | -| **functions** | `never` | optional | [REMOVED] `manifest.contributes.functions` was removed in @objectstack/spec 17 (#10724, ADR-0049 enforce-or-remove) — nothing ever read it; ObjectQL functions declared here were never registered. Delete the key. Declare functions on the stack (`defineStack({ functions: […] })`), which the hook binder registers via `engine.registerFunction`. | -| **routes** | `never` | optional | [REMOVED] `manifest.contributes.routes` was removed in @objectstack/spec 17 (#10726, ADR-0049 enforce-or-remove) — nothing ever read it: the HttpDispatcher never registered a prefix from the declaration, so an entry here parsed cleanly and served nothing while published material kept recommending it. Delete the key. A route that needs real handler CODE is mounted imperatively: resolve the `http.server` service from the plugin context and register the handler on `kernel:ready`. A declarative endpoint over a pipeline the platform already runs (query/return records, trigger a flow) is `defineStack({ apis })`. | -| **commands** | `never` | optional | [REMOVED] `manifest.contributes.commands` was removed in @objectstack/spec 17 (#10724, ADR-0049 enforce-or-remove) — the CLI never resolved commands from this declaration: commands are auto-discovered through oclif's native plugin system (the plugin package declares an `oclif` section in its own `package.json`; see `cli-extension.zod.ts`), and the `objectstack.config.ts` plugins array no longer determines CLI commands. Delete the key. | +| **events** | `never` | optional | [REMOVED] `manifest.contributes.events` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the list: its only in-repo author already subscribed imperatively in plugin code, so the declaration was decorative. Delete the key. Subscribe to system events in the plugin itself — `ctx.hook('kernel:ready', …)` (or the events service) from `init`/`start` is the enforced channel; record lifecycle hooks register on the data engine. | +| **menus** | `never` | optional | [REMOVED] `manifest.contributes.menus` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no renderer ever read it; two alias maps already redirected this spelling to `navigation`. Delete the key. Declare navigation in the app's `navigation` tree, or inject items into another package's app via `manifest.navigationContributions` (ADR-0029 D7), which the engine registers. | +| **themes** | `never` | optional | [REMOVED] `manifest.contributes.themes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: theme registration reaches the registry only through the stack-level `themes` collection (a `ThemeSchema` surface, unrelated to this `{ id, label, path }` shape), never through `contributes.themes`. Delete the key; declare themes in the stack `themes` collection instead. | +| **translations** | `never` | optional | [REMOVED] `manifest.contributes.translations` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no loader ever read these `{ locale, path }` entries; authoring them registered no translations. Delete the key. Declare translations as `translation` metadata: `defineTranslationBundle({ … })` in the stack's `translations` collection (`defineStack({ translations: […] })`), which the engine registers and the i18n pipeline serves. | +| **actions** | `never` | optional | [REMOVED] `manifest.contributes.actions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it; actions declared here were never invocable. Delete the key. Declare actions in the stack `actions` collection (registered by the engine) or register imperatively via `engine.registerAction`. | +| **drivers** | `never` | optional | [REMOVED] `manifest.contributes.drivers` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: a storage driver is wired by registering a kernel SERVICE named `driver.*` (the objectql plugin picks it up and calls `registerDriver`), and its only in-repo author was registered that way, not by this declaration. Delete the key. | +| **fieldTypes** | `never` | optional | [REMOVED] `manifest.contributes.fieldTypes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — there is no `registerFieldType` seam anywhere: the declaration advertised an extension point the platform does not have, so authoring it configured nothing. Delete the key. The field-type vocabulary is the spec `FieldType` enum; extending it is a spec change, not a manifest declaration. | +| **functions** | `never` | optional | [REMOVED] `manifest.contributes.functions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it; ObjectQL functions declared here were never registered. Delete the key. Declare functions on the stack (`defineStack({ functions: […] })`), which the hook binder registers via `engine.registerFunction`. | +| **routes** | `never` | optional | [REMOVED] `manifest.contributes.routes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: the HttpDispatcher never registered a prefix from the declaration, so an entry here parsed cleanly and served nothing while published material kept recommending it. Delete the key. A route that needs real handler CODE is mounted imperatively: resolve the `http.server` service from the plugin context and register the handler on `kernel:ready`. A declarative endpoint over a pipeline the platform already runs (query/return records, trigger a flow) is `defineStack({ apis })`. | +| **commands** | `never` | optional | [REMOVED] `manifest.contributes.commands` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the CLI never resolved commands from this declaration: commands are auto-discovered through oclif's native plugin system (the plugin package declares an `oclif` section in its own `package.json`; see `cli-extension.zod.ts`), and the `objectstack.config.ts` plugins array no longer determines CLI commands. Delete the key. | ### Nested Shape: `Manifest.data[number]` diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 3ebbbadc21..b907ca8edf 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -103,7 +103,7 @@ const result = MetadataBulkResultSchema.parse(data); | **storage** | `{ datasource?: string; tableName: string; fallback: Enum<'filesystem' \| 'memory' \| 'none'>; rootDir?: string; … }` | ✅ | Storage backend configuration | | **customizationPolicies** | `{ metadataType: string; allowCustomization: boolean; lockedFields?: string[]; customizableFields?: string[]; … }[]` | optional | Default customization policies per type | | **mergeStrategy** | `{ defaultStrategy: Enum<'keep-custom' \| 'accept-incoming' \| 'three-way-merge'>; alwaysAcceptIncoming?: string[]; alwaysKeepCustom?: string[]; autoResolveNonConflicting: boolean }` | optional | Merge strategy for package upgrades | -| **additionalTypes** | `never` | optional | [REMOVED] `config.additionalTypes` was removed from `MetadataPluginConfig` in @objectstack/spec 17 (#8586, 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. | +| **additionalTypes** | `never` | optional | [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. | | **enableEvents** | `boolean` | optional (default: `true`) | Emit metadata change events | | **validateOnWrite** | `boolean` | optional (default: `true`) | Validate metadata on write | | **enableVersioning** | `boolean` | optional (default: `false`) | Track metadata version history | @@ -183,7 +183,7 @@ const result = MetadataBulkResultSchema.parse(data); | **storage** | `{ datasource?: string; tableName: string; fallback: Enum<'filesystem' \| 'memory' \| 'none'>; rootDir?: string; … }` | ✅ | Storage backend configuration | | **customizationPolicies** | `{ metadataType: string; allowCustomization: boolean; lockedFields?: string[]; customizableFields?: string[]; … }[]` | optional | Default customization policies per type | | **mergeStrategy** | `{ defaultStrategy: Enum<'keep-custom' \| 'accept-incoming' \| 'three-way-merge'>; alwaysAcceptIncoming?: string[]; alwaysKeepCustom?: string[]; autoResolveNonConflicting: boolean }` | optional | Merge strategy for package upgrades | -| **additionalTypes** | `never` | optional | [REMOVED] `config.additionalTypes` was removed from `MetadataPluginConfig` in @objectstack/spec 17 (#8586, 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. | +| **additionalTypes** | `never` | optional | [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. | | **enableEvents** | `boolean` | optional (default: `true`) | Emit metadata change events | | **validateOnWrite** | `boolean` | optional (default: `true`) | Validate metadata on write | | **enableVersioning** | `boolean` | optional (default: `false`) | Track metadata version history | @@ -331,7 +331,7 @@ const result = MetadataBulkResultSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | Display label | -| **description** | `string \| Record` | optional | Explanatory 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 — #7278). Not the LLM-facing `ai.description`. | +| **description** | `string \| Record` | optional | Explanatory 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`. | | **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). | | **icon** | `string` | optional | Icon name | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Locations where this action is visible | @@ -340,11 +340,11 @@ const result = MetadataBulkResultSchema.parse(data); | **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For 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; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. | -| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 (#3855) — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort 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. | -| **confirmText** | `string \| Record` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused (#7428) — 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. | +| **confirmText** | `string \| Record` | optional | Confirmation message before execution. On a registered action, 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. | | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | @@ -354,8 +354,8 @@ const result = MetadataBulkResultSchema.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | -| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | @@ -365,7 +365,7 @@ const result = MetadataBulkResultSchema.parse(data); | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open 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. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions (#9566/#9474). `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index 32c31b10e3..721755b7f8 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -209,7 +209,7 @@ Install package request | **capabilities** | `{ implements?: object[]; provides?: object[]; requires?: object[]; extensionPoints?: object[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (#4914, ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier (ADR-0025 §3.6) | @@ -304,7 +304,7 @@ Installed package with runtime lifecycle state | **capabilities** | `{ implements?: object[]; provides?: object[]; requires?: object[]; extensionPoints?: object[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (#4914, ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier (ADR-0025 §3.6) | diff --git a/content/docs/references/kernel/package-upgrade.mdx b/content/docs/references/kernel/package-upgrade.mdx index 180e04a7bd..18bd54c704 100644 --- a/content/docs/references/kernel/package-upgrade.mdx +++ b/content/docs/references/kernel/package-upgrade.mdx @@ -158,7 +158,7 @@ Upgrade package request | **capabilities** | `{ implements?: object[]; provides?: object[]; requires?: object[]; extensionPoints?: object[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (#4914, ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier (ADR-0025 §3.6) | @@ -300,7 +300,7 @@ Pre-upgrade state snapshot for rollback capability | **capabilities** | `{ implements?: object[]; provides?: object[]; requires?: object[]; extensionPoints?: object[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (#4914, ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier (ADR-0025 §3.6) | diff --git a/content/docs/references/kernel/plugin-lifecycle-advanced.mdx b/content/docs/references/kernel/plugin-lifecycle-advanced.mdx index 4263d7df98..ee0e588bbc 100644 --- a/content/docs/references/kernel/plugin-lifecycle-advanced.mdx +++ b/content/docs/references/kernel/plugin-lifecycle-advanced.mdx @@ -41,7 +41,7 @@ const result = HotReloadConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | | -| **watchPatterns** | `never` | optional | [REMOVED] `HotReloadConfig.watchPatterns` was removed in @objectstack/spec 18 (#12428, ADR-0049 enforce-or-remove) — nothing ever read it. Its only two uses were log lines in `HotReloadManager`, and one of them announced 'File watching started' at INFO level while no watcher was ever constructed: `startWatching` held a placeholder, and `watchHandles` was read, deleted, iterated and cleared but never set. So an author could declare a glob and no file change could ever trigger a reload. Delete the key. File watching is the HOST's job in this host-driven library: run your own watcher, declare your globs wherever that watcher reads them, and call `HotReloadManager.scheduleReload(pluginName, reloadFn)` when one matches — the debounced integration point this class does implement, and which is unchanged. | +| **watchPatterns** | `never` | optional | [REMOVED] `HotReloadConfig.watchPatterns` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing ever read it. Its only two uses were log lines in `HotReloadManager`, and one of them announced 'File watching started' at INFO level while no watcher was ever constructed: `startWatching` held a placeholder, and `watchHandles` was read, deleted, iterated and cleared but never set. So an author could declare a glob and no file change could ever trigger a reload. Delete the key. File watching is the HOST's job in this host-driven library: run your own watcher, declare your globs wherever that watcher reads them, and call `HotReloadManager.scheduleReload(pluginName, reloadFn)` when one matches — the debounced integration point this class does implement, and which is unchanged. | | **debounceDelay** | `integer` | optional (default: `1000`) | Wait time after change detection before reload | | **preserveState** | `boolean` | optional (default: `true`) | Keep plugin state across reloads | | **stateStrategy** | `Enum<'memory' \| 'none'>` | optional (default: `"memory"`) | How to preserve state during reload | @@ -63,9 +63,9 @@ const result = HotReloadConfigSchema.parse(data); | **failureThreshold** | `integer` | optional (default: `3`) | Consecutive failures needed to mark unhealthy | | **successThreshold** | `integer` | optional (default: `1`) | Consecutive successes needed to mark healthy | | **checkMethod** | `string` | optional | Method name to call for health check | -| **autoRestart** | `never` | optional | [REMOVED] `PluginHealthCheck.autoRestart` was removed in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) — it never restarted a plugin. A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. | -| **maxRestartAttempts** | `never` | optional | [REMOVED] `PluginHealthCheck.maxRestartAttempts` was removed in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) — it capped a restart that never happened. A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. The cap counted destroy calls, so raising it only scheduled further "restarts" of a plugin that was never brought back up. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. | -| **restartBackoff** | `never` | optional | [REMOVED] `PluginHealthCheck.restartBackoff` was removed in @objectstack/spec 18 (#12032, ADR-0049 enforce-or-remove) — it delayed a restart that never happened. A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. The chosen strategy only moved when the destroy landed. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. | +| **autoRestart** | `never` | optional | [REMOVED] `PluginHealthCheck.autoRestart` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never restarted a plugin.A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. | +| **maxRestartAttempts** | `never` | optional | [REMOVED] `PluginHealthCheck.maxRestartAttempts` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it capped a restart that never happened.A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. The cap counted destroy calls, so raising it only scheduled further "restarts" of a plugin that was never brought back up. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. | +| **restartBackoff** | `never` | optional | [REMOVED] `PluginHealthCheck.restartBackoff` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it delayed a restart that never happened.A `PluginHealthMonitor` never restarted anything. `attemptRestart` called `plugin.destroy()` and stopped there — the in-source comment said "Call destroy and init to restart", but `init` appeared in `health-monitor.ts` ONLY inside that comment. What a plugin actually got was: destroy, a log line reading 'Plugin restarted', status `recovering`, and periodic health checks continuing against the destroyed instance — which the default check (`{ name: 'plugin-loaded', status: 'passed' }`, used whenever no `checkMethod` resolves) passes forever, so the terminal report on a destroyed, never-re-initialised plugin was `healthy`. The chosen strategy only moved when the destroy landed. Delete the key. Restarting a plugin is the HOST's job in this host-driven library, and the monitor could not do it even in principle: `Plugin.init(ctx)` needs a `PluginContext`, which only the kernel constructs and which it exposes to nobody (`ObjectKernel.context` is private; `KernelBase.createContext` is protected). Poll `getHealthStatus(pluginName)` / `getHealthReport(pluginName)` and act on `unhealthy` / `failed` at the level that owns the plugin's lifetime — recreate the kernel, or let your supervisor restart the process. The monitor reports; it does not act. | --- diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 4d0a2081c0..e2e81725db 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -55,15 +55,15 @@ const result = AdminScopeSchema.parse(data); | **allowRead** | `boolean` | optional (default: `false`) | Read permission | | **allowEdit** | `boolean` | optional (default: `false`) | Edit permission | | **allowDelete** | `boolean` | optional (default: `false`) | Delete permission | -| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | -| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | -| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | -| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | +| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) | +| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) | -| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). | +| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies. | | **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org | | **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org | -| **apiOperations** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Server-resolved effective API operations for this object (#3391). Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs, #3543), not the authored six-value ApiMethod enum. | +| **apiOperations** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Server-resolved effective API operations for this object. Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs), not the authored six-value ApiMethod enum. | --- @@ -103,12 +103,12 @@ const result = AdminScopeSchema.parse(data); | **allowRead** | `boolean` | optional (default: `false`) | Read permission | | **allowEdit** | `boolean` | optional (default: `false`) | Edit permission | | **allowDelete** | `boolean` | optional (default: `false`) | Delete permission | -| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | -| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | -| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | -| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | +| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) | +| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) | -| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). | +| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies. | | **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org | | **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org | @@ -150,12 +150,12 @@ const result = AdminScopeSchema.parse(data); | **allowRead** | `boolean` | optional (default: `false`) | Read permission | | **allowEdit** | `boolean` | optional (default: `false`) | Edit permission | | **allowDelete** | `boolean` | optional (default: `false`) | Delete permission | -| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | -| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | -| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | -| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. | +| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) | +| **allowRestore** | `never` | optional | [REMOVED] `objects..allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **allowPurge** | `never` | optional | [REMOVED] `objects..allowPurge` was removed in @objectstack/spec 17 (ADR-0049) — the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing (a compliance/GDPR erase the author believed was permission-locked was not — the operation itself does not exist). Delete the key — a dispatched `purge` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **viewAllRecords** | `boolean` | optional (default: `false`) | View All Data (Bypass Sharing) | -| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698). | +| **modifyAllRecords** | `boolean` | optional (default: `false`) | Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies. | | **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org | | **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org | @@ -179,7 +179,7 @@ const result = AdminScopeSchema.parse(data); | **check** | `string` | optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level) | | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | -| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0 (#3896 security audit). It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **tags** | `string[]` | optional | Policy categorization tags | ### Nested Shape: `PermissionSet.adminScope` diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index 02e8723136..dab0e05b38 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -175,7 +175,7 @@ const result = RLSEvaluationResultSchema.parse(data); | **check** | `string` | optional | Validation condition for INSERT/UPDATE (defaults to USING clause if not specified - enforced at application level) | | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | -| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0 (#3896 security audit). It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect and could not: applicable policies OR-combine (most permissive wins), so there is no conflict to order. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **tags** | `string[]` | optional | Policy categorization tags | diff --git a/content/docs/references/shared/mapping.mdx b/content/docs/references/shared/mapping.mdx index 18424c837a..cba9e4ef28 100644 --- a/content/docs/references/shared/mapping.mdx +++ b/content/docs/references/shared/mapping.mdx @@ -75,7 +75,7 @@ const result = FieldMappingSchema.parse(data); | :--- | :--- | :--- | :--- | | **source** | `string` | ✅ | Source field name | | **target** | `string` | ✅ | Target field name | -| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (#5552, ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired in #3278. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **defaultValue** | `any` | optional | Default if source is null/undefined | diff --git a/content/docs/references/system/book.mdx b/content/docs/references/system/book.mdx index 73f4b56fde..ad26d83997 100644 --- a/content/docs/references/system/book.mdx +++ b/content/docs/references/system/book.mdx @@ -67,7 +67,7 @@ const result = BookSchema.parse(data); | :--- | :--- | :--- | :--- | | **key** | `string` | ✅ | Stable group key (used by overrides, deep links, explicit `doc.group`) | | **label** | `string` | ✅ | Section title — first-class, i18n-homed | -| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **order** | `number` | optional | Order of THIS group within the book | | **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) | | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | @@ -115,7 +115,7 @@ Type: `'public'` | :--- | :--- | :--- | :--- | | **key** | `string` | ✅ | Stable group key (used by overrides, deep links, explicit `doc.group`) | | **label** | `string` | ✅ | Section title — first-class, i18n-homed | -| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **order** | `number` | optional | Order of THIS group within the book | | **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) | | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | diff --git a/content/docs/references/system/job.mdx b/content/docs/references/system/job.mdx index 62eb0901ca..f57c83d188 100644 --- a/content/docs/references/system/job.mdx +++ b/content/docs/references/system/job.mdx @@ -60,8 +60,8 @@ const result = CronScheduleSchema.parse(data); | **description** | `string` | optional | Job description / purpose | | **schedule** | `{ type: 'cron'; expression: string \| object; timezone?: string } \| { type: 'interval'; intervalMs: integer } \| { type: 'once'; at: string }` | ✅ | Job schedule configuration | | **handler** | `string` | ✅ | Handler function name (must match a key in `defineStack({ functions })`) | -| **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt (#3494). Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 (#4661) — state a count to opt in. | -| **timeout** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout" (#3494). The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. | +| **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt. Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 — state a count to opt in. | +| **timeout** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. | | **enabled** | `boolean` | optional (default: `true`) | Whether the job is enabled | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -102,7 +102,7 @@ const result = CronScheduleSchema.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | --- @@ -159,7 +159,7 @@ const result = CronScheduleSchema.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | --- diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 5fa2e557ce..a04fcb87a5 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -57,19 +57,19 @@ Add a new field to an existing object | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Format string (e.g. email, phone) | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (#9447, maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | -| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | +| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | | **min** | `number` | optional | Minimum value | | **max** | `number` | optional | Maximum value | -| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | @@ -78,7 +78,7 @@ Add a new field to an existing object | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | -| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn, #3951); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | +| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | | **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | @@ -102,15 +102,15 @@ Add a new field to an existing object | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'` | -| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17 (#3855). | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17. | +| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | -| **internal** | `boolean` | optional | [#7728] Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | -| **readonly** | `boolean` | optional (default: `false`) | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE (#2948/#3003) and on INSERT (#3043; a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`, #3493) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply (#6640): a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | +| **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | +| **readonly** | `boolean` | optional (default: `false`) | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE and on INSERT (a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply: a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate). | -| **maskingRule** | `Enum<'phone' \| 'id_card' \| 'bank_account' \| 'email' \| 'name'> \| { keepHead: integer; keepTail: integer }` | optional | [#8993] Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or `{ keepHead, keepTail }`. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. | -| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning (#3420). No effect on non-password fields. | +| **maskingRule** | `Enum<'phone' \| 'id_card' \| 'bank_account' \| 'email' \| 'name'> \| { keepHead: integer; keepTail: integer }` | optional | Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or `{ keepHead, keepTail }`. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. | +| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning. No effect on non-password fields. | | **system** | `boolean` | optional | Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag. | | **sortable** | `boolean` | optional (default: `true`) | Whether field is sortable in list views | | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | @@ -339,9 +339,9 @@ Create a new object | **titleFormat** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | -| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (#11408, #10144 family). | +| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | -| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused (#6674) — mirror the value onto a stored text field and declare that. | +| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be <= sharingModel in openness. | @@ -374,7 +374,7 @@ Deployment-level record that a data migration ran here and its self-check passed | **blocking** | `integer` | ✅ | Blocking discrepancies reported by the last self-check. The gate requires 0 | | **advisory** | `integer` | optional | Advisory findings from the last run (external URLs, stale owners, …) — cost storage or need a modelling decision, never block the gate | | **details** | `string` | optional | JSON-encoded counts from the last run, for diagnostics | -| **deviation_observed_at** | `string \| null` | optional | When this deployment last ADMITTED a value the verified contract rejects, via an OS_ALLOW_LAX_* escape hatch. Does not clear verified_at — it withdraws the irreversible half of what the certificate authorises (#4797) | +| **deviation_observed_at** | `string \| null` | optional | When this deployment last ADMITTED a value the verified contract rejects, via an OS_ALLOW_LAX_* escape hatch. Does not clear verified_at — it withdraws the irreversible half of what the certificate authorises | | **deviation_detail** | `string \| null` | optional | JSON-encoded first counterexample behind deviation_observed_at (object, field, type, parse issue), for diagnostics | @@ -474,19 +474,19 @@ Add a new field to an existing object | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Format string (e.g. email, phone) | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (#9447, maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | -| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | +| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | | **min** | `number` | optional | Minimum value | | **max** | `number` | optional | Maximum value | -| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | @@ -495,7 +495,7 @@ Add a new field to an existing object | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | -| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn, #3951); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | +| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | | **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | @@ -519,15 +519,15 @@ Add a new field to an existing object | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'` | -| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17 (#3855). | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17. | +| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | -| **internal** | `boolean` | optional | [#7728] Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | -| **readonly** | `boolean` | optional (default: `false`) | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE (#2948/#3003) and on INSERT (#3043; a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`, #3493) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply (#6640): a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | +| **internal** | `boolean` | optional | Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column. | +| **readonly** | `boolean` | optional (default: `false`) | Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE and on INSERT (a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply: a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate). | -| **maskingRule** | `Enum<'phone' \| 'id_card' \| 'bank_account' \| 'email' \| 'name'> \| { keepHead: integer; keepTail: integer }` | optional | [#8993] Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or `{ keepHead, keepTail }`. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. | -| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning (#3420). No effect on non-password fields. | +| **maskingRule** | `Enum<'phone' \| 'id_card' \| 'bank_account' \| 'email' \| 'name'> \| { keepHead: integer; keepTail: integer }` | optional | Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or `{ keepHead, keepTail }`. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. | +| **ackPlaintextMasking** | `boolean` | optional | [ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning. No effect on non-password fields. | | **system** | `boolean` | optional | Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag. | | **sortable** | `boolean` | optional (default: `true`) | Whether field is sortable in list views | | **inlineHelpText** | `string` | optional | Help text displayed below the field in forms | @@ -621,9 +621,9 @@ Create a new object | **titleFormat** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | -| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (#11408, #10144 family). | +| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | -| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused (#6674) — mirror the value onto a stored text field and declare that. | +| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be <= sharingModel in openness. | diff --git a/content/docs/references/system/notification.mdx b/content/docs/references/system/notification.mdx index b35ea46b4d..900c593c31 100644 --- a/content/docs/references/system/notification.mdx +++ b/content/docs/references/system/notification.mdx @@ -23,7 +23,7 @@ const result = NotificationChannelSchema.parse(data); ## NotificationChannel -Notification delivery channel (implemented today: inbox, email, sms — push/slack/teams/webhook are not yet implemented and dead-letter; see #3197) +Notification delivery channel ### Allowed Values diff --git a/content/docs/references/ui/action-params.mdx b/content/docs/references/ui/action-params.mdx index 14b1b0b19f..115fccaa8e 100644 --- a/content/docs/references/ui/action-params.mdx +++ b/content/docs/references/ui/action-params.mdx @@ -55,8 +55,8 @@ Action-body `ctx.session` — the caller identity an action body reads (runtime | :--- | :--- | :--- | :--- | | **userId** | `string` | optional | Invoking user id (absent when the call carries no user) | | **organizationId** | `string` | optional | Active organization id (blessed developer-facing name; absent when the call is org-less) | -| **positions** | `string[]` | optional | Position names held by the caller (ADR-0090 D3 vocabulary; the value of `ExecutionContext.positions`, whose schema comment reads "Formerly `roles`") — the CANONICAL spelling at this boundary and the key an action body should read. Within the #5613 deprecation window `buildActionSession()` emits the same array under both this key and the deprecated `roles`, so migrating is a change of key and nothing else; `roles` is then removed on the v16 session-alias removal path (#3280 deprecate → #3290 remove: one window, then gone). Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a position-name string comparison. | -| **roles** | `string[]` | optional | DEPRECATED alias of `positions` — the same caller position names under the one spelling ADR-0090 D3 forbids (the value is `ExecutionContext.positions`, "Formerly `roles`"). Read `positions` instead: within the #5613 deprecation window `buildActionSession()` emits both keys with identical values, so migrating is a change of key and nothing else. The migration prescription and its acceptance criteria are the ADR-0087 semantic migration `action-session-roles-to-positions`; removal follows the v16 session-alias removal path (#3280 deprecated → #3290 removed). Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a role-name string comparison. | +| **positions** | `string[]` | optional | Position names held by the caller (ADR-0090 D3 vocabulary; the value of `ExecutionContext.positions`, whose schema comment reads "Formerly `roles`") — the CANONICAL spelling at this boundary and the key an action body should read. Within the deprecation window `buildActionSession()` emits the same array under both this key and the deprecated `roles`, so migrating is a change of key and nothing else; `roles` is then removed on the v16 session-alias removal path (deprecate → remove: one window, then gone). Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a position-name string comparison. | +| **roles** | `string[]` | optional | DEPRECATED alias of `positions` — the same caller position names under the one spelling ADR-0090 D3 forbids (the value is `ExecutionContext.positions`, "Formerly `roles`"). Read `positions` instead: within the deprecation window `buildActionSession()` emits both keys with identical values, so migrating is a change of key and nothing else. The migration prescription and its acceptance criteria are the ADR-0087 semantic migration `action-session-roles-to-positions`; removal follows the v16 session-alias removal path (deprecated → removed). Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a role-name string comparison. | --- diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index c335ad6c93..af20ace4c8 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -62,7 +62,7 @@ const result = ActionSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | Display label | -| **description** | `string \| Record` | optional | Explanatory 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 — #7278). Not the LLM-facing `ai.description`. | +| **description** | `string \| Record` | optional | Explanatory 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`. | | **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). | | **icon** | `string` | optional | Icon name | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Locations where this action is visible | @@ -71,11 +71,11 @@ const result = ActionSchema.parse(data); | **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For 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; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. | -| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 (#3855) — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort 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. | -| **confirmText** | `string \| Record` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused (#7428) — 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. | +| **confirmText** | `string \| Record` | optional | Confirmation message before execution. On a registered action, 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. | | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | @@ -85,8 +85,8 @@ const result = ActionSchema.parse(data); | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | -| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | @@ -96,7 +96,7 @@ const result = ActionSchema.parse(data); | **mode** | `Enum<'create' \| 'edit' \| 'delete' \| 'custom'>` | optional | Semantic mode of the action. | | **opensInNewTab** | `boolean` | optional | Open 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. | | **newTabUrl** | `string` | optional | Direct new-tab URL template (`{recordId}` placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. | -| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions (#9566/#9474). `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating $`{param.*}`, $`{ctx.*}` and $`{result.*}` (the server response); `openIn` defaults 'self'. The handler-return convention (`{ redirectUrl }` without openIn) keeps its 17.0.0 new-tab behavior. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -335,7 +335,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | -| **confirmText** | `string \| Record` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused (#7428) — 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. | +| **confirmText** | `string \| Record` | optional | Confirmation message before execution. On a registered action, 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. | | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 5b8c2ec7ca..40a3236a4a 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -47,7 +47,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -84,10 +84,10 @@ const result = ActionNavItemSchema.parse(data); | **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | -| **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **objects** | `never` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **apis** | `never` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key and declare the endpoint one level up, on the STACK: `defineStack({ apis })`. That surface EXECUTES from protocol 17 (#5040). Between #4936 and the executor landing it was refused wholesale — nothing mounted a declared path, so every key including `authRequired` parsed and gated nothing — and that blanket refusal is now narrowed to five per-endpoint publish gates (namespace, supported target, mapping, policy, uniqueness): an endpoint that passes them is mounted and serves traffic as soon as the stack is published. Two things to get right when you move it: the path must sit inside your own carve-out, `/api/v1/apps//` with an explicit `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the only thing that opens anonymous access, and ADR-0121 D6 then requires an armed `rateLimit: { enabled: true, windowMs, maxRequests }`. Read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a security review, not a rename. A route that genuinely needs handler CODE is mounted imperatively instead: resolve the `http.server` service from your plugin context and register the route on `kernel:ready` (NOT the manifest `contributes.routes` key — removed in @objectstack/spec 17, #10726: nothing ever read it, and authoring it is now rejected with its own prescription). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **apis** | `never` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key and declare the endpoint one level up, on the STACK: `defineStack({ apis })`. That surface EXECUTES from protocol 17. Before the executor landed it was refused wholesale — nothing mounted a declared path, so every key including `authRequired` parsed and gated nothing — and that blanket refusal is now narrowed to five per-endpoint publish gates (namespace, supported target, mapping, policy, uniqueness): an endpoint that passes them is mounted and serves traffic as soon as the stack is published. Two things to get right when you move it: the path must sit inside your own carve-out, `/api/v1/apps//` with an explicit `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the only thing that opens anonymous access, and ADR-0121 D6 then requires an armed `rateLimit: { enabled: true, windowMs, maxRequests }`. Read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a security review, not a rename. A route that genuinely needs handler CODE is mounted imperatively instead: resolve the `http.server` service from your plugin context and register the route on `kernel:ready` (NOT the manifest `contributes.routes` key — removed in @objectstack/spec 17: nothing ever read it, and authoring it is now rejected with its own prescription). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `never` | optional | [REMOVED] `App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared sharing that did not exist. Public access is granted per FORM VIEW (`FormView.sharing`, the public-data-collection surface). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **embed** | `never` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **mobileNavigation** | `never` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | @@ -107,7 +107,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **primaryColor** | `string` | optional | Primary theme color hex code | -| **accentColor** | `string` | optional | Accent color hex code (highlights, active states). Declared to match the objectui ConsoleLayout read of branding.accentColor (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **accentColor** | `string` | optional | Accent color hex code (highlights, active states). Declared to match the objectui ConsoleLayout read of branding.accentColor (inverse-drift fix, liveness audit /). | | **logo** | `string` | optional | Custom logo URL for this app | | **favicon** | `string` | optional | Custom favicon URL for this app | @@ -120,7 +120,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -131,7 +131,7 @@ const result = ActionNavItemSchema.parse(data); | **recordId** | `string` | optional | Navigate directly to this record id instead of the list view. Supports template vars: `{current_user_id}`, `{current_org_id}`. | | **recordMode** | `Enum<'view' \| 'edit'>` | optional | Open the record in view (default) or edit mode. Only meaningful when `recordId` is set. | | **filters** | `Record` | optional | URL filter conditions — targets the /:objectName/data bare surface via filter[``]=`` params instead of a saved view. Values support template vars `{current_user_id}`, `{current_org_id}`. Mutually exclusive with recordId/viewName. | -| **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action", #4848). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | +| **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action"). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | | **children** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Child navigation items (e.g. specific views) | ### Nested Shape: `App.navigation[number][type='dashboard']` @@ -143,7 +143,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -160,7 +160,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -178,7 +178,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -196,7 +196,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -213,7 +213,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -230,7 +230,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -256,7 +256,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -304,7 +304,7 @@ const result = ActionNavItemSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **primaryColor** | `string` | optional | Primary theme color hex code | -| **accentColor** | `string` | optional | Accent color hex code (highlights, active states). Declared to match the objectui ConsoleLayout read of branding.accentColor (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **accentColor** | `string` | optional | Accent color hex code (highlights, active states). Declared to match the objectui ConsoleLayout read of branding.accentColor (inverse-drift fix, liveness audit /). | | **logo** | `string` | optional | Custom logo URL for this app | | **favicon** | `string` | optional | Custom favicon URL for this app | @@ -347,7 +347,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -370,7 +370,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -392,7 +392,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -424,7 +424,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -435,7 +435,7 @@ const result = ActionNavItemSchema.parse(data); | **recordId** | `string` | optional | Navigate directly to this record id instead of the list view. Supports template vars: `{current_user_id}`, `{current_org_id}`. | | **recordMode** | `Enum<'view' \| 'edit'>` | optional | Open the record in view (default) or edit mode. Only meaningful when `recordId` is set. | | **filters** | `Record` | optional | URL filter conditions — targets the /:objectName/data bare surface via filter[``]=`` params instead of a saved view. Values support template vars `{current_user_id}`, `{current_org_id}`. Mutually exclusive with recordId/viewName. | -| **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action", #4848). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | +| **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action"). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | | **children** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Child navigation items (e.g. specific views) | ### Nested Shape: `NavigationArea.navigation[number][type='dashboard']` @@ -447,7 +447,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -464,7 +464,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -482,7 +482,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -500,7 +500,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -517,7 +517,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -534,7 +534,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -560,7 +560,7 @@ const result = ActionNavItemSchema.parse(data); | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -594,7 +594,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -605,7 +605,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **recordId** | `string` | optional | Navigate directly to this record id instead of the list view. Supports template vars: `{current_user_id}`, `{current_org_id}`. | | **recordMode** | `Enum<'view' \| 'edit'>` | optional | Open the record in view (default) or edit mode. Only meaningful when `recordId` is set. | | **filters** | `Record` | optional | URL filter conditions — targets the /:objectName/data bare surface via filter[``]=`` params instead of a saved view. Values support template vars `{current_user_id}`, `{current_org_id}`. Mutually exclusive with recordId/viewName. | -| **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action", #4848). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | +| **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action"). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | | **children** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Child navigation items (e.g. specific views) | ### Nested Shape: `NavigationContribution.items[number][type='dashboard']` @@ -617,7 +617,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -634,7 +634,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -652,7 +652,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -670,7 +670,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -687,7 +687,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -704,7 +704,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -730,7 +730,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -761,7 +761,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -772,7 +772,7 @@ This schema accepts one of the following structures: | **recordId** | `string` | optional | Navigate directly to this record id instead of the list view. Supports template vars: `{current_user_id}`, `{current_org_id}`. | | **recordMode** | `Enum<'view' \| 'edit'>` | optional | Open the record in view (default) or edit mode. Only meaningful when `recordId` is set. | | **filters** | `Record` | optional | URL filter conditions — targets the /:objectName/data bare surface via filter[``]=`` params instead of a saved view. Values support template vars `{current_user_id}`, `{current_org_id}`. Mutually exclusive with recordId/viewName. | -| **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action", #4848). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | +| **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action"). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | | **children** | `[NavigationItem](#navigationitem)[]` | optional | Child navigation items (e.g. specific views) | --- @@ -790,7 +790,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -813,7 +813,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -837,7 +837,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -861,7 +861,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -884,7 +884,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -914,7 +914,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -952,7 +952,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -977,7 +977,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -988,7 +988,7 @@ This schema accepts one of the following structures: | **recordId** | `string` | optional | Navigate directly to this record id instead of the list view. Supports template vars: `{current_user_id}`, `{current_org_id}`. | | **recordMode** | `Enum<'view' \| 'edit'>` | optional | Open the record in view (default) or edit mode. Only meaningful when `recordId` is set. | | **filters** | `Record` | optional | URL filter conditions — targets the /:objectName/data bare surface via filter[``]=`` params instead of a saved view. Values support template vars `{current_user_id}`, `{current_org_id}`. Mutually exclusive with recordId/viewName. | -| **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action", #4848). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | +| **runAction** | `string` | optional | Auto-run this declared action once on arrival at the object's list surface (deep-link "navigate = run action"). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId. | --- @@ -1004,7 +1004,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -1027,7 +1027,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | @@ -1049,7 +1049,7 @@ This schema accepts one of the following structures: | **icon** | `string` | optional | Icon name | | **order** | `number` | optional | Sort order within the same level (lower = first) | | **badge** | `string \| number` | optional | Badge text or count displayed on the item | -| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894). | +| **badgeVariant** | `Enum<'default' \| 'secondary' \| 'destructive' \| 'outline'>` | optional | Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL). e.g. P`'org_admin' in current_user.positions` | | **requiredPermissions** | `string[]` | optional | Permissions required to access this item | | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx index 2a4f8b1bea..fa6d544197 100644 --- a/content/docs/references/ui/bulk-action.mdx +++ b/content/docs/references/ui/bulk-action.mdx @@ -44,12 +44,12 @@ const result = BulkActionDefSchema.parse(data); | **icon** | `string` | optional | Lucide icon name (e.g. "user-check", "trash-2"). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>` | optional | Visual treatment of the button. | | **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). | -| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds` (objectui#3139). Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | +| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `({ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … } & Record)[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | | **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | -| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm (#5970): a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | +| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | | **maxRecords** | `integer` | optional | Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request. | | **batchSize** | `integer` | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index d28a35670a..d4fe79a5f8 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -72,7 +72,7 @@ const result = AIChatWindowProps.parse(data); | **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | -| **confirmText** | `string \| Record` | optional | Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused (#7428) — 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. | +| **confirmText** | `string \| Record` | optional | Confirmation message before execution. On a registered action, 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. | | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | @@ -207,12 +207,12 @@ const result = AIChatWindowProps.parse(data); | **filter** | `any` | optional | Filter criteria for available records | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order — synonym of the component-level `dataSource.sort`, which takes precedence when both are set | | **limit** | `integer` | optional | Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50) | -| **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **placeholder** | `string \| Record` | optional | Placeholder text | | **emptyText** | `string \| Record` | optional | Text shown when the query returns no records (default "No records") | -| **displayField** | `never` | optional | [REMOVED] `element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a required declaration no renderer ever read, while the renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key to `labelField`; the value (a field name) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use `filter` (or the component-level `dataSource.filter`), which the query path does apply. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0049) — the picker is a single-select `Select` and the bound page variable holds one record id, so `multiple: true` selected nothing extra and reported success. Delete the key; multi-record selection is not implemented on this element. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **displayField** | `never` | optional | [REMOVED] `element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — it was a required declaration no renderer ever read, while the renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key to `labelField`; the value (a field name) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use `filter` (or the component-level `dataSource.filter`), which the query path does apply. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker is a single-select `Select` and the bound page variable holds one record id, so `multiple: true` selected nothing extra and reported success. Delete the key; multi-record selection is not implemented on this element. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `ElementRecordPickerProps.sort[number]` @@ -248,7 +248,7 @@ Sort field and direction pair | **required** | `boolean` | optional (default: `false`) | Mark the field as required | | **disabled** | `boolean` | optional (default: `false`) | Disable the input | | **description** | `string \| Record` | optional | Helper text shown below the input | -| **targetVariable** | `never` | optional | [REMOVED] `element:text_input` property `targetVariable` was removed in @objectstack/spec 17 (#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the typed value, declare it on the variable — `variables: [{ name: '', type: 'string', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **targetVariable** | `never` | optional | [REMOVED] `element:text_input` property `targetVariable` was removed in @objectstack/spec 17 (ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the typed value, declare it on the variable — `variables: [{ name: '', type: 'string', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `ElementTextInputProps.aria` @@ -290,7 +290,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **objectName** | `string` | optional | Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953) | +| **objectName** | `string` | optional | Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **calendar** | `any` | optional | Calendar field config: `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` | | **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode | | **filter** | `any` | optional | Base query filter | @@ -309,7 +309,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **objectName** | `string` | optional | Object this form creates/edits. Optional because the component-level `dataSource` binding can supply the object instead (#6953) | +| **objectName** | `string` | optional | Object this form creates/edits. Optional because the component-level `dataSource` binding can supply the object instead | | **recordId** | `string \| number` | optional | Record to load (edit/view modes) | | **mode** | `Enum<'create' \| 'edit' \| 'view'>` | optional | Form mode | | **formType** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional | Form presentation | @@ -358,15 +358,15 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **objectName** | `string` | optional | Object this grid binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953) | +| **objectName** | `string` | optional | Object this grid binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **label** | `string \| Record` | optional | Grid label — used as the table caption and export file title | | **title** | `string \| Record` | optional | Fallback for `label` (the renderer reads `label \|\| title`) | | **columns** | `any[]` | optional | Columns: field names or column definition objects | | **fields** | `any[]` | optional | Field list fallback used when `columns` is absent | -| **filter** | `any` | optional | Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key #7750 misspelled as plural | +| **filter** | `any` | optional | Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key, singular — not the plural misspelling | | **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` | | **sort** | `any` | optional | Initial sort (array of `{ field, order }`) | -| **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (#11805, ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **pagination** | `any` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging | | **pageSize** | `number` | optional | Flat page-size shorthand; `pagination.pageSize` wins when both are set | | **showPagination** | `boolean` | optional | Show the pager (read only when `pagination` is absent) | @@ -435,7 +435,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **objectName** | `string` | optional | Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953) | +| **objectName** | `string` | optional | Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead | | **groupBy** | `string` | optional | Field whose values become the board columns | | **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection | | **filter** | `any` | optional | Base query filter, handed to the wire `$filter` | @@ -458,10 +458,10 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **objectName** | `string` | optional | PARENT object. Optional because the component-level `dataSource` binding can supply the object instead (#7121) | +| **objectName** | `string` | optional | PARENT object. Optional because the component-level `dataSource` binding can supply the object instead | | **recordId** | `string \| number` | optional | Parent record to load (edit mode) | | **mode** | `Enum<'create' \| 'edit'>` | optional | Form mode | -| **formType** | `Enum<'simple' \| 'tabbed'>` | optional | Parent form presentation — the two variants the renderer honours for the parent half (#11873, objectui#5939) | +| **formType** | `Enum<'simple' \| 'tabbed'>` | optional | Parent form presentation — the two variants the renderer honours for the parent half | | **sections** | `any[]` | optional | Parent form sections | | **fields** | `any[]` | optional | Parent fields shown | | **details** | `any[]` | optional | Detail collections (`{ title, childObject, addLabel?, columns?, relationshipField? }` — FK and columns auto-derive from child metadata) | @@ -482,7 +482,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **objectName** | `string` | optional | Object this metric aggregates. Optional because the component-level `dataSource` binding can supply the object instead (#6953) | +| **objectName** | `string` | optional | Object this metric aggregates. Optional because the component-level `dataSource` binding can supply the object instead | | **label** | `string \| Record` | optional | Metric label | | **description** | `string \| Record` | optional | Helper text under the value | | **title** | `string \| Record` | optional | Drill-down panel title; defaults to the metric label | @@ -543,9 +543,9 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **title** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **bordered** | `boolean` | optional (default: `true`) | | -| **actions** | `never` | optional | [REMOVED] `page:card` property `actions` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui's card renderer builds its `` from `title`, `bordered`, `children` and `footer` only, has no actions area, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key and author the buttons as components in the card's `children` or `footer` (`element:button`, `record:quick_actions`), which is what actually renders. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **actions** | `never` | optional | [REMOVED] `page:card` property `actions` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — no renderer ever read it: objectui's card renderer builds its `` from `title`, `bordered`, `children` and `footer` only, has no actions area, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key and author the buttons as components in the card's `children` or `footer` (`element:button`, `record:quick_actions`), which is what actually renders. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **children** | `any[]` | optional | Card content components, in order (the card body slot) | -| **body** | `never` | optional | [REMOVED] `page:card` property `body` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — it was a second spelling of the composition slot every other container calls `children`, and the renderer reads both. Rename the key to `children`; the value (an array of child components) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **body** | `never` | optional | [REMOVED] `page:card` property `body` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — it was a second spelling of the composition slot every other container calls `children`, and the renderer reads both. Rename the key to `children`; the value (an array of child components) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **footer** | `any[]` | optional | Card footer components (slot) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -579,7 +579,7 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **title** | `string \| Record` | optional | Page title. Omit to let the renderer derive the heading from the record (the default for record pages) — set explicitly on non-record pages (dashboard, landing) with no record to derive from. | | **subtitle** | `string \| Record` | optional | Page subtitle | -| **icon** | `never` | optional | [REMOVED] `page:header` property `icon` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the header's own props bag, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key. The header's own identity is drawn by the record chrome (`recordChrome`, on by default) and each action carries its own `icon`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **icon** | `never` | optional | [REMOVED] `page:header` property `icon` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the header's own props bag, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key. The header's own identity is drawn by the record chrome (`recordChrome`, on by default) and each action carries its own `icon`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **breadcrumb** | `boolean` | optional (default: `true`) | Show breadcrumb | | **actions** | `string[]` | optional | Action IDs to show in header | | **recordChrome** | `boolean` | optional (default: `true`) | Render the record chrome — the title as a record chip with its follow star and copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. | @@ -607,7 +607,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **tabStyle** | `Enum<'line' \| 'card' \| 'pill'>` | optional (default: `"line"`) | Tab-strip visual style: 'line' underlines the active tab, 'card' frames each tab, 'pill' renders rounded pills | -| **type** | `never` | optional | [REMOVED] `page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (#6776, ADR-0087 D2) — a props key named `type` collides with the page component's own dispatch key, so it is unauthorable in the flat and JSX carriers and was never validated in them. Rename the key to `tabStyle`; the value (`line` \| `card` \| `pill`) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **type** | `never` | optional | [REMOVED] `page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — a props key named `type` collides with the page component's own dispatch key, so it is unauthorable in the flat and JSX carriers and was never validated in them. Rename the key to `tabStyle`; the value (`line` \| `card` \| `pill`) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **position** | `Enum<'top' \| 'left'>` | optional (default: `"top"`) | | | **alwaysShowStrip** | `boolean` | optional | Render the tab strip even when only one tab is visible (renderer default: a one-tab strip is hidden). | | **items** | `{ label: string \| Record; icon?: string; visibleWhen?: string \| object; value?: string; … }[]` | ✅ | | @@ -752,7 +752,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) | -| **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sections** | `{ name?: string; label?: string \| Record; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. | | **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) | | **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) | diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 816ba580d2..127a18b064 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -39,8 +39,8 @@ const result = DashboardSchema.parse(data); | **refreshInterval** | `number` | optional | Auto-refresh interval in seconds | | **dateRange** | `{ field?: string; defaultRange: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange: boolean }` | optional | Global dashboard date range filter configuration | | **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record; … }[]` | optional | Global filters that apply to all widgets in the dashboard | -| **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no dashboard renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **performance** | `never` | optional | [REMOVED] `dashboard.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; dashboard performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no dashboard renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **performance** | `never` | optional | [REMOVED] `dashboard.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; dashboard performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this dashboard. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -82,8 +82,8 @@ const result = DashboardSchema.parse(data); | **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record` | optional | Widget specific configuration | | **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | | **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget | -| **responsive** | `never` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (#4876, ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. This message used to point at `page.components[].responsive` as the live home of the shared `ResponsiveConfig` shape; that key was measured equally unread and removed with the shape in #11027. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0 (#3896). Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. This message used to point at `page.components[].responsive` as the live home of the shared `ResponsiveConfig` shape; that key was measured equally unread and removed with the shape with it. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0. Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `Dashboard.dateRange` @@ -99,7 +99,7 @@ const result = DashboardSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Stable filter name (variable key); defaults to field | | **field** | `string` | ✅ | Field name to filter on | -| **object** | `string` | optional | Object whose `fields..` translation-bundle entry resolves this filter's field label and option labels (#7804) | +| **object** | `string` | optional | Object whose `fields..` translation-bundle entry resolves this filter's field label and option labels | | **label** | `string \| Record` | optional | Display label for the filter | | **type** | `Enum<'text' \| 'select' \| 'date' \| 'number' \| 'lookup'>` | optional | Filter input type | | **options** | `{ value: string \| number \| boolean; label: string \| Record }[]` | optional | Static filter options | @@ -187,8 +187,8 @@ Dashboard header action | **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record` | optional | Widget specific configuration | | **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | | **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget | -| **responsive** | `never` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (#4876, ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. This message used to point at `page.components[].responsive` as the live home of the shared `ResponsiveConfig` shape; that key was measured equally unread and removed with the shape in #11027. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0 (#3896). Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. This message used to point at `page.components[].responsive` as the live home of the shared `ResponsiveConfig` shape; that key was measured equally unread and removed with the shape with it. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0. Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Allowed Values: `DashboardWidget.type` @@ -277,7 +277,7 @@ Widget configuration — declared query keys + open renderer extras | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Stable filter name (variable key); defaults to field | | **field** | `string` | ✅ | Field name to filter on | -| **object** | `string` | optional | Object whose `fields..` translation-bundle entry resolves this filter's field label and option labels (#7804) | +| **object** | `string` | optional | Object whose `fields..` translation-bundle entry resolves this filter's field label and option labels | | **label** | `string \| Record` | optional | Display label for the filter | | **type** | `Enum<'text' \| 'select' \| 'date' \| 'number' \| 'lookup'>` | optional | Filter input type | | **options** | `{ value: string \| number \| boolean; label: string \| Record }[]` | optional | Static filter options | diff --git a/content/docs/references/ui/expression-bindable-text-keys.mdx b/content/docs/references/ui/expression-bindable-text-keys.mdx index c325c7a418..ff52137071 100644 --- a/content/docs/references/ui/expression-bindable-text-keys.mdx +++ b/content/docs/references/ui/expression-bindable-text-keys.mdx @@ -88,7 +88,7 @@ const result = ExpressionBindableTextKeySchema.parse(data); ## ExpressionBindableTextKey -One of the closed set of expression-bindable text keys — the top-level node keys a SchemaRenderer evaluation memo may evaluate (objectui#4795 Direction 1; carriage per component type is EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT). +One of the closed set of expression-bindable text keys — the top-level node keys a SchemaRenderer evaluation memo may evaluate (Direction 1; carriage per component type is EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT). ### Allowed Values diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 96f51be169..71c0d1eb09 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -249,7 +249,7 @@ View filter rule | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" | | **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **dataSource** | `{ object: string; view?: string; filter?: any; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | -| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (#11027, ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `PageComponent.responsiveStyles` @@ -348,7 +348,7 @@ View filter rule | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.`. The shipping renderer additionally mounts `app`, `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" | | **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **dataSource** | `{ object: string; view?: string; filter?: any; sort?: object[]; … }` | optional | Per-element data binding for multi-object pages | -| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (#11027, ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index f1ab4e9aa8..b2d9e5a14c 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -160,7 +160,7 @@ Column footer summary configuration | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | | **dependsOn** | `string` | optional | Parent field name for cascading | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here since objectui#6010 — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). | | **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types | @@ -287,7 +287,7 @@ View filter rule | **description** | `string` | optional | Optional description rendered under the section header. | | **collapsible** | `boolean` | optional (default: `false`) | | | **collapsed** | `boolean` | optional (default: `false`) | | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at section level — it is unbound here and the predicate would fault open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): unbound on the standalone form routes, where the predicate would fault open. | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at section level — it is unbound here and the predicate would fault open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | @@ -322,7 +322,7 @@ View filter rule | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | | **dependsOn** | `string` | optional | Parent field name for cascading | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here since objectui#6010 — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). | | **fields** | `{ field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … }[]` | optional | Sub-fields for composite/repeater/record types | @@ -355,12 +355,12 @@ View filter rule | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | -| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11, #7496). | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm (framework#1894 / #2998). | -| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | +| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | +| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `FormView.data[provider='object']` @@ -401,7 +401,7 @@ View filter rule | **description** | `string` | optional | Optional description rendered under the section header. | | **collapsible** | `boolean` | optional (default: `false`) | | | **collapsed** | `boolean` | optional (default: `false`) | | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at section level — it is unbound here and the predicate would fault open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): unbound on the standalone form routes, where the predicate would fault open. | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at section level — it is unbound here and the predicate would fault open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | @@ -416,7 +416,7 @@ View filter rule | **description** | `string` | optional | Optional description rendered under the section header. | | **collapsible** | `boolean` | optional (default: `false`) | | | **collapsed** | `boolean` | optional (default: `false`) | | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at section level — it is unbound here and the predicate would fault open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): unbound on the standalone form routes, where the predicate would fault open. | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at section level — it is unbound here and the predicate would fault open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | @@ -452,7 +452,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **kind** | `'redirect'` | ✅ | | -| **url** | `string` | ✅ | Where the browser goes after a successful submit. Ruled 2026-08-11 (#7496): (1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative URLs are refused, which is what closes the open-redirect face; (2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, and every interpolated value is URL-escaped when the redirect is built; (3) a verbatim redirect on the resolved relative path is the intended consumption. To send the browser OUT of the app, use an app navigation item (`{ type: 'url', url }`) instead. | +| **url** | `string` | ✅ | Where the browser goes after a successful submit. Ruled 2026-08-11: (1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative URLs are refused, which is what closes the open-redirect face; (2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, and every interpolated value is URL-escaped when the redirect is built; (3) a verbatim redirect on the resolved relative path is the intended consumption. To send the browser OUT of the app, use an app navigation item (`{ type: 'url', url }`) instead. | | **delayMs** | `integer` | optional | | ### Nested Shape: `FormView.buttons` @@ -723,7 +723,7 @@ Map view configuration | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | @@ -735,11 +735,11 @@ Map view configuration | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `ListView.data[provider='object']` @@ -824,7 +824,7 @@ View filter rule | **view** | `string` | optional | Name of the form view to use for details (e.g. "summary_view", "edit_form") | | **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely | | **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) | -| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | [#2578] Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. | +| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. | | **width** | `string \| number` | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket. | ### Nested Shape: `ListView.pagination` @@ -955,12 +955,12 @@ View filter rule | **icon** | `string` | optional | Lucide icon name (e.g. "user-check", "trash-2"). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>` | optional | Visual treatment of the button. | | **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). | -| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds` (objectui#3139). Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | +| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `({ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … } & Record)[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | | **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | -| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm (#5970): a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | +| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | | **maxRecords** | `integer` | optional | Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request. | | **batchSize** | `integer` | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. | @@ -1059,7 +1059,7 @@ Tab configuration for multi-tab view interface | **view** | `string` | optional | Name of the form view to use for details (e.g. "summary_view", "edit_form") | | **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely | | **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) | -| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | [#2578] Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. | +| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. | | **width** | `string \| number` | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket. | @@ -1117,7 +1117,7 @@ Tab configuration for multi-tab view interface | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | @@ -1129,11 +1129,11 @@ Tab configuration for multi-tab view interface | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `ObjectListView.data[provider='object']` @@ -1209,7 +1209,7 @@ View filter rule | **view** | `string` | optional | Name of the form view to use for details (e.g. "summary_view", "edit_form") | | **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely | | **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) | -| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | [#2578] Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. | +| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. | | **width** | `string \| number` | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket. | ### Nested Shape: `ObjectListView.pagination` @@ -1340,12 +1340,12 @@ View filter rule | **icon** | `string` | optional | Lucide icon name (e.g. "user-check", "trash-2"). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>` | optional | Visual treatment of the button. | | **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). | -| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds` (objectui#3139). Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | +| **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | | **params** | `({ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; … } & Record)[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | | **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | -| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm (#5970): a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | +| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. | | **maxRecords** | `integer` | optional | Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request. | | **batchSize** | `integer` | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. | @@ -1702,7 +1702,7 @@ Tab configuration for multi-tab view interface | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | @@ -1714,11 +1714,11 @@ Tab configuration for multi-tab view interface | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `View.form` @@ -1744,12 +1744,12 @@ Tab configuration for multi-tab view interface | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | -| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11, #7496). | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm (framework#1894 / #2998). | -| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | +| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | +| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `View.listViews[string]` @@ -1786,7 +1786,7 @@ Tab configuration for multi-tab view interface | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | @@ -1798,11 +1798,11 @@ Tab configuration for multi-tab view interface | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `View.formViews[string]` @@ -1828,12 +1828,12 @@ Tab configuration for multi-tab view interface | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | -| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11, #7496). | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm (framework#1894 / #2998). | -| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | +| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | +| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `View.protection` @@ -2026,7 +2026,7 @@ This schema accepts one of the following structures: | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | @@ -2038,11 +2038,11 @@ This schema accepts one of the following structures: | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `ViewItem[viewKind='list'].protection` @@ -2102,12 +2102,12 @@ This schema accepts one of the following structures: | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | -| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11, #7496). | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm (framework#1894 / #2998). | -| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | +| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | +| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `ViewItem[viewKind='form'].protection` @@ -2163,7 +2163,7 @@ This schema accepts one of the following structures: | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **isPinned** | `boolean` | optional | Studio round-trip: view pinned in the switcher (per-user state, written by the console — not authored). | | **sortOrder** | `integer` | optional | Studio round-trip: position within the switcher (per-user state, written by the console — not authored). | -| **columnState** | `{ order?: string[]; widths?: Record }` | optional | Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored). #9933 | +| **columnState** | `{ order?: string[]; widths?: Record }` | optional | Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored) | ### Nested Shape: `ViewItemWire[viewKind='list'].config` @@ -2201,7 +2201,7 @@ This schema accepts one of the following structures: | **fieldOrder** | `string[]` | optional | Explicit field display order for this view | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | -| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time (#4457). Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | +| **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Conditional formatting rules for list rows | | **inlineEdit** | `boolean` | optional | Allow inline editing of records directly in the list view | | **exportOptions** | `Enum<'csv' \| 'xlsx' \| 'json'>[] \| { formats?: Enum<'csv' \| 'xlsx' \| 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … }` | optional | Export configuration for the list toolbar export menu: `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`. A bare format array is the legacy spelling and lifts to `{ formats: [...] }` at parse. | @@ -2213,11 +2213,11 @@ This schema accepts one of the following structures: | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `ViewItemWire[viewKind='list'].protection` @@ -2262,7 +2262,7 @@ This schema accepts one of the following structures: | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **isPinned** | `boolean` | optional | Studio round-trip: view pinned in the switcher (per-user state, written by the console — not authored). | | **sortOrder** | `integer` | optional | Studio round-trip: position within the switcher (per-user state, written by the console — not authored). | -| **columnState** | `{ order?: string[]; widths?: Record }` | optional | Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored). #9933 | +| **columnState** | `{ order?: string[]; widths?: Record }` | optional | Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored) | ### Nested Shape: `ViewItemWire[viewKind='form'].config` @@ -2287,12 +2287,12 @@ This schema accepts one of the following structures: | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | -| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11, #7496). | -| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm (framework#1894 / #2998). | -| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | +| **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | +| **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | +| **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | +| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `ViewItemWire[viewKind='form'].protection` diff --git a/packages/metadata/src/plugin-artifact-forward-conversion.test.ts b/packages/metadata/src/plugin-artifact-forward-conversion.test.ts index e646d3da67..228f22a352 100644 --- a/packages/metadata/src/plugin-artifact-forward-conversion.test.ts +++ b/packages/metadata/src/plugin-artifact-forward-conversion.test.ts @@ -166,7 +166,7 @@ describe('artifact door — the conversion is versioned, not a blanket amnesty ( } catch (e: any) { const message = String(e?.message ?? e); expect(message).toContain('allowRestore'); - expect(message).toContain('was removed in @objectstack/spec 17 (#12497, ADR-0049)'); + expect(message).toContain('was removed in @objectstack/spec 17 (ADR-0049)'); expect(message).toContain('Run `os migrate meta --from 17`'); } }); diff --git a/packages/metadata/src/stored-envelope.test.ts b/packages/metadata/src/stored-envelope.test.ts index b1eb840b41..e1d8a2f5e5 100644 --- a/packages/metadata/src/stored-envelope.test.ts +++ b/packages/metadata/src/stored-envelope.test.ts @@ -194,7 +194,8 @@ describe('[#5384] every stored-envelope key carries a wrong-layer prescription o // The prescription, not merely a rejection: it must name the layer that // owns the key and tell the author what to do instead. expect(unknown!.message).toContain('storage bookkeeping'); - expect(unknown!.message).toContain('#5309'); + expect(unknown!.message, 'the prescription names the owning layer and the remedy,\n' + + 'never a tracker id').not.toMatch(/#\d{3,5}/); }); it('CONTROL — the same body without a bookkeeping key parses, so the case above cannot pass vacuously', () => { diff --git a/packages/objectql/src/engine-unknown-option.test.ts b/packages/objectql/src/engine-unknown-option.test.ts index be7332c8e2..aa691c8b41 100644 --- a/packages/objectql/src/engine-unknown-option.test.ts +++ b/packages/objectql/src/engine-unknown-option.test.ts @@ -149,7 +149,7 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => { it.each(['cursor', 'distinct'])('%s is rejected with its #4286 tombstone', async (key) => { await expect(engine.find('task', { [key]: 'x' } as any)) - .rejects.toThrow(/#4286, ADR-0049/); + .rejects.toThrow(/ADR-0049/); }); it('update `upsert` is rejected with its #8057 tombstone — it was accepted and silently dropped', async () => { @@ -158,7 +158,7 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => { // prescription: delete the key; create-if-absent is explicit now that // the by-id branch throws on a missing row (#7867's not-found gate). await expect(engine.update('task', { title: 'Z' }, { where: { id: a.id }, upsert: true } as any)) - .rejects.toThrow(/`update\.options\.upsert` was removed .*#8057, ADR-0049.*never implemented.*Delete the key.*not-found gate/s); + .rejects.toThrow(/`update\.options\.upsert` was removed .*ADR-0049.*never implemented.*Delete the key.*not-found gate/s); }); it('a null-valued `upsert` stays a withdrawal — no intent a drop could lose', async () => { diff --git a/packages/services/service-automation/src/builtin/screen-nodes.test.ts b/packages/services/service-automation/src/builtin/screen-nodes.test.ts index d8c5719098..5eb1789da4 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.test.ts @@ -182,7 +182,9 @@ describe('script retired branches, as a stored flow meets them (#4343)', () => { // this is what `tsc` and `os validate` put in front of an author. const result = ScriptConfigSchema.safeParse({ function: 'score_lead', actionType: 'email' }); expect(result.success).toBe(false); - expect(result.error!.issues[0]!.message).toMatch(/#4343/); + expect(result.error!.issues[0]!.message).toMatch(/`script\.config\.actionType` was removed/); + expect(result.error!.issues[0]!.message, 'the prescription teaches the replacement, never a tracker id') + .not.toMatch(/#\d{3,5}/); expect(ScriptConfigSchema.parse({ function: 'score_lead', inputs: { leadId: '{record.id}' }, diff --git a/packages/spec/src/ai/agent.test.ts b/packages/spec/src/ai/agent.test.ts index 57769b06ee..c1bf74ed58 100644 --- a/packages/spec/src/ai/agent.test.ts +++ b/packages/spec/src/ai/agent.test.ts @@ -206,7 +206,7 @@ describe('AgentSchema', () => { message = String((e as Error).message); } expect(message).toMatch(/knowledge-service|source level/); - expect(message).toMatch(/#3896/); + expect(message).toMatch(/audit close-out/); }); it('should accept agent with skills (Agent→Skill→Tool architecture)', () => { diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts index 5dff87dfaf..ee4a886094 100644 --- a/packages/spec/src/ai/agent.zod.ts +++ b/packages/spec/src/ai/agent.zod.ts @@ -21,7 +21,7 @@ import { strictObject } from '../shared/strict-object'; * agent, which is why this surface hides its mistakes better than most. */ const AGENT_HISTORY = - 'Until #4001 closed this shape these were dropped silently — the agent still registered ' + 'Until this shape was closed these were dropped silently — the agent still registered ' + 'and still answered, minus whatever the key was meant to configure or constrain.'; export const AIModelConfigSchema = lazySchema(() => strictObject({ @@ -169,12 +169,12 @@ export const AgentSchema = lazySchema(() => strictObject({ // believed the agent was hidden. It was listed to everyone, and had been // all along. visibility: - '`visibility` was removed in #1901 — it never hid anything. No runtime read it: ' + '`visibility` was removed — it never hid anything. No runtime read it: ' + 'the chat-access evaluator ignored it and the agent list route did not filter on it, ' + "so `private` listed the agent to everyone. Use `access` (who may chat) and/or " - + '`permissions` (required permission-set capabilities) — both ENFORCED at the chat route since #1884.', + + '`permissions` (required permission-set capabilities) — both ENFORCED at the chat route.', tenantId: - '`tenantId` was removed in #2377 — it never scoped anything. Tenancy comes from the ' + '`tenantId` was removed — it never scoped anything. Tenancy comes from the ' + 'request context (`resolveAuthzContext`), never from a field on the artifact, so this ' + 'key did not restrict which tenant could reach the agent. Delete it; scope reachability ' + 'with `access` / `permissions`.', @@ -195,7 +195,7 @@ export const AgentSchema = lazySchema(() => strictObject({ /** Cognition */ instructions: z.string().describe('System Prompt / Prime Directives'), model: AIModelConfigSchema.optional(), - lifecycle: StateMachineSchema.optional().describe('[EXPERIMENTAL — not enforced] State machine defining the agent conversation flow and constraints. Parsed but no runtime consumer yet (liveness #1878/#1893).'), + lifecycle: StateMachineSchema.optional().describe('[EXPERIMENTAL — not enforced] State machine defining the agent conversation flow and constraints. Parsed but no runtime consumer yet.'), /** * ADR-0063 §1 / ADR-0064 — the product surface this agent IS. The kernel @@ -233,7 +233,7 @@ export const AgentSchema = lazySchema(() => strictObject({ * often an AI (ADR-0033), actually reads (`shared/retired-key.ts`). */ tools: retiredKey( - '`agent.tools` was removed in @objectstack/spec 17 (#3894) — use `skills`. ' + + '`agent.tools` was removed in @objectstack/spec 17 — use `skills`. ' + 'An agent reaches exactly the tools its surface-compatible skills declare ' + '(ADR-0064), so move each reference into a skill: a platform tool by its ' + 'registered name, or `action_` for one of your own AI-exposed Actions. ' + @@ -250,7 +250,7 @@ export const AgentSchema = lazySchema(() => strictObject({ // knowledge-tools.ts:96). An author who "scoped" retrieval here scoped // nothing — the dangerous direction, same shape as tool.permissions. knowledge: retiredKey( - '`agent.knowledge` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' + + '`agent.knowledge` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + 'declaring knowledge sources/indexes on an agent never scoped retrieval: the ' + "`search_knowledge` tool takes `sourceIds` from the LLM's tool-call arguments, not from " + 'the agent record. Delete the block. Restrict retrieval at the knowledge-service / ' + @@ -300,7 +300,7 @@ export const AgentSchema = lazySchema(() => strictObject({ guidance: { // The removal recorded in the note below, given the rejection it never had. shortTerm: - '`shortTerm` was removed (ADR-0013 D3, cloud#339) — it declared a working-memory ' + '`shortTerm` was removed (ADR-0013 D3) — it declared a working-memory ' + 'window nothing in the runtime consumed. Cross-turn grounding comes from tools reading ' + 'live state, and the context budget is governed by the per-request token guardrail, ' + 'not by this block. Delete it.', @@ -331,7 +331,7 @@ export const AgentSchema = lazySchema(() => strictObject({ /** Reflection interval — how often the agent reflects on past actions */ reflectionInterval: z.number().int().min(1).optional().describe('Reflect every N interactions to improve behavior'), - }).optional().describe('[EXPERIMENTAL — not enforced] Agent memory management. Parsed but no runtime consumer yet (liveness #1878/#1893).'), + }).optional().describe('[EXPERIMENTAL — not enforced] Agent memory management. Parsed but no runtime consumer yet.'), /** Guardrails */ guardrails: strictObject({ @@ -361,10 +361,10 @@ export const AgentSchema = lazySchema(() => strictObject({ /** Topics or actions the agent must avoid */ blockedTopics: z.array(z.string()).optional().describe('Forbidden topics or action names'), - }).optional().describe('[EXPERIMENTAL — not enforced] Safety guardrails for the agent. Parsed but not enforced — real limits come from the quota service (liveness #1878/#1893).'), + }).optional().describe('[EXPERIMENTAL — not enforced] Safety guardrails for the agent. Parsed but not enforced — real limits come from the quota service.'), /** Structured Output */ - structuredOutput: StructuredOutputConfigSchema.optional().describe('[EXPERIMENTAL — not enforced] Structured output format and validation configuration. Parsed but no runtime consumer yet (liveness #1878/#1893).'), + structuredOutput: StructuredOutputConfigSchema.optional().describe('[EXPERIMENTAL — not enforced] Structured output format and validation configuration. Parsed but no runtime consumer yet.'), /** * ADR-0010 §3.7 — Package-level protection envelope. Package * authors declare lock policy here; the loader translates it diff --git a/packages/spec/src/ai/skill.test.ts b/packages/spec/src/ai/skill.test.ts index 9b6659c0fe..b0835ea87b 100644 --- a/packages/spec/src/ai/skill.test.ts +++ b/packages/spec/src/ai/skill.test.ts @@ -201,7 +201,7 @@ describe('#3896 close-out — retired `triggerPhrases`', () => { message = String((e as Error).message); } expect(message).toMatch(/triggerConditions/); - expect(message).toMatch(/#3896/); + expect(message).toMatch(/audit close-out/); }); it('does not offer the retired key as the fix for a near-miss of it', () => { diff --git a/packages/spec/src/ai/skill.zod.ts b/packages/spec/src/ai/skill.zod.ts index 58dca66c9b..8a37b713a1 100644 --- a/packages/spec/src/ai/skill.zod.ts +++ b/packages/spec/src/ai/skill.zod.ts @@ -266,7 +266,7 @@ export type SkillTriggerCondition = z.input; export const SkillSchema = lazySchema(() => strictObject({ surface: 'this skill', history: - 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the item still registered, minus whatever the key was meant to configure.', aliases: { prompt: 'instructions', content: 'instructions', body: 'instructions', tool: 'tools' }, guidance: { // #5013 — `trigger` used to be an ALIAS pointing at `triggers`, a key this @@ -291,7 +291,7 @@ export const SkillSchema = lazySchema(() => strictObject({ permissions: '`permissions` is not a skill key — skill invocation was never permission-gated, ' + 'so this was stripped in silence and the author believed they had a gate. Gate at ' - + 'the AGENT instead (`access` / `permissions` on the agent, enforced since #1884), ' + + 'the AGENT instead, ' + "or on the underlying tools' actions.", }, }, { @@ -340,7 +340,7 @@ export const SkillSchema = lazySchema(() => strictObject({ * text. A skill with no `instructions` has nothing to project and is not * listed as a prompt at all. */ - instructions: z.string().optional().describe('LLM instructions when skill is active — also served as an MCP prompt (#3905)'), + instructions: z.string().optional().describe('LLM instructions when skill is active — also served as an MCP prompt'), /** * References to tool names that belong to this skill. @@ -384,7 +384,7 @@ export const SkillSchema = lazySchema(() => strictObject({ // cloud API served the field back to clients, a dead-end projection that // made the false capability look extra real. triggerPhrases: retiredKey( - '`skill.triggerPhrases` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) ' + + '`skill.triggerPhrases` was removed in @objectstack/spec 17.0.0 (audit close-out) ' + "— phrases were never matched against the user's message; skill activation is " + "`triggerConditions` (AND of context field/operator/value) intersected with the agent's " + '`skills[]`, plus explicit /skill-name pinning. Delete the key. Put routing intent in ' + diff --git a/packages/spec/src/ai/tool.test.ts b/packages/spec/src/ai/tool.test.ts index f56c186486..ec3b621b9a 100644 --- a/packages/spec/src/ai/tool.test.ts +++ b/packages/spec/src/ai/tool.test.ts @@ -150,7 +150,7 @@ describe('defineTool', () => { } // FROM → TO: the action-level flag is the only path that stops execution. expect(message).toMatch(/ai\.requiresConfirmation/); - expect(message).toMatch(/#3715/); + expect(message).toMatch(/ADR-0033/); }); it('rejects an unrelated unknown key too (strictness is not special-cased)', () => { @@ -180,7 +180,7 @@ describe('defineTool', () => { message = String((e as Error).message); } expect(message).toContain(key); - expect(message).toMatch(/#3896/); + expect(message).toMatch(/audit close-out/); }); it('the `permissions` rejection points at the gate the middleware actually runs', () => { @@ -258,7 +258,7 @@ describe('defineTool', () => { const message = rejectionMessage({ requiresConfirmation: true, labl: 'x' }); const preamble = 'Unrecognized key(s) on the tool definition:'; const fix = 'action.ai.requiresConfirmation'; - const history = 'the #1535 silent-strip class'; + const history = 'the silent-strip class'; expect(message.startsWith(preamble)).toBe(true); expect(message.indexOf(fix)).toBeGreaterThan(message.indexOf(preamble)); @@ -273,11 +273,11 @@ describe('defineTool', () => { // quietly reworded a retirement prescription would be the defect this // table exists to prevent, wearing the fold as cover. expect(rejectionMessage({ permissions: ['x'] })).toContain( - '`tool.permissions` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it ' + '`tool.permissions` was removed in @objectstack/spec 17.0.0 (audit close-out) — it ' + 'promised a capability gate on tool invocation that nothing ever enforced', ); expect(rejectionMessage({ builtIn: true })).toContain( - '`tool.builtIn` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no ' + '`tool.builtIn` was removed in @objectstack/spec 17.0.0 (audit close-out) — no ' + 'runtime branches on it; it never affected registration, selection or execution. Delete ' + 'the key.', ); diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts index d3bca491f8..7f8be1cc6b 100644 --- a/packages/spec/src/ai/tool.zod.ts +++ b/packages/spec/src/ai/tool.zod.ts @@ -35,30 +35,30 @@ import { strictObject } from '../shared/strict-object'; */ const TOOL_RETIRED_KEY_GUIDANCE: Record = { permissions: - '`tool.permissions` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it ' + + '`tool.permissions` was removed in @objectstack/spec 17.0.0 (audit close-out) — it ' + 'promised a capability gate on tool invocation that nothing ever enforced: the key is not ' + 'part of AIToolDefinition and no execution path read it, so a tool "requiring" capabilities ' + 'ran for everyone. Delete the key. To gate what a tool can DO, gate the underlying action ' + '(`action.requiredPermissions`, ADR-0066) or the object it touches (permission sets) — those ' + 'are the checks the middleware actually runs.', active: - '`tool.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' + + '`tool.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + '`active: false` read as "withdrawn" but withdrew nothing: AIToolDefinition has no such ' + 'field, ToolRegistry.getAll() returns everything, and the tool kept reaching the LLM tool ' + 'set and `POST /ai/tools/:name/execute` kept running it (unlike agent.active / skill.active, ' + 'which ARE enforced). Delete the key. To withdraw a tool, remove it from the skills/agents ' + 'that reference it.', category: - '`tool.category` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing ' + + '`tool.category` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing ' + 'groups, filters or routes tools by it; the only reader was a serializer pass-through. ' + 'Delete the key. Organizational grouping belongs in the skill that carries the tool.', builtIn: - '`tool.builtIn` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no ' + + '`tool.builtIn` was removed in @objectstack/spec 17.0.0 (audit close-out) — no ' + 'runtime branches on it; it never affected registration, selection or execution. Delete ' + 'the key.', requiresConfirmation: '`tool.requiresConfirmation` was removed from @objectstack/spec in the 16.x line ' + - '(#3715, ADR-0033 §2) — it never had a consumer, and a SAFETY flag that is merely ' + + '(ADR-0033 §2) — it never had a consumer, and a SAFETY flag that is merely ' + 'accepted is false compliance: authors set it on destructive tools believing the ' + 'call would pause, and nothing ever did. No execution path read it — not the LLM ' + 'tool set (a tool reaches the model as name/description/parameters only), not ' + @@ -98,7 +98,7 @@ const TOOL_RETIRED_KEY_GUIDANCE: Record = { const TOOL_STRICT_HISTORY = 'Until this shape was closed an undeclared key was dropped without a word — the tool ' + 'still registered and still reached the LLM tool set, minus whatever the key was meant ' - + 'to do (the #1535 silent-strip class).'; + + 'to do (the silent-strip class).'; /** * Tool Schema @@ -158,7 +158,7 @@ export const ToolSchema = lazySchema(() => strictObject({ * (service-ai action-tools) but performs NO output validation against this * schema, and downstream tool chaining does not consume it either. */ - outputSchema: z.record(z.string(), z.unknown()).optional().describe('[EXPERIMENTAL — not enforced] JSON Schema for tool output. Keys are folded into the tool description only; outputs are not validated (liveness #1878/#1893).'), + outputSchema: z.record(z.string(), z.unknown()).optional().describe('[EXPERIMENTAL — not enforced] JSON Schema for tool output. Keys are folded into the tool description only; outputs are not validated.'), /** * Associated object name (when the tool operates on a specific data object). @@ -189,7 +189,7 @@ export const ToolSchema = lazySchema(() => strictObject({ // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, -}).describe('AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Authoring a tool as metadata does NOT make it runnable: this schema has no `implementation`/`handler` field and no framework executor loads a metadata-authored tool. The runtime executes a separately-registered `AIToolDefinition` (cloud `@objectstack/service-ai`); tool metadata is a one-way projection for Studio/discovery. Do not expect a hand-authored tool to run in the open edition (liveness audit #1878/#1892).')); +}).describe('AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Authoring a tool as metadata does NOT make it runnable: this schema has no `implementation`/`handler` field and no framework executor loads a metadata-authored tool. The runtime executes a separately-registered `AIToolDefinition` (cloud `@objectstack/service-ai`); tool metadata is a one-way projection for Studio/discovery. Do not expect a hand-authored tool to run in the open edition.')); export type Tool = z.input; diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index 42beeb6645..f398fdb757 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -49,13 +49,13 @@ export const AnalyticsQueryRequestSchema = lazySchema(() => AnalyticsQuerySchema.extend({ cube: z.string().describe('Target cube name'), query: retiredKey( - '`query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0 (#3878). ' + - 'The { cube, query: {...} } envelope was the dialect of the retired degraded analytics shim (#3891) — ' + + '`query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. ' + + 'The { cube, query: {...} } envelope was the dialect of the retired degraded analytics shim — ' + 'the real engine never understood it. Move the query.* fields to the body top level: ' + '{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }.', ), format: retiredKey( - '`format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0 (#3878). ' + + '`format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. ' + 'It was never implemented — every response is the JSON envelope. Delete the key; ' + 'for CSV/XLSX use the export surface instead.', ), diff --git a/packages/spec/src/api/auth-endpoints.test.ts b/packages/spec/src/api/auth-endpoints.test.ts index f3d89490f0..f8eefb9a3e 100644 --- a/packages/spec/src/api/auth-endpoints.test.ts +++ b/packages/spec/src/api/auth-endpoints.test.ts @@ -113,7 +113,7 @@ describe('AuthFeaturesConfig retired flags (#7481)', () => { it('rejects `passkeys` with its own prescription, naming the missing consumer', () => { expect(() => AuthFeaturesConfigSchema.parse({ ...valid, passkeys: true })) - .toThrow(/`features\.passkeys` was removed.*Delete the key.*objectui#4179/s); + .toThrow(/`features\.passkeys` was removed.*Delete the key/s); }); it('rejects `magicLink` with a prescription that keeps its endpoints alive', () => { @@ -121,7 +121,7 @@ describe('AuthFeaturesConfig retired flags (#7481)', () => { // endpoints still answer, and a shared prescription would tell a magic-link // deployer to stop using a capability that was never withdrawn. expect(() => AuthFeaturesConfigSchema.parse({ ...valid, magicLink: true })) - .toThrow(/`features\.magicLink` was removed.*magic-link\/send.*objectui#4179/s); + .toThrow(/`features\.magicLink` was removed.*magic-link\/send/s); }); it('does not serve either key on a clean parse', () => { diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index af1beddbfc..6458512e35 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -204,22 +204,22 @@ export const EmailPasswordConfigPublicSchema = lazySchema(() => z.object({ */ const PASSKEYS_UNADVERTISED = '`features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 ' - + '(#7481, ADR-0049) — it was served from introduction and consumed by nothing: no login ' + + '(ADR-0049) — it was served from introduction and consumed by nothing: no login ' + 'UI in any client reads it, and no better-auth passkey plugin is wired behind it, so a ' + 'deployer who set `plugins.passkeys: true` flipped a switch that changed no behaviour ' + 'anywhere. Delete the key. There is no replacement flag to read: passkey sign-in is not ' + 'a capability this platform offers yet. It returns to this payload in the change that ' - + 'ships the login UI (objectui#4179), classified in PUBLIC_AUTH_FEATURES again at that ' + + 'ships the login UI, classified in PUBLIC_AUTH_FEATURES again at that ' + 'point — do not re-add it ahead of a consumer.'; const MAGIC_LINK_UNADVERTISED = '`features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 ' - + '(#7481, ADR-0049) — the ADVERTISEMENT was inert, not the capability: no client renders ' + + '(ADR-0049) — the ADVERTISEMENT was inert, not the capability: no client renders ' + 'a magic-link sign-in affordance off this flag, so it only told a deployer that a UI ' + 'existed when none did. Delete the key. The server side is unchanged and still yours to ' + 'call: `AuthPluginConfig.plugins.magicLink` wires better-auth\'s magic-link plugin, and ' + '`/api/v1/auth/magic-link/send` + `/magic-link/verify` answer exactly as before — drive ' - + 'them from your own UI, or wait for objectui#4179, which restores this flag along with ' + + 'them from your own UI, or wait for the console release that restores this flag along with ' + 'the login UI that reads it.'; /** @@ -234,10 +234,10 @@ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({ 'SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains)', ), phoneNumber: z.boolean().optional().describe( - 'Phone-number sign-in enabled (phone + password, #2766 V1.5)', + 'Phone-number sign-in enabled (phone + password, V1.5)', ), phoneNumberOtp: z.boolean().optional().describe( - 'Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service (#2780)', + 'Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service', ), })); diff --git a/packages/spec/src/api/batch.zod.ts b/packages/spec/src/api/batch.zod.ts index bc7adbf84e..df2fc90360 100644 --- a/packages/spec/src/api/batch.zod.ts +++ b/packages/spec/src/api/batch.zod.ts @@ -82,7 +82,7 @@ export const BatchOptionsSchema = lazySchema(() => z.object({ 'If true (and atomic=false), continue processing remaining records after errors. ' + 'Default false: the first failure ENDS the run — records before it stay written (nothing is rolled ' + 'back on this arm), and every record after it is reported `errors[0].code` NOT_ATTEMPTED rather than ' - + 'omitted, so `results` always covers all `total` records and `succeeded + failed === total` (#7539).'), + + 'omitted, so `results` always covers all `total` records and `succeeded + failed === total`.'), // `validateOnly` promised a dry-run — "validate records without persisting" — // but no batch surface ever read it (`updateManyData` / `deleteManyData` / // `batchData` all persist regardless). A caller sending `validateOnly: true` @@ -93,7 +93,7 @@ export const BatchOptionsSchema = lazySchema(() => z.object({ // and should be reintroduced deliberately, not back-filled to match a promise // nothing kept. Tombstoned so writing it is audible, not silently stripped. validateOnly: retiredKey( - '`options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). ' + '`options.validateOnly` was removed from BatchOptions in @objectstack/spec. ' + 'It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have ' + 'silently executed. There is no dry-run today — drop the key. If you need to preview a batch ' + 'without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) ' @@ -197,16 +197,16 @@ export const BatchOperationResultSchema = lazySchema(() => z.object({ errors: z.array(ApiErrorSchema).optional().describe( 'Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back ' + 'marks rows that were written then undone with code ROLLED_BACK and rows never reached with ' - + 'NOT_ATTEMPTED, while the causal row keeps its own error (#4793). A NON-atomic batch that stopped ' + + 'NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped ' + '(the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code ' - + '— rows before the failure stay written and keep reporting success, since nothing was rolled back ' - + '(#7539).'), + + '— rows before the failure stay written and keep reporting success, since nothing was rolled back' + + '.'), data: RecordDataSchema.optional().describe('Full record data (if returnRecords=true)'), index: z.number().optional().describe('Index of the record in the request array'), droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( - 'Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from ' + - 'THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` ' + - '(#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop ' + + 'Write-observability: caller-supplied fields LEGALLY stripped from ' + + 'THIS row before it was written — static `readonly` / TRUE `readonlyWhen`' + + ' on update, or the create-ingress strip. Per-row because a batch can drop ' + 'different fields on different rows (`readonlyWhen` is record-state-dependent). Present ' + 'ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). ' + 'A single response header cannot express per-row drops, so this body field is the ' + @@ -412,9 +412,9 @@ export type CrossObjectBatchDroppedFields = z.input z.object({ results: z.array(z.unknown()).describe('Per-operation result, index-aligned with the request operations'), droppedFields: z.array(CrossObjectBatchDroppedFieldsSchema).optional().describe( - 'Write-observability (#3407/#3431/#3455/#3794): caller-supplied fields the engine LEGALLY ' + - 'stripped from an operation before it was written — static `readonly` (#2948) or a TRUE ' + - '`readonlyWhen` predicate (#3042). This endpoint is the console record form\'s save path ' + + 'Write-observability: caller-supplied fields the engine LEGALLY ' + + 'stripped from an operation before it was written — static `readonly` or a TRUE ' + + '`readonlyWhen` predicate. This endpoint is the console record form\'s save path ' + '(master-detail writes parent + children in one transaction), so without it the ONE surface ' + 'where a user edits a `readonlyWhen` field reported plain success while the value never ' + 'landed. Each event carries the `index` of its operation. Present ONLY when ≥1 field was ' + diff --git a/packages/spec/src/api/contract.zod.ts b/packages/spec/src/api/contract.zod.ts index 058590424c..e2706d2a38 100644 --- a/packages/spec/src/api/contract.zod.ts +++ b/packages/spec/src/api/contract.zod.ts @@ -44,7 +44,7 @@ export const ApiErrorSchema = lazySchema(() => z.object({ * therefore MEANS demotion: a consumer that sees this field knows the * producer spelled a code the serving side's ledger does not know. */ - declaredCode: z.string().optional().describe('The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112, #9106)'), + declaredCode: z.string().optional().describe('The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112)'), message: z.string().describe('Readable error message'), /** * The producer's user-facing refusal text, verbatim — the producer-side @@ -85,9 +85,9 @@ export const ApiErrorSchema = lazySchema(() => z.object({ * for logs and developers. */ userMessage: z.string().optional().describe( - 'Producer-marked user-facing refusal text, verbatim (#9934). Present exactly when the ' + 'Producer-marked user-facing refusal text, verbatim. Present exactly when the ' + 'producer opted in at throw time; consumers render it to end users and keep their ' - + 'generic substitution (#3821) for anything unmarked. Status-agnostic; never replaces `message`.', + + 'generic substitution for anything unmarked. Status-agnostic; never replaces `message`.', ), category: z.string().optional().describe('Error category (e.g. validation, authorization)'), /** diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index f75bffb67a..c4fb3dcb9c 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -570,7 +570,7 @@ export const WellKnownCapabilitiesSchema = lazySchema(() => z.object({ * (declared === enforced). */ transactionalBatch: z.boolean().describe( - 'Whether the backend exposes the atomic cross-object batch endpoint (POST {basePath}/batch, #1604/ADR-0034): ' + 'Whether the backend exposes the atomic cross-object batch endpoint (POST {basePath}/batch, /ADR-0034): ' + 'all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation ' + 'instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction.' ), @@ -594,7 +594,7 @@ export const WellKnownCapabilitiesSchema = lazySchema(() => z.object({ */ websockets: z.boolean().describe( 'Whether the backend mounts a realtime push surface (WebSocket/SSE) clients can subscribe to. ' - + 'False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12, #2462).' + + 'False while realtime is an in-process bus with no mounted HTTP/WS surface (ADR-0076 D12).' ), /** * Whether a file-storage surface is served at all (upload / download / diff --git a/packages/spec/src/api/endpoint.zod.ts b/packages/spec/src/api/endpoint.zod.ts index ca728feba3..6e373025f3 100644 --- a/packages/spec/src/api/endpoint.zod.ts +++ b/packages/spec/src/api/endpoint.zod.ts @@ -13,7 +13,7 @@ import { lazySchema } from '../shared/lazy-schema'; export const ApiMappingSchema = lazySchema(() => z.object({ source: z.string().describe('Source field/path'), target: z.string().describe('Target field/path'), - transform: z.string().optional().describe('Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (#5040 E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object)'), + transform: z.string().optional().describe('Transformation function name — NOT EXECUTED in 17.x, and publish REJECTS the key: there is no transformation-function registry anywhere in the platform, so it stays in the frozen vocabulary and is refused rather than parsed and ignored (E7). A mapping entry moves and renames fields by dot path and nothing more — shape the value where it is produced instead (a flow endpoint whose flow computes it, or a formula field on the object)'), })); export type ApiMapping = z.input; @@ -34,8 +34,8 @@ export type ApiMapping = z.input; */ const STORED_BOOKKEEPING_GUIDANCE = 'This is the metadata layer\'s own storage bookkeeping, not endpoint vocabulary. It is written onto ' - + 'the stored ROW by `register` / `publishPackage` and peeled off before this schema sees a body ' - + '(#5309), so writing it on a declaration configures nothing. Remove it — publication state is ' + + 'the stored ROW by `register` / `publishPackage` and peeled off before this schema sees a body' + + ', so writing it on a declaration configures nothing. Remove it — publication state is ' + 'managed by `os package publish`, not authored.'; /** @@ -100,7 +100,7 @@ const STORED_BOOKKEEPING_GUIDANCE = export const ApiEndpointSchema = strictObject({ surface: 'this API endpoint', history: - 'Until #5384 closed this shape these were dropped silently — the endpoint still published and ' + 'Until this shape was closed these were dropped silently — the endpoint still published and ' + 'served, minus whatever the key was meant to configure (a `cacheTTL` / `objectParam` typo cost ' + 'the whole policy or projection, with `os validate` green).', aliases: { @@ -167,8 +167,8 @@ export const ApiEndpointSchema = strictObject({ description: z.string().optional(), /** Execution Logic */ - type: z.enum(['flow', 'script', 'object_operation', 'proxy']).describe("Implementation type — only 'object_operation' and 'flow' EXECUTE in 17.x. 'script' and 'proxy' stay in the frozen vocabulary (#5040) and are rejected at publish, not parsed and ignored: express script logic as a flow whose script node runs your registered function, and an outbound call as a flow using a declared connector"), - target: z.string().optional().describe("Target Flow ID, per `type` — REQUIRED at publish for `type: 'flow'` (the gate refuses a flow endpoint that names no target flow) and UNREAD for `type: 'object_operation'`, so do not write it there: that endpoint is addressed by `objectParams.object` / `.operation`, and a `target` beside them is a dead string nothing checks against `objectParams.object` (#10338 made the key optional for exactly that reason). The vocabulary's other spellings — a Script Name or Proxy URL — stay unreachable in 17.x, since publish rejects `type: 'script'` and `type: 'proxy'`"), + type: z.enum(['flow', 'script', 'object_operation', 'proxy']).describe("Implementation type — only 'object_operation' and 'flow' EXECUTE in 17.x. 'script' and 'proxy' stay in the frozen vocabulary and are rejected at publish, not parsed and ignored: express script logic as a flow whose script node runs your registered function, and an outbound call as a flow using a declared connector"), + target: z.string().optional().describe("Target Flow ID, per `type` — REQUIRED at publish for `type: 'flow'` (the gate refuses a flow endpoint that names no target flow) and UNREAD for `type: 'object_operation'`, so do not write it there: that endpoint is addressed by `objectParams.object` / `.operation`, and a `target` beside them is a dead string nothing checks against `objectParams.object` (made the key optional for exactly that reason). The vocabulary's other spellings — a Script Name or Proxy URL — stay unreachable in 17.x, since publish rejects `type: 'script'` and `type: 'proxy'`"), /** Logic Config */ objectParams: z.object({ diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts index a935e32fc2..785e78d944 100644 --- a/packages/spec/src/api/errors.zod.ts +++ b/packages/spec/src/api/errors.zod.ts @@ -376,9 +376,9 @@ export const EnhancedApiErrorSchema = lazySchema(() => z.object({ * `message`. */ userMessage: z.string().optional().describe( - 'Producer-marked user-facing refusal text, verbatim (#9934) — see ApiErrorSchema.userMessage. ' + 'Producer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. ' + 'Present only when the producer opted in at throw time; unmarked errors keep the generic ' - + 'consumer substitution (#3821).', + + 'consumer substitution.', ), category: ErrorCategory.optional().describe('Error category'), httpStatus: z.number().optional().describe('HTTP status code'), @@ -405,7 +405,7 @@ export const EnhancedApiErrorSchema = lazySchema(() => z.object({ */ fieldErrors: retiredKey( '`EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 ' + - '(ADR-0114 D4, #3977) — the array is unchanged, only the property name. Every ' + + '(ADR-0114 D4) — the array is unchanged, only the property name. Every ' + 'producer already emitted `fields`; `fieldErrors` was declared and never emitted, ' + 'so a reader keying on it was reading a field no server sent.', ), diff --git a/packages/spec/src/api/export.zod.ts b/packages/spec/src/api/export.zod.ts index 40fa36149b..ef4543c6b9 100644 --- a/packages/spec/src/api/export.zod.ts +++ b/packages/spec/src/api/export.zod.ts @@ -337,7 +337,7 @@ export const ImportRequestSchema = lazySchema(() => z.object({ .describe( 'Validate + coerce every row without persisting. The verdict is the engine\'s own write-path ' + 'validation, with one boundary an author should know: a preview runs NO automations. Hooks never ' + - 'fire in a dry run (#6037) — a preview that executed user-authored side effects (mail, outbound ' + + 'fire in a dry run — a preview that executed user-authored side effects (mail, outbound ' + 'calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a ' + 'dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would ' + 'populate during the real import; for hook-derived fields the real write is authoritative.', @@ -350,13 +350,13 @@ export const ImportRequestSchema = lazySchema(() => z.object({ .describe( 'Fire triggers/hooks for each imported row. ON by default, and opting out must be ' + 'explicit: automations always ran on import historically (the engine ignored this flag ' + - 'until #2922), so a caller that wants a silent bulk load sends `runAutomations: false` — ' + + 'until this flag was honoured), so a caller that wants a silent bulk load sends `runAutomations: false` — ' + 'omitting the key runs them. This matches platform convention (Salesforce fires triggers ' + 'on import by default). One boundary: a `dryRun` preview runs NO automations whatever ' + - 'this flag says (#6037).', + 'this flag says.', ), treatAsHistorical: z.boolean().default(false) - .describe('Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates (#3479); and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them (#3493). Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped (#3556).'), + .describe('Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped.'), trimWhitespace: z.boolean().default(true) .describe('Trim leading/trailing whitespace from string cells'), nullValues: z.array(z.string()).optional() diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 1150f0d763..f920a059d0 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -322,7 +322,7 @@ describe('ObjectStack Protocol', () => { expect(issue!.message).toMatch(/`cursor` was removed from GET \/api\/v1\/notifications/); expect(issue!.message).toMatch(/not paginated/i); expect(issue!.message).toMatch(/limit/); - expect(issue!.message).toMatch(/#6361/); + expect(issue!.message).toMatch(/declared on the request AND the response/); } // `tsc` is the first channel retiredKey buys — the input type is `never`. diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 7a1b784bc4..05b5afa6bd 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -412,7 +412,7 @@ export const GetMetaItemResponseSchema = lazySchema(() => z.object({ name: z.string().describe('Item name'), item: z.unknown().describe('Metadata item definition'), sortability: ObjectSortabilitySchema.optional().describe( - 'Per-column sortability projection (#10235) — present exactly when `type` ' + 'Per-column sortability projection — present exactly when `type` ' + 'is `object`, on every serving branch. Computed at serve time from the ' + 'served document via the spec\'s own storage predicates; consumers render ' + 'sort affordances from this signal and never re-derive it from field ' @@ -545,7 +545,7 @@ export const RuntimeAuthoringIssueSchema = lazySchema(() => z.object({ + 'write types (`object` / `permission` / `book`) the TOP-LEVEL collection ' + 'entry is keyed by NAME (`objects.acme_invoice.sharingModel`), never by ' + 'an array index — the gate evaluates against a private per-write ' - + 'snapshot whose indexes no caller can resolve (#10064). Every other ' + + 'snapshot whose indexes no caller can resolve. Every other ' + 'write type is the sole member of its own collection, so its `[0]` is ' + 'trivially stable and stays positional (`flows[0]...`), as do nested ' + 'positions inside one named item (`objects.acme_invoice.indexes[1]`), ' @@ -580,7 +580,7 @@ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ name: MetadataItemNameSchema.describe( 'Item name — lowercase snake_case segments, optionally dot-qualified ' + '(`crm_lead`, `crm_lead.pipeline`). Slash-compound names are refused at ' - + 'the publish door (#12176).', + + 'the publish door.', ), item: z.unknown().describe('Metadata item definition'), })); @@ -642,10 +642,10 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ + '`projectionApplied.success` rather than rely on the 200.', ), advisories: z.array(RuntimeAuthoringIssueSchema).optional().describe( - 'Non-gating findings from the #4463 runtime authoring gate — the same ' + 'Non-gating findings from the runtime authoring gate — the same ' + 'shared author-time rules `os validate` / `os build` / `os lint` run, ' + 'applied to this body on its way to `active`. The write SUCCEEDED; these ' - + 'are what the gate has to say about it anyway (#4717, closing #4463 D3). ' + + 'are what the gate has to say about it anyway (closing D3). ' + 'Present ONLY when at least one advisory was raised — an empty array is ' + 'never emitted, so a clean save\'s response bytes are unchanged and ' + 'absence means "nothing to report", never "the gate did not run". ' @@ -654,10 +654,10 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ + '422 `invalid_metadata` envelope instead of here. A caller that ignores ' + 'this key behaves exactly as before. Runtime-only: the CLI surfaces the ' + 'same findings on its own stdout, and a Studio / MCP / AI author has no ' - + 'CLI at all, which is the gap #4463 exists to close. The gate runs on ' - + 'both write doors (#4463 D1), and both report: ' + + 'CLI at all, which is the gap this key exists to close. The gate runs on ' + + 'both write doors (D1), and both report: ' + '`POST /meta/:type/:name/publish` carries the same key on ' - + '`PublishMetaItemResponseSchema` (#9176).', + + '`PublishMetaItemResponseSchema`.', ), message: z.string().optional(), })); @@ -694,17 +694,17 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ name: MetadataItemNameSchema.describe( 'Item name — lowercase snake_case segments, optionally dot-qualified ' + '(`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the ' - + 'same grammar as `saveMetaItem` (#12194).', + + 'same grammar as `saveMetaItem`.', ), organizationId: z.string().optional().describe( 'Organization (tenant) scope for the promotion. The implementation resolves ' - + 'the draft through the org partition (ADR-0005, #8805), so a draft ' + + 'the draft through the org partition (ADR-0005), so a draft ' + 'authored org-scoped must be published under the same scope or the lookup ' + 'answers 404 `[no_draft]`. Absent = environment-wide.', ), actor: z.string().optional().describe( 'Identity recorded on the `op=\'publish\'` history event. On the REST door ' - + 'this is the request\'s authenticated identity (one producer, #7749) — ' + + 'this is the request\'s authenticated identity (one producer) — ' + 'never a caller-supplied header.', ), message: z.string().optional().describe( @@ -713,7 +713,7 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ packageId: z.string().nullable().optional().describe( 'ADR-0048 — the software package the draft being promoted was listed ' + 'under, when the caller has one to state (`?package=` on the REST ' - + 'door; #10063 / #10350). ⚠️ `null` is NOT the same as absent, and the ' + + 'door). ⚠️ `null` is NOT the same as absent, and the ' + 'difference is load-bearing: the implementation branches on the KEY ' + 'BEING PRESENT, so an ABSENT key keeps the historical "match any ' + 'package" resolution while `null` pins the lookup to the ' @@ -839,11 +839,11 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ + 'and logged, never thrown.', ), advisories: z.array(RuntimeAuthoringIssueSchema).optional().describe( - 'Non-gating findings from the #4463 runtime authoring gate — the same ' + 'Non-gating findings from the runtime authoring gate — the same ' + 'shared author-time rules `os validate` / `os build` / `os lint` run, ' - + 'applied to the DRAFT body this promotion carried to `active` (#9176, ' + + 'applied to the DRAFT body this promotion carried to `active` (' + 'the same key `SaveMetaItemResponseSchema` carries, because the gate ' - + 'runs on both write doors by #4463 D1). The promotion SUCCEEDED; these ' + + 'runs on both write doors, D1). The promotion SUCCEEDED; these ' + 'are what the gate has to say about it anyway. Present ONLY when at ' + 'least one advisory was raised — an empty array is never emitted, so a ' + 'clean publish\'s response bytes are unchanged and absence means ' @@ -853,7 +853,7 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ + '`invalid_metadata` envelope instead of here. A caller that ignores ' + 'this key behaves exactly as before. This door is the one Studio\'s ' + 'designer takes on every edit (draft save, then publish), and a Studio ' - + '/ MCP / AI author has no CLI at all — which is the gap #4463 exists ' + + '/ MCP / AI author has no CLI at all — which is the gap this key exists ' + 'to close.', ), message: z.string().optional().describe( @@ -861,7 +861,7 @@ export const PublishMetaItemResponseSchema = lazySchema(() => z.object({ + '[seq=3]`. The producer sets it on every publish today; it stays optional ' + 'to match the producer\'s own signature and its `SaveMetaItemResponse` ' + 'twin, and because an absent human-readable string strips no data — the ' - + 'failure mode #5745 exists to prevent.', + + 'failure mode this key exists to prevent.', ), })); @@ -913,13 +913,13 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ 'True only when every pending draft promoted (`failed` empty) AND at ' + 'least one item published. A pre-flight refusal or an ADR-0067 D2 ' + 'rollback answers false on a 200 — but so does a publish with nothing ' - + 'to promote, so false alone is NOT a refusal: read `outcome` (#10462), ' + + 'to promote, so false alone is NOT a refusal: read `outcome`, ' + 'not this boolean or the HTTP status. Always equal to ' + '`outcome === \'published\'` (pinned). It does NOT cover the ' + 'best-effort receipts below, each of which reports its own `success`.', ), outcome: z.enum(['published', 'refused', 'nothing_to_publish']).describe( - 'First-class discriminant for WHICH exit answered (#10462) — the fact ' + 'First-class discriminant for WHICH exit answered — the fact ' + '`success` compresses into one boolean. `published`: at least one draft ' + 'promoted and none refused. `refused`: the batch was refused — a ' + 'pre-flight violation or the ADR-0067 D2 all-or-nothing rollback; the ' @@ -955,8 +955,8 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ + 'part of this contract.', ), advisories: z.array(RuntimeAuthoringIssueSchema).optional().describe( - 'Non-gating findings the #4463 runtime authoring gate raised against ' - + 'THIS draft\'s promotion (#9343 — the same element shape and the ' + 'Non-gating findings the runtime authoring gate raised against ' + + 'THIS draft\'s promotion (the same element shape and the ' + 'same omitted-when-empty discipline as ' + '`PublishMetaItemResponseSchema.advisories`, riding each element ' + 'rather than a parallel top-level map). Present ONLY when at least ' @@ -980,7 +980,7 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ + 'A refusal that produced structured findings states a one-sentence ' + 'HEADLINE here (what failed, where, which rules, how many); the ' + 'per-path detail rides `issues[]` instead of being restated in this ' - + 'string (#10524 — consumers rendering both channels were showing ' + + 'string (consumers rendering both channels were showing ' + 'every finding twice).', ), code: z.string().optional().describe( @@ -990,13 +990,13 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ ), issues: z.array(RuntimeAuthoringIssueSchema).optional().describe( 'The structured findings behind the refusal, when the refusing error ' - + 'carried them — today the #4463 author-time gate\'s ' + + 'carried them — today the author-time gate\'s ' + 'INVALID_METADATA refusal on the causal item. The producer has ' - + 'emitted this key since #8333; declaring it (#10524) is what lets a ' + + 'emitted this key; declaring it is what lets a ' + 'typed consumer read it back, and what lets `error` stay a headline ' + 'without losing the per-path detail. Same element shape as ' + '`published[].advisories` and the single-item 422\'s `issues[]` ' - + '(#4717 — one dialect, declared once). Present ONLY on the causal ' + + '(— one dialect, declared once). Present ONLY on the causal ' + 'item and only when the refusal produced structured findings; ' + 'BATCH_ABORTED siblings never carry it. Absent means "this refusal ' + 'carried no structured findings", never "no problems".', @@ -1025,7 +1025,7 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ 'Single failure message, present when the apply failed before the ' + 'loader ran (including "no readable seed bodies"). When the failure ' + 'is the seed bodies\' own schema refusal, this is a one-sentence ' - + 'headline and the per-path detail rides `issues[]` (#10524).', + + 'headline and the per-path detail rides `issues[]`.', ), errors: z.array(z.unknown()).optional().describe( 'Per-record failures reported by the seed loader, plus any seed-body ' @@ -1040,16 +1040,16 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ message: z.string().describe('What is wrong, in the schema\'s own words.'), code: z.string().optional().describe( 'Zod\'s own issue code, verbatim — deliberately NOT the ADR-0114 ' - + '`fields[]` vocabulary (the #5364 decision: this is a ' + + '`fields[]` vocabulary (the decision: this is a ' + 'metadata-authoring diagnostic and its consumers read raw zod ' + 'codes; aligning the vocabularies is a separate decision).', ), })).optional().describe( 'Structured spec-validation findings behind `error`, present when the ' + 'apply was refused by the seed bodies\' own schema — the declared ' - + '422 `seedRequestValidationError` mints (#8443). The per-path ' - + 'detail lives HERE, once; `error` stays a one-sentence headline ' - + '(#10524). Absent on non-validation failures (driver faults, ' + + '422 `seedRequestValidationError` mints. The per-path ' + + 'detail lives HERE, once; `error` stays a one-sentence headline' + + '. Absent on non-validation failures (driver faults, ' + 'unreadable bodies), whose whole story is `error` / `errors[]`.', ), }).optional().describe( @@ -1095,7 +1095,7 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ 'ADR-0038 L3 post-publish runtime probe report — one real read per ' + 'published artifact (seeded objects have rows, views are readable, ' + 'widget dataset selections execute). DELIBERATELY OPAQUE in this ' - + 'contract (#9406): the key is declared and carried through verbatim, ' + + 'contract: the key is declared and carried through verbatim, ' + 'but its inner shape is intentionally not modeled until a consumer ' + 'needs a field of it. Present only when something was publishable; ' + 'probes never fail the publish.', @@ -1110,14 +1110,14 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ + 'has no drafts, only this flip). Attached by the REST door, not the ' + 'protocol helper. Present ONLY when at least one app flipped — on a ' + 'mid-loop failure it names the apps that DID persist, beside ' - + '`unhideError` (#5242\'s split report). Spelling is a permanent wire ' - + 'contract (see the door\'s #6955 note).', + + '`unhideError` (\'s split report). Spelling is a permanent wire ' + + 'contract (see the door\'s note).', ), unhideError: z.string().optional().describe( 'Present when the ADR-0045 visibility flip failed (wholly or partway): ' + 'the drafts ARE published, but apps still stored `_unpublished: true` ' + 'stay externally unobservable. Client-facing text only — undeclared ' - + 'driver text is withheld per ADR-0112 (#8516); the full cause is in ' + + 'driver text is withheld per ADR-0112; the full cause is in ' + 'the server log. The route is idempotent: re-run it once the cause is ' + 'resolved.', ), @@ -1126,7 +1126,7 @@ export const PublishPackageDraftsResponseSchema = lazySchema(() => z.object({ + 'everything is published and stored, but boot-cached consumers keep ' + 'the pre-publish view until re-run or restart (a newly published ' + 'record-triggered flow does not bind its trigger). Client-facing text ' - + 'only, same ADR-0112 withhold as `unhideError` (#8516).', + + 'only, same ADR-0112 withhold as `unhideError`.', ), })); @@ -1156,7 +1156,7 @@ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name'), organizationId: z.string().optional().describe( - 'Organization (tenant) scope for the reset (#8805). Load-bearing, not ' + 'Organization (tenant) scope for the reset. Load-bearing, not ' + 'advisory: it selects the ADR-0005 overlay partition, so it decides ' + 'WHICH row the reset destroys — an org-scoped delete removes that ' + 'tenant\'s own overlay, while an org-less delete reaches the ' @@ -1172,9 +1172,9 @@ export const DeleteMetaItemRequestSchema = lazySchema(() => z.object({ ), actor: z.string().optional().describe( 'Identity recorded on the delete\'s history tombstone row. On the REST ' - + 'door this is the request\'s authenticated identity (one producer, ' - + '#7749) — never a caller-supplied header. Absent, the event is recorded ' - + 'actor-less (null), deliberately not attributed to "system" (#4556).', + + 'door this is the request\'s authenticated identity (one producer) ' + + '— never a caller-supplied header. Absent, the event is recorded ' + + 'actor-less (null), deliberately not attributed to "system".', ), state: z.enum(['active', 'draft']).optional().describe( 'Which lifecycle row to discard: `draft` discards the pending draft ' @@ -1223,7 +1223,7 @@ export const AuditMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), name: z.string().describe('Item name'), organizationId: z.string().nullable().optional().describe( - 'Organization (tenant) scope for the read (#8747). With an organization, ' + 'Organization (tenant) scope for the read. With an organization, ' + 'the trail includes that org\'s rows AND the env-wide ' + '(`organization_id IS NULL`) rows — the env-wide limb is load-bearing, ' + 'because env-level writes are stamped org-less. `null` and absent are ' @@ -1339,7 +1339,7 @@ export const AuditMetaItemResponseSchema = lazySchema(() => z.object({ * implementation detail rather than a declared union. */ export const GetPublishedMetaItemResponseSchema = lazySchema(() => z.unknown().describe( - 'The published metadata item body, opaque by ruling (#12038 1C). Shape is ' + 'The published metadata item body, opaque by ruling (1C). Shape is ' + 'the item\'s own metadata-type schema, resolved at read time — never ' + 'frozen into this contract.', )); @@ -1487,12 +1487,12 @@ export const GetMetaItemCachedRequestSchema = lazySchema(() => z.object({ locale: z.string().optional().describe( 'Resolved response locale. Folded into the ETag so a language switch ' + 'never returns a stale-locale 304 — metadata is translated *after* the ' - + 'cache validator check (issue #1319).', + + 'cache validator check (issue).', ), organizationId: z.string().optional().describe( 'Organization (tenant) scope for the read. Selects the org partition in the ' + 'ADR-0005 overlay read order — org overlay wins over env-wide overlay wins ' - + 'over packaged artifact — exactly as on the uncached read (#9454). Also ' + + 'over packaged artifact — exactly as on the uncached read. Also ' + 'folded into the ETag, so a scope switch never returns a stale 304 from ' + 'another scope\'s cached representation. Absent = environment-wide read.', ), @@ -1649,9 +1649,9 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({ id: z.string().describe('The ID of the newly created record.'), record: z.record(z.string(), z.unknown()).describe('The created record, including server-generated fields (created_at, owner).'), droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( - 'Write-observability (#3407/#3431): caller-supplied fields that were LEGALLY stripped ' + + 'Write-observability: caller-supplied fields that were LEGALLY stripped ' + 'before the record was written — a non-system create cannot seed a static `readonly` ' + - 'column (#3043 ingress strip), so those keys are dropped and the field re-derives its ' + + 'column (ingress strip), so those keys are dropped and the field re-derives its ' + 'default. Present ONLY when ≥1 field was dropped; the create still succeeded without ' + 'them (status/success semantics unchanged). REST additionally surfaces this as the ' + '`X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the ' + @@ -1749,7 +1749,7 @@ export const ValidateDataResponseSchema = lazySchema(() => z.object({ 'The ADR-0104 posture the verdict was reached under — reported because it is the difference between ' + '"this row is fine" and "this row is fine HERE". The same row can be an error on a self-certified ' + 'deployment and an admitted warning on an un-migrated one, and a caller explaining a verdict needs to ' + - 'know which it got. An unconditionally-strict preview was considered and rejected (#4633 option B): it ' + + 'know which it got. An unconditionally-strict preview was considered and rejected (option B): it ' + 'would fail rows on every un-migrated deployment that the write would have accepted.', ), })); @@ -1787,9 +1787,9 @@ export const UpdateDataResponseSchema = lazySchema(() => z.object({ id: z.string().describe('Updated record ID'), record: z.record(z.string(), z.unknown()).describe('Updated record'), droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( - 'Write-observability (#3407/#3431): caller-supplied fields the engine LEGALLY stripped ' + - 'from the write before persisting — static `readonly` (#2948) or a TRUE `readonlyWhen` ' + - 'predicate (#3042). Present ONLY when ≥1 field was dropped; the update still succeeded ' + + 'Write-observability: caller-supplied fields the engine LEGALLY stripped ' + + 'from the write before persisting — static `readonly` or a TRUE `readonlyWhen` ' + + 'predicate. Present ONLY when ≥1 field was dropped; the update still succeeded ' + 'without them (status/success semantics unchanged — stripping is legitimate, not an ' + 'error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response ' + 'header. Optional — omit-when-empty keeps the shape backward-compatible for existing ' + @@ -1847,7 +1847,7 @@ export const SearchAllHitSchema = lazySchema(() => z.object({ snippet: z.string().optional().describe( 'Excerpt cut around the first matched term in a searchable text column, ellipsized at ' + 'both ends when truncated. ABSENT when no source column literally contains a term ' - + '(e.g. a pinyin companion match, #7643) — absence is a correct answer, not a miss.' + + '(e.g. a pinyin companion match) — absence is a correct answer, not a miss.' ), record: z.record(z.string(), z.unknown()).describe( 'The matched record as the engine\'s find path returns it (row-level security applied, ' @@ -1883,7 +1883,7 @@ export const SearchAllResponseSchema = lazySchema(() => z.object({ totalObjects: z.number().describe( 'Number of objects the sweep actually SCANNED (searchable, API-enabled, with a ' + 'resolvable search-field set) — not the number of objects with hits. An object ' - + 'whose table was never provisioned is skipped and not counted (#8896).' + + 'whose table was never provisioned is skipped and not counted.' ), totalHits: z.number().describe( 'Number of hits returned — equals `hits.length`. NOT a deployment-wide total-match ' @@ -1929,7 +1929,7 @@ export const CreateManyDataResponseSchema = lazySchema(() => z.object({ records: z.array(z.record(z.string(), z.unknown())).describe('Created records'), count: z.number().describe('Number of records created'), droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( - 'Write-observability (#3407/#3431/#3455): caller-supplied `readonly` fields the #3043 ' + + 'Write-observability: caller-supplied `readonly` fields the ' + 'create-ingress strip removed before the rows were written. AGGREGATED across the batch ' + '(one event per object/reason with the union of dropped field names) rather than per-row, ' + 'because the insert-time strip is static-`readonly` only — schema-uniform, so every row ' + @@ -2289,7 +2289,7 @@ export const NotificationSchema = lazySchema(() => z.object({ */ const NOTIFICATIONS_CURSOR_REMOVED = '`cursor` was removed from GET /api/v1/notifications in @objectstack/spec 17 ' - + '(#6361, ADR-0049) — it was declared on the request AND the response and honoured on ' + + '(ADR-0049) — it was declared on the request AND the response and honoured on ' + 'neither: the server reads only `read`/`type`/`limit`, and no emit site ever wrote the ' + 'response key, so a caller paginating by it re-read the first window forever with no ' + 'error and no 400. Delete the key; the `cursor` argument of ' @@ -2650,7 +2650,7 @@ export const GetLocalesResponseSchema = lazySchema(() => z.object({ locales: z.array(z.object({ code: z.string().describe('BCP-47 locale code (e.g., en-US, zh-CN)'), label: z.string().describe( - 'Locale label. Equals `code` on every serving surface today — the client names locales for its UI (#7634)', + 'Locale label. Equals `code` on every serving surface today — the client names locales for its UI', ), isDefault: z.boolean().default(false).describe('Whether this is the default locale'), })).describe('Available locales'), diff --git a/packages/spec/src/api/realtime.zod.ts b/packages/spec/src/api/realtime.zod.ts index 8fcf468373..a412993497 100644 --- a/packages/spec/src/api/realtime.zod.ts +++ b/packages/spec/src/api/realtime.zod.ts @@ -35,7 +35,7 @@ export const RealtimeEventType = z.enum([ 'record.updated', 'record.deleted', 'field.changed', -]).describe('Realtime event type (not yet enforced — the runtime emits data.record.* event names instead, and field.changed is never emitted; see #3197)'); +]).describe('Realtime event type '); export type RealtimeEventType = z.input; diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index f24ece6d90..a2e0306915 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -138,7 +138,7 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ * Which is the exact failure mode this key's removal is about. */ requireAuth: retiredKey( - '`api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data ' + '`api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data ' + 'is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. ' + 'To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a ' + "share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of " @@ -529,7 +529,7 @@ export const RestServerConfigSchema = lazySchema(() => z.object({ * (see the section comment above). */ openApi31: retiredKey( - '`RestServerConfig.openApi31` was removed in @objectstack/spec 17 (#4579, ADR-0049) — no ' + '`RestServerConfig.openApi31` was removed in @objectstack/spec 17 (ADR-0049) — no ' + 'runtime ever read it: the REST server forwards only `api`/`crud`/`metadata`/`batch`/`routes`, ' + 'and the served /openapi.json is the pre-generated contract enriched with the live server URL ' + 'and the registered objects, so webhook/callback definitions declared here never appeared in ' diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index 5aa12ee29f..2818df6db1 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -398,7 +398,7 @@ export const ApprovalNodeApproverSchema = lazySchema(() => strictObject( expandas: 'resolveAs', }, history: - 'Until #4001 these were dropped silently — the approver still parsed, so the ' + + 'Until this shape was closed, these were dropped silently — the approver still parsed, so the ' + 'request could route to the wrong slate without a diagnostic.', }, { @@ -529,7 +529,7 @@ export const DecisionOutputDefSchema = lazySchema(() => strictObject( surface: 'this decision-output declaration', aliases: { name: 'key', widget: 'type', many: 'multiple' }, history: - 'Until #4001 these were dropped silently — the declaration still parsed, so the ' + + 'Until this shape was closed, these were dropped silently — the declaration still parsed, so the ' + 'decision dialog rendered a different input than the author specified.', }, { @@ -609,7 +609,7 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject( target: 'escalateTo', }, history: - 'Until #4001 these were dropped silently — the escalation still parsed, so an SLA ' + + 'Until this shape was closed, these were dropped silently — the escalation still parsed, so an SLA ' + 'the author declared never fired the way they intended.', }, { @@ -688,7 +688,7 @@ export const ApprovalNodeConfigSchema = lazySchema(() => strictObject( 'revise loop is the `revise` out-edge with `maxRevisions` bounding it.', }, history: - 'Until #4001 these were dropped silently — the node still parsed, so an approval ' + + 'Until this shape was closed, these were dropped silently — the node still parsed, so an approval ' + 'gate shipped that quietly ignored part of its declared behavior.', }, { diff --git a/packages/spec/src/automation/builtin-node-config.test.ts b/packages/spec/src/automation/builtin-node-config.test.ts index f7ad4ca4a8..fc2bb7cca2 100644 --- a/packages/spec/src/automation/builtin-node-config.test.ts +++ b/packages/spec/src/automation/builtin-node-config.test.ts @@ -74,7 +74,8 @@ describe('CRUD config contracts — strict as of #4001 批 9', () => { (_nodeType, schema, base) => { const message = unknownKeyMessage(schema, { ...base, filters: { status: 'stale' } })!; expect(message).toContain('flow-node-crud-filter-alias'); - expect(message).toContain('#3810'); + expect(message).toContain('match-everything write'); + expect(message, 'the prescription names the hazard, never a tracker id').not.toMatch(/#\d{3,5}/); }, ); @@ -91,11 +92,12 @@ describe('CRUD config contracts — strict as of #4001 批 9', () => { ['create_record', CreateRecordConfigSchema, { objectName: 'task' }], ['update_record', UpdateRecordConfigSchema, { objectName: 'lead' }], ] as ReadonlyArray<[string, Parseable, Record]>)( - '%s: `fieldValues` gets the #2419 prescription, not a runtime alias', + '%s: `fieldValues` gets the rejected-alias prescription, not a runtime alias', (_nodeType, schema, base) => { const message = unknownKeyMessage(schema, { ...base, fieldValues: { subject: 'hi' } })!; - expect(message).toContain('#2419'); + expect(message).toContain('never had a runtime reader'); expect(message).toContain('`fields`'); + expect(message, 'the prescription names the write map, never a tracker id').not.toMatch(/#\d{3,5}/); }, ); @@ -108,7 +110,8 @@ describe('CRUD config contracts — strict as of #4001 批 9', () => { (_nodeType, schema, base) => { const message = unknownKeyMessage(schema, { ...base, recordId: '{record.id}' })!; expect(message).toContain('`filter`'); - expect(message).toContain('#3810'); + expect(message).toContain('match-everything delete'); + expect(message, 'the prescription names the hazard, never a tracker id').not.toMatch(/#\d{3,5}/); }, ); @@ -119,7 +122,7 @@ describe('CRUD config contracts — strict as of #4001 批 9', () => { '%s: `outputVariable` is a DOCUMENTED absence, so the rejection says so rather than staying silent', (_nodeType, schema, base) => { const message = unknownKeyMessage(schema, { ...base, outputVariable: 'updated' })!; - expect(message).toContain('#4045'); + expect(message).toContain('binds no output'); expect(message).toContain('get_record'); }, ); @@ -156,7 +159,8 @@ describe('CRUD config contracts — strict as of #4001 批 9', () => { for (const key of ['bulk', 'all', 'multiple']) { const message = unknownKeyMessage(schema, { ...base, [key]: true })!; expect(message, key).toContain('`multi: true`'); - expect(message, key).toContain('#5393'); + expect(message, key).toContain('NO spelling of'); + expect(message, key).not.toMatch(/#\d{3,5}/); // The distance claim, pinned: none of these reaches `multi`, so // without the curated entry the rejection would offer nothing. expect(message, key).not.toContain(`\`${key}\` → `); @@ -203,10 +207,11 @@ describe('ScreenConfigSchema / ScreenFieldConfigSchema — strict as of #4001 .toContain('this screen field'); }); - it('`visibleIf` on a field carries the #3528 prescription — the typo the whole ladder descends from', () => { + it('`visibleIf` on a field carries its prescription — the typo the whole ladder descends from', () => { const message = unknownKeyMessage(ScreenFieldConfigSchema, { name: 'amount', visibleIf: "type == 'x'" })!; expect(message).toContain('`visibleWhen`'); - expect(message).toContain('#3528'); + expect(message).toContain('the whole undeclared-key ladder descends from'); + expect(message, 'the prescription names the right key, never a tracker id').not.toMatch(/#\d{3,5}/); }); it('`object` on a screen names `objectName`, which no other layer would tell the author', () => { diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index d7af0c90af..1e1995e539 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -77,7 +77,7 @@ import { strictObject } from '../shared/strict-object'; /** What a rejected key on these contracts silently did before #4001 批 9. */ const BUILTIN_NODE_CONFIG_HISTORY = - 'Until #4001 an undeclared key here was dropped at the execute-time parse — the step still ran and the run ' + 'Until this shape was closed, an undeclared key here was dropped at the execute-time parse — the step still ran and the run ' + 'still reported success, minus whatever the key was meant to configure.'; /** @@ -109,17 +109,17 @@ const BUILTIN_NODE_CONFIG_HISTORY = const CRUD_ALIAS_GUIDANCE = { object: 'The object slot is `objectName`. `object` was the last tenant of the `readAliasedConfig` executor shim; it ' - + 'graduated into the ADR-0087 D2 conversion `flow-node-crud-object-alias` (#3796), which rewrites it at load. ' + + 'graduated into the ADR-0087 D2 conversion `flow-node-crud-object-alias`, which rewrites it at load. ' + 'If `objectName` is also present, this node names two different objects and the conversion left both keys ' - + 'alone rather than picking for you (#4923) — decide which object this node acts on, put it on `objectName`, ' + + 'alone rather than picking for you — decide which object this node acts on, put it on `objectName`, ' + 'and delete `object`.', filters: 'The match map is `filter` (singular). `filters` was a consumer-side executor fallback that graduated into the ' + 'ADR-0087 D2 conversion `flow-node-crud-filter-alias`, which rewrites it at load; delete it once `filter` ' + 'carries the pairs. If `filter` is also present, the two carry DIFFERENT match maps and the conversion kept ' - + 'both rather than choosing (#4923) — reconcile them onto `filter`. Beware the half-migrated shape: an empty ' - + '`filter` next to a populated `filters` is what made this alias dangerous enough to declare (#3810 — a ' - + 'match-everything write).', + + 'both rather than choosing — reconcile them onto `filter`. Beware the half-migrated shape: an empty ' + + '`filter` next to a populated `filters` is what made this alias dangerous enough to declare — it is a ' + + 'match-everything write.', } as const; /** @@ -141,7 +141,7 @@ const CRUD_RECORD_ID_GUIDANCE = 'CRUD nodes address rows through `filter`, never through a `recordId` key — no executor has ever read one. ' + "Write the id as a filter VALUE: `filter: { id: '{record.id}' }`, which is the shape the node's own descriptor " + 'documents. This matters most on `delete_record`: a config whose only "constraint" is an unread key is a ' - + 'match-everything delete, the #3810 hazard.'; + + 'match-everything delete, the hazard.'; /** * `fieldValues` — the AI-authoring dialect that never had a runtime reader. @@ -154,7 +154,7 @@ const CRUD_RECORD_ID_GUIDANCE = */ const FIELD_VALUES_GUIDANCE = 'The write map is `fields`. `fieldValues` was an AI-authoring dialect that never had a runtime reader, and a ' - + 'consumer-side `cfg.fields ?? cfg.fieldValues` alias was rejected by design (#2419) — the fix is the authoring ' + + 'consumer-side `cfg.fields ?? cfg.fieldValues` alias was rejected by design — the fix is the authoring ' + 'source and this rejection, not a runtime fallback.'; /** @@ -167,8 +167,8 @@ const FIELD_VALUES_GUIDANCE = * reaches nothing. */ const NO_OUTPUT_VARIABLE_GUIDANCE = - 'This node binds no output — the executor reads no `outputVariable`, and #4045 recorded that absence ' - + 'deliberately after re-verifying the executor. Its siblings (`get_record`, `create_record`, `map`) do declare ' + 'This node binds no output — the executor reads no `outputVariable`, and that absence is ' + + 'deliberate, recorded after re-verifying the executor. Its siblings (`get_record`, `create_record`, `map`) do declare ' + 'one, which is why the key looks universal and is not. To use what was written, follow this node with a ' + '`get_record` that reads the row back.'; @@ -196,7 +196,7 @@ const NO_OUTPUT_VARIABLE_GUIDANCE = */ const BULK_INTENT_PRESCRIPTION = 'Bulk intent is declared with `multi: true` — the same word the data engine has always used for it ' - + '(`options.multi`), so the concept keeps one name from node config to driver call. Until #5393 NO spelling of ' + + '(`options.multi`), so the concept keeps one name from node config to driver call. Until this key arrived NO spelling of ' + 'it existed on this node, which is why a predicate write was refused by the engine ' + '(`… requires an ID or options.multi=true`) and no flow could reach `updateMany`/`deleteMany` at all. Leaving ' + 'it off is still a valid, deliberate choice: without it the write must name one row by scalar `id`.'; @@ -204,7 +204,7 @@ const BULK_INTENT_PRESCRIPTION = const BULK_INTENT_OPTIONS_PRESCRIPTION = 'This is the NODE config, not the data engine\'s options bag — declare `multi: true` at the top level of ' + '`config`, never `options: { multi: true }`. Translating the declared intent into `options.multi` on the ' - + 'engine call is the executor\'s job, and it is the only thing that should be doing it (#5393).'; + + 'engine call is the executor\'s job, and it is the only thing that should be doing it.'; const CRUD_BULK_INTENT_GUIDANCE = { bulk: BULK_INTENT_PRESCRIPTION, @@ -374,7 +374,7 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ guidance: { visibleIf: 'The visibility predicate is `visibleWhen` — bare CEL (ADR-0032), forwarded raw and re-evaluated ' - + 'client-side as the user types (#3528). `visibleIf` is four edits away from the right key, which is why ' + + 'client-side as the user types. `visibleIf` is four edits away from the right key, which is why ' + 'the registration-time rejection prints the declared set rather than trusting a suggester; it is also the ' + 'typo the whole undeclared-key ladder descends from — three diagnostic passes for a field that silently ' + 'never hid.', @@ -485,9 +485,9 @@ export const MapConfigSchema = lazySchema(() => strictObject({ guidance: { flow: 'The per-item subflow is named by `flowName`. `flow` was an undeclared executor fallback no schema or form ' - + 'described; it graduated into the ADR-0087 D2 conversion `flow-node-map-flow-alias` (#4045), which rewrites ' + + 'described; it graduated into the ADR-0087 D2 conversion `flow-node-map-flow-alias`, which rewrites ' + 'it at load. If `flowName` is also present, the two name DIFFERENT subflows and the conversion kept both ' - + 'rather than picking one to run per item (#4923) — decide which flow this is, put it on `flowName`, and ' + + 'rather than picking one to run per item — decide which flow this is, put it on `flowName`, and ' + 'delete `flow`.', }, }, { diff --git a/packages/spec/src/automation/control-flow.zod.ts b/packages/spec/src/automation/control-flow.zod.ts index c2e28a7d00..9ca6d91449 100644 --- a/packages/spec/src/automation/control-flow.zod.ts +++ b/packages/spec/src/automation/control-flow.zod.ts @@ -80,7 +80,7 @@ import { FLOW_REGION_SLOTS_BY_TYPE } from './region-slots'; * description of it, so the five rejections cannot drift apart. */ const CONTROL_FLOW_STRIP_HISTORY = - 'Until #4001 an undeclared key here was dropped silently — the container still parsed, registered and ran, with the author\'s configuration simply absent.'; + 'Until this shape was closed, an undeclared key here was dropped silently — the container still parsed, registered and ran, with the author\'s configuration simply absent.'; /** * ADR-0031 §Decision 2, stated once per spelling a BPMN-trained author reaches diff --git a/packages/spec/src/automation/flow-function.zod.ts b/packages/spec/src/automation/flow-function.zod.ts index 8bfceba1bc..6e3a7735c8 100644 --- a/packages/spec/src/automation/flow-function.zod.ts +++ b/packages/spec/src/automation/flow-function.zod.ts @@ -149,15 +149,15 @@ export const FlowFunctionDeclarationSchema = lazySchema(() => strictObject({ 'take one — but that is the array entry\'s own shape, not this record.', }, history: - 'Until #4001 these were dropped silently — and `normalizeFlowFunctionEntry` reads only ' + + 'Until this shape was closed, these were dropped silently — and `normalizeFlowFunctionEntry` reads only ' + '`handler`/`effect` by construction, so a misspelled `effect` was discarded twice over: ' + 'the function still registered, still ran, and its writes were still counted as none, ' + - 'which is what keeps #4354\'s broken-sweep alert quiet on the run that needed it.', + 'which is what keeps the broken-sweep alert quiet on the run that needed it.', }, { handler: z.function().describe('The function invoked by name (a `script` node, a string-named Hook/Action handler)'), effect: FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT) .describe("What the function does to data — omit for the pure default"), -}).describe('A named handler function plus its declared effect (#4396)')); +}).describe('A named handler function plus its declared effect')); export type FlowFunctionDeclaration = z.input; /** Post-parse shape of {@link FlowFunctionDeclaration} — defaults applied, transforms run (ADR-0122). */ @@ -185,7 +185,7 @@ export type FlowFunctionDeclarationParsed = z.infer FlowFunctionDeclarationSchema.extend({ handler: z.string().min(1) .describe('The lowered handler ref (built artifacts) — the callable rides in the sibling ESM module'), -}).describe('A lowered `functions` declaration: what the function declared about itself, with its callable replaced by a handler ref (#4976)')); +}).describe('A lowered `functions` declaration: what the function declared about itself, with its callable replaced by a handler ref')); /** * One entry of the `functions` map, in the four shapes it legitimately takes: diff --git a/packages/spec/src/automation/flow.test.ts b/packages/spec/src/automation/flow.test.ts index acb30f7605..8d05aaa9a0 100644 --- a/packages/spec/src/automation/flow.test.ts +++ b/packages/spec/src/automation/flow.test.ts @@ -365,7 +365,7 @@ describe('FlowSchema', () => { message = String((e as Error).message); } expect(message).toMatch(/status/); - expect(message).toMatch(/#3896/); + expect(message).toMatch(/audit close-out/); }); it('should default runAs to user', () => { @@ -1288,7 +1288,7 @@ describe('BPMN — Wait Event Configuration', () => { const key = Object.keys(retired)[0]; expect(result.success, `${key} must be rejected, not silently dropped`).toBe(false); // The prescription names the issue and the replacement (or its absence). - expect(JSON.stringify(result.error?.issues), `${key} guidance`).toMatch(/4158/); + expect(JSON.stringify(result.error?.issues), `${key} guidance`).toMatch(/was removed in @objectstack\/spec 17/); } }); diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 3d6bfa3e53..16604d1a1a 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -149,7 +149,7 @@ export const FlowVariableSchema = lazySchema(() => strictObject( surface: 'this flow variable', aliases: { input: 'isInput', output: 'isOutput', default: 'defaultValue', initialValue: 'defaultValue' }, history: - 'Until #4001 these were dropped silently — the variable still parsed, so a ' + + 'Until this shape was closed, these were dropped silently — the variable still parsed, so a ' + 'mis-declared input/output contract shipped without a diagnostic.', }, { @@ -256,7 +256,7 @@ function flowNodeObject() { return strictObject( '(e.g. `config.inputs` for script/function nodes); `inputSchema` declares their types.', }, history: - 'Until #4001 these were dropped silently — the node still parsed, so a mis-placed ' + + 'Until this shape was closed, these were dropped silently — the node still parsed, so a mis-placed ' + 'config shipped as a step that quietly ignored it.', }, { @@ -297,7 +297,7 @@ function flowNodeObject() { return strictObject( payload: 'input', }, history: - 'Until #4001 these were dropped silently — the block still parsed, so a whole ' + + 'Until this shape was closed, these were dropped silently — the block still parsed, so a whole ' + 'mapped input map written under another word vanished and the executor ' + 'dispatched the action with `input ?? {}`: a successful call carrying nothing.', }, @@ -320,7 +320,7 @@ function flowNodeObject() { return strictObject( { surface: "this node's canvas `position`", history: - 'Until #4001 these were dropped silently — the block still parsed, so a canvas ' + + 'Until this shape was closed, these were dropped silently — the block still parsed, so a canvas ' + 'hint written beside x/y (a size, a third coordinate, a designer marker) was ' + 'discarded, and the round-trip back through the designer could not tell it had ' + 'ever been written.', @@ -347,7 +347,7 @@ function flowNodeObject() { return strictObject( '`required: true`.', }, history: - 'Until #4001 these were dropped silently — the declaration still parsed, so a ' + + 'Until this shape was closed, these were dropped silently — the declaration still parsed, so a ' + 'parameter constrained under a word we do not declare (`optional: false`) came ' + 'back UNconstrained: `required` fell to its `false` default, and the engine\'s ' + 'pre-execution check (`validateNodeInputSchemas`) then had nothing to require.', @@ -362,7 +362,7 @@ function flowNodeObject() { return strictObject( // `outputSchema` REMOVED (#3896 audit close-out): declared, never validated — // no engine path checked node outputs against it (ledger: dead). outputSchema: retiredKey( - '`flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (#3896 audit ' + + '`flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit ' + 'close-out) — it was never validated: the engine does not check node outputs against ' + 'it, so it documented a contract nothing enforced. Delete the key. Downstream nodes ' + "read prior outputs via expressions ({{nodeId.field}}) regardless of any declaration. " + @@ -391,12 +391,12 @@ function flowNodeObject() { return strictObject( // helper once told an author to write something that gets rejected next. timeout: '`wait` has no timeout — nothing has ever failed or resumed a wait on a deadline ' + - '(#4158 retired the two keys that claimed one). Use `timerDuration`, and QUOTE the ' + + '(retired the two keys that claimed one). Use `timerDuration`, and QUOTE the ' + 'number: the key is a string, and a bare numeric string is read as milliseconds, so ' + "`timerDuration: '60000'` is a 60s wait (`timerDuration: 'PT1M'` says the same in ISO 8601).", }, history: - 'Until #4001 these were dropped silently — the block still parsed, so a wait node ' + + 'Until this shape was closed, these were dropped silently — the block still parsed, so a wait node ' + 'whose resume condition the author spelled slightly wrong waited on nothing.', }, { /** Type of event to wait for */ @@ -432,7 +432,7 @@ function flowNodeObject() { return strictObject( * happened to be declared. */ timeoutMs: retiredKey( - '`waitEventConfig.timeoutMs` was removed in @objectstack/spec 17 (#4158). It documented a ' + '`waitEventConfig.timeoutMs` was removed in @objectstack/spec 17. It documented a ' + 'timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its ' + 'only reader treated it as the timer DURATION when `timerDuration` was absent, so use ' + '`timerDuration` — but QUOTE the number: the key is a string, and a bare numeric string is ' @@ -443,7 +443,7 @@ function flowNodeObject() { return strictObject( + 'sources; apply them by hand.', ), onTimeout: retiredKey( - '`waitEventConfig.onTimeout` was removed in @objectstack/spec 17 (#4158). It had no readers at ' + '`waitEventConfig.onTimeout` was removed in @objectstack/spec 17. It had no readers at ' + 'all — no code path ever inspected it, so neither `fail` nor `continue` ever happened. Delete ' + 'the key. There is no replacement: `wait` has no timeout, and a wait node resumes only when ' + 'its timer elapses or its signal arrives. ' @@ -474,7 +474,7 @@ function flowNodeObject() { return strictObject( duration: 'timerDuration', }, history: - 'Until #4001 these were dropped silently — the block still parsed, so BPMN\'s ' + + 'Until this shape was closed, these were dropped silently — the block still parsed, so BPMN\'s ' + '`cancelActivity: false` was discarded and `interrupting` fell to its `true` ' + 'default: an event the author declared NON-interrupting cancelled the host ' + 'activity anyway.', @@ -514,7 +514,7 @@ export const FlowEdgeSchema = lazySchema(() => strictObject( guard: 'condition', }, history: - 'Until #4001 these were dropped silently — the edge still parsed, so a branch ' + + 'Until this shape was closed, these were dropped silently — the edge still parsed, so a branch ' + 'predicate or endpoint the author wrote was quietly ignored.', }, { @@ -608,7 +608,7 @@ export const FlowSchema = lazySchema(() => strictObject( '`config.schedule` on the START node, not at the flow top level.', }, history: - 'Until #4001 these were dropped silently — the flow still parsed, so a trigger ' + + 'Until this shape was closed, these were dropped silently — the flow still parsed, so a trigger ' + 'binding or config the author wrote was quietly ignored.', }, { @@ -641,7 +641,7 @@ export const FlowSchema = lazySchema(() => strictObject( status: z.enum(['draft', 'active', 'obsolete', 'invalid']).default('draft').describe('Deployment status'), // `template` REMOVED (#3896 audit close-out): no reader in designer or engine. template: retiredKey( - '`flow.template` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' + + '`flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + 'no designer or engine path ever read it, so flagging a flow as a template/subflow did ' + 'nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the ' + 'flow by name. ' + @@ -664,7 +664,7 @@ export const FlowSchema = lazySchema(() => strictObject( // (`status` is the enforced lifecycle). An author who "disabled" a flow here // left it firing. active: retiredKey( - '`flow.active` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it ' + + '`flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it ' + 'never had an effect: the engine arms flows from `status`, and `active: false` did NOT ' + 'stop a flow (worse, the default read as disabled while the engine treated unset as ' + "enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and " + @@ -775,18 +775,18 @@ export const FlowSchema = lazySchema(() => strictObject( 'flow `errorHandling` counts retries AFTER it. Write `maxRetries: ` ' + '— renaming the key alone would quietly run one attempt fewer than you asked for.', fallback: - 'There is no fallback node on `errorHandling` (`fallbackNodeId` was removed in 17, ' + - '#3896 — the engine never read it). Draw a per-node FAULT EDGE from the failing node ' + + 'There is no fallback node on `errorHandling` (`fallbackNodeId` was removed in 17 — ' + + 'the engine never read it). Draw a per-node FAULT EDGE from the failing node ' + 'to the handler node instead.', fallbackNode: - 'There is no fallback node on `errorHandling` (`fallbackNodeId` was removed in 17, ' + - '#3896 — the engine never read it). Draw a per-node FAULT EDGE from the failing node ' + + 'There is no fallback node on `errorHandling` (`fallbackNodeId` was removed in 17 — ' + + 'the engine never read it). Draw a per-node FAULT EDGE from the failing node ' + 'to the handler node instead.', }, history: - 'Until #4001 these were dropped silently — the block still parsed, so a retry budget ' + + 'Until this shape was closed, these were dropped silently — the block still parsed, so a retry budget ' + 'or backoff the author configured was replaced by this block\'s defaults without a word. ' + - 'Since #4964 the retry keys are the converged `RetryPolicySchema` contract, so a spelling ' + + 'The retry keys are now the converged `RetryPolicySchema` contract, so a spelling ' + 'learned on `job.retryPolicy` or a `try_catch` node\'s `retry` is correct here too.', }, { strategy: z.enum(['fail', 'retry', 'continue']).default('fail').describe("How to handle node execution errors. 'retry' governs ONE synchronous dispatch: a durable pause (approval/screen/wait) ends the retry-governed segment, so a failure after the run resumes is not retried."), @@ -822,7 +822,7 @@ export const FlowSchema = lazySchema(() => strictObject( // unrecoverable errors via per-node FAULT EDGES, never this — an author // who configured a fallback here had none. fallbackNodeId: retiredKey( - '`flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (#3896 ' + + '`flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (' + 'audit close-out) — the engine routes unrecoverable node errors via per-node fault ' + "edges (an edge with type: 'fault'), and never read this key: a fallback " + 'configured here silently did not exist. Delete the key and draw a fault edge from ' + diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index b8e2344fcd..b44b47773e 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -65,7 +65,7 @@ import { strictObject } from '../shared/strict-object'; * minus whatever the key was meant to configure. */ const IO_NODE_CONFIG_HISTORY = - 'Until #4001 an undeclared key here was dropped at the execute-time parse — the step still ran and ' + 'Until this shape was closed, an undeclared key here was dropped at the execute-time parse — the step still ran and ' + 'the run still reported success, minus whatever the key was meant to configure.'; /** @@ -88,28 +88,28 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { to: 'The recipient slot is `recipients`. `to` is the pre-17 spelling, rewritten at load by the ADR-0087 D2 ' + 'conversion `flow-node-notify-config-aliases` — so if `recipients` is also present, the two name DIFFERENT ' - + 'recipients and the conversion kept both rather than choosing who gets notified (#4923). Decide the ' + + 'recipients and the conversion kept both rather than choosing who gets notified. Decide the ' + 'recipients, put them on `recipients`, and delete `to`.', subject: 'The heading slot is `title`. `subject` is the pre-17 spelling rewritten at load by ' + '`flow-node-notify-config-aliases`; delete it once `title` carries the text. If `title` is also present with ' - + 'DIFFERENT text, the conversion kept both rather than choosing (#4923) — reconcile them onto `title`.', + + 'DIFFERENT text, the conversion kept both rather than choosing — reconcile them onto `title`.', body: 'The body slot is `message`. `body` is the pre-17 spelling rewritten at load by ' + '`flow-node-notify-config-aliases`; delete it once `message` carries the text. If `message` is also present ' - + 'with DIFFERENT text, the conversion kept both rather than choosing (#4923) — reconcile them onto `message`. ' + + 'with DIFFERENT text, the conversion kept both rather than choosing — reconcile them onto `message`. ' + '(`body` IS canonical on an `http` node — the key is wrong only here.)', url: 'The click-through slot is `actionUrl`. It was renamed at 17 because `url` elsewhere on the platform means ' + '"HTTP endpoint to call" (`http` node, webhooks), a different concept from an in-app click target. ' + '`flow-node-notify-config-aliases` rewrites it at load; delete it once `actionUrl` carries the link. If ' + '`actionUrl` is also present with a DIFFERENT link, the conversion kept both rather than choosing where the ' - + 'notification points (#4923) — reconcile them onto `actionUrl`.', + + 'notification points — reconcile them onto `actionUrl`.', source: 'The click-through target is the flat PAIR `sourceObject` + `sourceId`, never a nested `source: { object, id }`. ' + '`flow-node-notify-config-aliases` lifts the nested shape at load and drops it once every part is accounted ' + 'for, so a surviving `source` means a part of it holds a DIFFERENT value from the flat `sourceObject` / ' - + '`sourceId` already in that slot, and the conversion declined to pick (#4923) — reconcile onto the flat pair ' + + '`sourceId` already in that slot, and the conversion declined to pick — reconcile onto the flat pair ' + 'and delete `source`. ' + 'Note the pair only takes effect together: a half-specified target is dropped so the inbox never renders a ' + 'dead link.', diff --git a/packages/spec/src/automation/node-executor.zod.ts b/packages/spec/src/automation/node-executor.zod.ts index 84c944342d..3d8812711d 100644 --- a/packages/spec/src/automation/node-executor.zod.ts +++ b/packages/spec/src/automation/node-executor.zod.ts @@ -340,12 +340,12 @@ export const ActionDescriptorSchema = lazySchema(() => z.object({ * it took the remove leg of the same ruling rather than the enforce leg. */ isAsync: retiredKey( - '`ActionDescriptor.isAsync` was removed in @objectstack/spec 17 (#6748, ADR-0049) — ' + + '`ActionDescriptor.isAsync` was removed in @objectstack/spec 17 (ADR-0049) — ' + 'no execution path ever read it, so declaring it never made a node suspend and ' + 'omitting it never stopped one. Delete the key. The live mechanism is two-part: an ' + 'executor suspends by RETURNING `suspend: true` from `execute()`, and its descriptor ' + 'must declare `supportsPause: true` (plus the `resumeAuthority` its pauses need) or ' + - 'the engine refuses that suspension (#6667). Declaring `isAsync: true` alongside ' + + 'the engine refuses that suspension. Declaring `isAsync: true` alongside ' + '`supportsPause: true` was always redundant; declaring it alone was always inert.', ), @@ -432,7 +432,7 @@ export const ActionDescriptorSchema = lazySchema(() => z.object({ * by the person who made it. */ resumeAuthority: z.enum(['any', 'service']).optional() - .describe("Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Carries no schema default so an omission stays observable — and an omission is fail-CLOSED at run time, equivalent to 'service': a pausing node whose pause is open to the generic route must declare 'any' explicitly (#5561)"), + .describe("Who may resume a run this node suspended: 'any' (the generic resume route) or 'service' (only the owning service, e.g. approvals). Carries no schema default so an omission stays observable — and an omission is fail-CLOSED at run time, equivalent to 'service': a pausing node whose pause is open to the generic route must declare 'any' explicitly"), /** * Runtime maturity of the capability behind this descriptor (ADR-0041 §4). diff --git a/packages/spec/src/automation/schemaless-node-config.test.ts b/packages/spec/src/automation/schemaless-node-config.test.ts index 105306082c..88d315276f 100644 --- a/packages/spec/src/automation/schemaless-node-config.test.ts +++ b/packages/spec/src/automation/schemaless-node-config.test.ts @@ -83,7 +83,7 @@ describe('ScriptConfigSchema (#4343 — converged to a function call)', () => { // The tombstone's payload is the prescription, not "unrecognized key" — // this string IS the upgrade doc for whoever hits it (retired-key.ts). expect(message).toContain(`\`script.config.${key}\``); - expect(message).toMatch(/#4343/); + expect(message).toMatch(/was removed in @objectstack\/spec 17/); expect(message).toMatch(/os migrate meta --from 16/); expect(result.error!.issues[0]!.path).toEqual([key]); }); @@ -196,8 +196,10 @@ describe('unknown keys — closed at #4001 批 9, and this class had no other ga // suppresses the rename, so the assertion is as much about what is ABSENT. const message = unknownKeyMessage(DecisionConfigSchema, { condition: "amount > 100000" })!; expect(message).toContain('this decision node config'); - expect(message).toContain('#4414'); + expect(message).toContain('double-declaration'); expect(message).toContain('OUT-EDGES'); + expect(message, 'the prescription names the double declaration, never a tracker id') + .not.toMatch(/#\d{3,5}/); expect(message).not.toContain('`condition` → `conditions`'); }); diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index 7334de5368..590e097113 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -109,8 +109,8 @@ import { strictObject } from '../shared/strict-object'; * for `script` / `subflow` / `decision`, what NOTHING else was catching. */ const SCHEMALESS_NODE_CONFIG_HISTORY = - 'Until #4001 an undeclared key here was dropped in silence at every layer: these node types publish no ' - + "descriptor `configSchema`, so `registerFlow()`'s undeclared-key rejection (#4277) structurally skips them, " + 'Until this shape was closed, an undeclared key here was dropped in silence at every layer: these node types publish no ' + + "descriptor `configSchema`, so `registerFlow()`'s undeclared-key rejection structurally skips them, " + 'and the execute-time parse checked only types and requiredness.'; /** @@ -132,15 +132,15 @@ const SCHEMALESS_NODE_CONFIG_HISTORY = */ const SCRIPT_KEY_GUIDANCE: Readonly> = { functionName: - 'The callable reference is `function` (#1870). `functionName` was the AI/template-emitted alias, rewritten at ' + 'The callable reference is `function`. `functionName` was the AI/template-emitted alias, rewritten at ' + 'load by the ADR-0087 D2 conversion `flow-node-script-config-aliases`. If `function` is also present, the two ' - + 'name DIFFERENT callables and the conversion kept both rather than picking which one runs (#4923) — decide ' + + 'name DIFFERENT callables and the conversion kept both rather than picking which one runs — decide ' + 'which it is, put it on `function`, and delete `functionName`.', input: 'The input map on a `script` node is `inputs` (plural). The singular `input` leaked in from ' + "`connector_action`, where `connectorConfig.input` is a DIFFERENT and canonical surface — do not \"fix\" that " + 'one. `flow-node-script-config-aliases` rewrites this key at load; delete it once `inputs` carries the values. ' - + 'If `inputs` is also present with DIFFERENT values, the conversion kept both rather than choosing (#4923) — ' + + 'If `inputs` is also present with DIFFERENT values, the conversion kept both rather than choosing — ' + 'reconcile them onto `inputs`.', }; @@ -148,9 +148,9 @@ const SCRIPT_KEY_GUIDANCE: Readonly> = { const SUBFLOW_KEY_GUIDANCE: Readonly> = { flow: 'The invoked flow is named by `flowName`. `flow` was an undeclared executor fallback that no schema or form ' - + 'ever described; it graduated into the ADR-0087 D2 conversion `flow-node-subflow-flow-alias` (#4278), which ' + + 'ever described; it graduated into the ADR-0087 D2 conversion `flow-node-subflow-flow-alias`, which ' + 'rewrites it at load. If `flowName` is also present, the two name DIFFERENT flows and the conversion kept ' - + 'both rather than picking which one this step invokes (#4923) — decide which it is, put it on `flowName`, ' + + 'both rather than picking which one this step invokes — decide which it is, put it on `flowName`, ' + 'and delete `flow`.', timeoutMs: "A subflow step's timeout is the engine's per-node guard, so it belongs on the NODE, not in its config: " @@ -177,11 +177,11 @@ const SUBFLOW_KEY_GUIDANCE: Readonly> = { const DECISION_KEY_GUIDANCE: Readonly> = { condition: 'Nothing reads `config.condition` on a `decision`: the key is the trigger gate on a `start` node and is inert ' - + 'on every other node type (#4414), so a predicate written here never gates anything — it is still ' + + 'on every other node type, so a predicate written here never gates anything — it is still ' + 'parse-validated at registration, which is why a malformed one is caught and an INERT one was not. Branching ' + 'lives on the OUT-EDGES: give each branch its own `condition` and mark the fallback `isDefault: true`. Do not ' + 'reach for the plural `conditions` here on the strength of the spelling — declaring branches here AND on the ' - + 'edges is the double-declaration #4414 was filed for. If the edges already carry the predicate, delete this key.', + + 'edges is the double-declaration this guidance exists to stop. If the edges already carry the predicate, delete this key.', }; // ─── script ────────────────────────────────────────────────────────── @@ -252,7 +252,7 @@ export const ScriptConfigSchema = lazySchema(() => strictObject({ // one rename: real messaging is `notify`, Slack is a connector, and inline // logic belongs in a registered function. actionType: retiredKey( - '`script.config.actionType` was removed in @objectstack/spec 17 (#4343) — none of its values ' + '`script.config.actionType` was removed in @objectstack/spec 17 — none of its values ' + 'did what it said. The two built-ins were logger-backed stubs that recorded the intent and ' + 'delivered nothing under any configuration, and every other value was a second spelling of ' + '`config.function`. Replace it per branch: for `email` use a `notify` node (it delivers ' @@ -264,7 +264,7 @@ export const ScriptConfigSchema = lazySchema(() => strictObject({ + 'case into `config.function`; the stub and marker values are removed.', ), template: retiredKey( - '`script.config.template` was removed in @objectstack/spec 17 (#4343) — it fed only the ' + '`script.config.template` was removed in @objectstack/spec 17 — it fed only the ' + 'logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template ' + 'id was ever resolved. Delete the key. A `notify` node carries its own `title`/`message`, and ' + 'stored templates live in the messaging service (`sys_notification_template`), not on the ' @@ -272,20 +272,20 @@ export const ScriptConfigSchema = lazySchema(() => strictObject({ + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), recipients: retiredKey( - '`script.config.recipients` was removed in @objectstack/spec 17 (#4343) — the addresses were ' + '`script.config.recipients` was removed in @objectstack/spec 17 — the addresses were ' + 'logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a ' + '`notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging ' + 'service for real. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), variables: retiredKey( - '`script.config.variables` was removed in @objectstack/spec 17 (#4343) — it injected values ' + '`script.config.variables` was removed in @objectstack/spec 17 — it injected values ' + 'into a template no side effect ever rendered. Delete the key. A `notify` node carries ' + 'structured data in `payload`; a registered function takes it in `config.inputs`. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), script: retiredKey( - '`script.config.script` was removed in @objectstack/spec 17 (#4343) — the built-in runtime has ' + '`script.config.script` was removed in @objectstack/spec 17 — the built-in runtime has ' + 'no server-side JS sandbox, so an inline body was recognized and never executed: the node ' + 'warned and completed as a no-op. Move the logic into a registered function ' + '(`defineStack({ functions })`) and name it in `config.function`. ' @@ -363,7 +363,7 @@ export const DecisionConditionSchema = lazySchema(() => strictObject({ + "projects it from the node's out-edges and applies edits back to them; it is never stored on the branch. " + "Route by making this branch's `label` match an out-edge's `label` exactly (a label nothing claims cannot " + 'route: traversal falls back to considering every out-edge, and `os validate` reports it as ' - + '`flow-branch-label-unmatched`, #4414).', + + '`flow-branch-label-unmatched`).', }, }, { /** Branch label — must match an out-edge's `label` to route anywhere. */ diff --git a/packages/spec/src/automation/state-machine.zod.ts b/packages/spec/src/automation/state-machine.zod.ts index 9af4382c74..c91f1315db 100644 --- a/packages/spec/src/automation/state-machine.zod.ts +++ b/packages/spec/src/automation/state-machine.zod.ts @@ -69,7 +69,7 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; * description of it, so the rejections cannot drift apart. */ const STATE_MACHINE_STRIP_HISTORY = - 'Until #4001 an undeclared key here was dropped silently — the machine still parsed, so a mistyped `on`/`entry`/`cond` produced a machine missing the very transition it was written to declare, reported as valid.'; + 'Until this shape was closed, an undeclared key here was dropped silently — the machine still parsed, so a mistyped `on`/`entry`/`cond` produced a machine missing the very transition it was written to declare, reported as valid.'; // --- Primitives --- diff --git a/packages/spec/src/automation/time-relative-trigger.zod.ts b/packages/spec/src/automation/time-relative-trigger.zod.ts index 50c43b95e8..c32412ae88 100644 --- a/packages/spec/src/automation/time-relative-trigger.zod.ts +++ b/packages/spec/src/automation/time-relative-trigger.zod.ts @@ -121,11 +121,11 @@ export const TimeRelativeTriggerSchema = lazySchema(() => 'expression: \'0 8 * * *\' } }`. Omitting it means daily at 08:00 UTC.', runAs: '`runAs` is a FLOW-level key, not part of the descriptor. A sweep has no trigger ' + - 'user, so under the default `runAs: \'user\'` its data operations are REFUSED ' + - '(#3760) — declare `runAs: \'system\'` beside `nodes`/`edges`.', + 'user, so under the default `runAs: \'user\'` its data operations are REFUSED' + + ' — declare `runAs: \'system\'` beside `nodes`/`edges`.', }, history: - 'Until #4001 these were dropped silently — the descriptor still parsed and the sweep ' + + 'Until this shape was closed, these were dropped silently — the descriptor still parsed and the sweep ' + 'still bound, so a mis-spelled window or filter produced a trigger that matched ' + 'nothing (or everything) while reporting itself as configured.', }, diff --git a/packages/spec/src/automation/webhook.test.ts b/packages/spec/src/automation/webhook.test.ts index 307d7f602e..8c58bcdc37 100644 --- a/packages/spec/src/automation/webhook.test.ts +++ b/packages/spec/src/automation/webhook.test.ts @@ -252,10 +252,10 @@ describe('unknown keys are rejected, not stripped (#4001 batch 11)', () => { .toContain('`events` → `triggers`'); }); - it('carries the #3494 removals as prescriptions, not bare rejections', () => { + it('carries the retired-knob removals as prescriptions, not bare rejections', () => { for (const key of ['body', 'payloadFields', 'includeSession', 'authentication', 'retryPolicy']) { const message = unknownKeyIssue({ ...valid, [key]: {} })!.message; - expect(message, `\`${key}\` should carry its #3494 reason`).toContain('#3494'); + expect(message, `\`${key}\` should carry its removal reason`).toContain(`\`${key}\` was removed`); } }); diff --git a/packages/spec/src/automation/webhook.zod.ts b/packages/spec/src/automation/webhook.zod.ts index 0cee0c3be1..b3bb03de33 100644 --- a/packages/spec/src/automation/webhook.zod.ts +++ b/packages/spec/src/automation/webhook.zod.ts @@ -153,15 +153,15 @@ export const WebhookSchema = lazySchema(() => strictObject({ guidance: { // #3494 removed these five and `retryPolicy` — the tombstone has to carry // the reason, or an author just writes them again one release later. - body: '`body` was removed in 17 (#3494) — the delivery path always sends its own fixed envelope, so a custom body was never sent. There is no replacement.', - payloadFields: '`payloadFields` was removed in 17 (#3494) — the delivery envelope is fixed and was never trimmed to a field list. There is no replacement.', - includeSession: '`includeSession` was removed in 17 (#3494) — session context was never included in a delivery. There is no replacement.', - authentication: '`authentication` was removed in 17 (#3494) — only HMAC signing via `secret` is applied to a delivery. Use `secret`.', - retryPolicy: '`retryPolicy` was removed in 17 (#3494) — delivery retries are owned by the messaging outbox on a fixed schedule, and the authored policy was never read. There is no replacement.', - tags: '`tags` was removed in 17 (#3494) — nothing read them. There is no replacement.', + body: '`body` was removed in 17 — the delivery path always sends its own fixed envelope, so a custom body was never sent. There is no replacement.', + payloadFields: '`payloadFields` was removed in 17 — the delivery envelope is fixed and was never trimmed to a field list. There is no replacement.', + includeSession: '`includeSession` was removed in 17 — session context was never included in a delivery. There is no replacement.', + authentication: '`authentication` was removed in 17 — only HMAC signing via `secret` is applied to a delivery. Use `secret`.', + retryPolicy: '`retryPolicy` was removed in 17 — delivery retries are owned by the messaging outbox on a fixed schedule, and the authored policy was never read. There is no replacement.', + tags: '`tags` was removed in 17 — nothing read them. There is no replacement.', }, history: - 'Until #4001 these were dropped silently — the webhook still parsed and still ' + + 'Until this shape was closed, these were dropped silently — the webhook still parsed and still ' + 'materialized, so a subscription scoped or secured with a key we do not declare ' + 'shipped listening to the wrong thing, or to everything.', // `WebhookConfigSchema` (integration/connector.zod.ts) is this shape diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index b22689b022..89896076da 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -445,7 +445,8 @@ describe('conversion layer (ADR-0087 D2)', () => { it('tombstones both keys so a source that skipped conversion is rejected, not stripped', () => { for (const bad of [{ timeoutMs: 60_000 }, { onTimeout: 'continue' }]) { const flow = (wecFlow({ eventType: 'timer', timerDuration: 'PT1M', ...bad }).flows as any[])[0]; - expect(() => FlowSchema.parse(flow), `${Object.keys(bad)[0]} must be rejected`).toThrow(/4158/); + expect(() => FlowSchema.parse(flow), `${Object.keys(bad)[0]} must be rejected`) + .toThrow(/was removed in @objectstack\/spec 17/); } }); }); @@ -550,7 +551,7 @@ describe('conversion layer (ADR-0087 D2)', () => { expect( () => ScriptConfigSchema.parse({ function: 'score_lead', ...bad }), `${key} must be rejected`, - ).toThrow(/4343/); + ).toThrow(/was removed in @objectstack\/spec 17/); } // The flow-level parse is deliberately blind here — pinned so the note // above stays true if `FlowNodeSchema.config` is ever tightened. diff --git a/packages/spec/src/data/analytics-strictness-batchd.test.ts b/packages/spec/src/data/analytics-strictness-batchd.test.ts index e991b0513f..b587b19820 100644 --- a/packages/spec/src/data/analytics-strictness-batchd.test.ts +++ b/packages/spec/src/data/analytics-strictness-batchd.test.ts @@ -151,10 +151,10 @@ describe('#4001 batch D — closed sites reject unknown keys where they live', ( // enforce-or-remove: no strategy ever read it), so the nested surface no // longer exists — the batch-D verdict for it is SUPERSEDED, not reopened. // The key itself now rejects with the retirement prescription: - it('`Metric.filters` — REMOVED (#10414); the key rejects with the prescription, not as a bare unknown', () => { + it('`Metric.filters` — REMOVED; the key rejects with the prescription, not as a bare unknown', () => { expect( reject(MetricSchema, { name: 'm', label: 'M', type: 'count', sql: '*', filters: [{ sql: 'x' }] }), - ).toContain('was removed in @objectstack/spec 17 (#10414'); + ).toContain('was removed in @objectstack/spec 17 (ADR-0049)'); }); it('`Dimension` — through the cube `dimensions` record', () => { @@ -245,8 +245,8 @@ describe('#4001 batch D — the strict base does not break the request wrapper', }); it('the #3878 tombstones still fire — `query` and `format` carry their migration text', () => { - expect(reject(AnalyticsQueryRequestSchema, { ...QUERY, query: { measures: ['count'] } })).toContain('#3878'); - expect(reject(AnalyticsQueryRequestSchema, { ...QUERY, format: 'csv' })).toContain('#3878'); + expect(reject(AnalyticsQueryRequestSchema, { ...QUERY, query: { measures: ['count'] } })).toContain('was removed from AnalyticsQueryRequest'); + expect(reject(AnalyticsQueryRequestSchema, { ...QUERY, format: 'csv' })).toContain('was removed from AnalyticsQueryRequest'); }); }); diff --git a/packages/spec/src/data/analytics.test.ts b/packages/spec/src/data/analytics.test.ts index 9eb4deeadb..7ec490b285 100644 --- a/packages/spec/src/data/analytics.test.ts +++ b/packages/spec/src/data/analytics.test.ts @@ -93,7 +93,7 @@ describe('MetricSchema', () => { type: 'avg', sql: 'order_total', filters: [{ sql: "status = 'completed'" }], - })).toThrow(/`measures\.\.filters`.*removed in @objectstack\/spec 17 \(#10414.*os migrate meta --from 17/s); + })).toThrow(/`measures\.\.filters`.*removed in @objectstack\/spec 17 \(.*os migrate meta --from 17/s); }); it('should apply defaults for optional fields', () => { diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index d55ff7cfdb..150d203d41 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -68,7 +68,7 @@ export type TimeUpdateInterval = z.input; export const MetricSchema = lazySchema(() => strictObject( { surface: 'this metric', - history: 'Until #4001 batch D an undeclared metric key was silently dropped — the cube ' + history: 'Until this shape was closed, an undeclared metric key was silently dropped — the cube ' + 'registered and the metric computed as if the key had never been written.', // `title` is CORRECT one level up (`CubeSchema.title`); a metric spells it `label`. aliases: { title: 'label' }, @@ -84,7 +84,7 @@ export const MetricSchema = lazySchema(() => strictObject( // `strictObject` the key carried (closed by #4001 batch D) is gone with // it — strictness on a shape nothing reads was fake compliance either way. filters: - '`measures..filters` was removed in @objectstack/spec 17 (#10414, ADR-0049) — ' + '`measures..filters` was removed in @objectstack/spec 17 (ADR-0049) — ' + 'it never had an effect: no strategy read it (NativeSQLStrategy and ObjectQLStrategy ' + 'both aggregate the metric\'s `sql` and ignore `filters`), so an authored ' + '`filters: [{ sql: … }]` parsed clean and the query returned the UNFILTERED aggregate. ' @@ -125,7 +125,7 @@ export const MetricSchema = lazySchema(() => strictObject( export const DimensionSchema = lazySchema(() => strictObject( { surface: 'this dimension', - history: 'Until #4001 batch D an undeclared dimension key was silently dropped.', + history: 'Until this shape was closed, an undeclared dimension key was silently dropped.', aliases: { // `title` is CORRECT one level up (`CubeSchema.title`); a dimension spells it `label`. title: 'label', @@ -160,7 +160,7 @@ export const DimensionSchema = lazySchema(() => strictObject( export const CubeJoinSchema = lazySchema(() => strictObject( { surface: 'this cube join', - history: 'Until #4001 batch D an undeclared join key was silently dropped — a typo\'d ' + history: 'Until this shape was closed, an undeclared join key was silently dropped — a typo\'d ' + '`relationship` fell back to the `many_to_one` default.', // The join condition is spelled `sql` here (its doc says "ON clause"). aliases: { on: 'sql' }, @@ -197,7 +197,7 @@ export const CubeJoinSchema = lazySchema(() => strictObject( export const CubeSchema = lazySchema(() => strictObject( { surface: 'this cube', - history: 'Until #4001 batch D an undeclared cube key was silently dropped — the cube ' + history: 'Until this shape was closed, an undeclared cube key was silently dropped — the cube ' + 'registered without it and the analytics service served whatever remained.', aliases: { // `label` is the metric/dimension spelling; the cube itself uses `title`. @@ -226,7 +226,7 @@ export const CubeSchema = lazySchema(() => strictObject( refreshKey: strictObject( { surface: 'this cube refreshKey block', - history: 'Until #4001 batch D an undeclared refreshKey key was silently dropped — ' + history: 'Until this shape was closed, an undeclared refreshKey key was silently dropped — ' + 'a typo\'d `sql` probe left the cube refreshing on nothing.', }, { @@ -261,7 +261,7 @@ export const CubeSchema = lazySchema(() => strictObject( export const AnalyticsQuerySchema = lazySchema(() => strictObject( { surface: 'this analytics query', - history: 'Until #4001 batch D an undeclared key here was silently dropped at every door ' + history: 'Until this shape was closed, an undeclared key here was silently dropped at every door ' + 'except the strict `/analytics/query` wrapper.', // The sibling record dialect (`data/query.zod.ts` `BaseQuerySchema`) spells // sorting `orderBy`; the analytics dialect spells it `order`. @@ -272,7 +272,7 @@ export const AnalyticsQuerySchema = lazySchema(() => strictObject( // the `triggerPhrase` lesson in strict-object.ts). filters: '`filters` is not an AnalyticsQuery field — use `where` (canonical Query DSL ' + 'FilterCondition, the same shape find() takes). There is no per-metric filter key ' - + 'either (#10414): fold the condition into the metric\'s own `sql` expression, or use ' + + 'either: fold the condition into the metric\'s own `sql` expression, or use ' + 'an ADR-0021 dataset measure\'s structured `filter`.', }, // No `extraKeys`: the one extension (`AnalyticsQueryRequestSchema`) adds @@ -309,7 +309,7 @@ export const AnalyticsQuerySchema = lazySchema(() => strictObject( timeDimensions: z.array(strictObject( { surface: 'this time dimension', - history: 'Until #4001 batch D an undeclared key here was silently stripped even at the ' + history: 'Until this shape was closed, an undeclared key here was silently stripped even at the ' + 'strict `/analytics/query` door — top-level strictness does not recurse.', // The plural is the cube DIMENSION's declaration key; a query's time // dimension takes exactly one `granularity`. diff --git a/packages/spec/src/data/data-engine.test.ts b/packages/spec/src/data/data-engine.test.ts index 85e75a2cb7..83c4827a38 100644 --- a/packages/spec/src/data/data-engine.test.ts +++ b/packages/spec/src/data/data-engine.test.ts @@ -201,7 +201,7 @@ describe('DataEngineUpdateOptionsSchema', () => { DataEngineUpdateOptionsSchema.parse({ upsert: true, }), - ).toThrow(/`update\.options\.upsert` was removed .*#8057, ADR-0049.*Delete the key/s); + ).toThrow(/`update\.options\.upsert` was removed .*ADR-0049.*Delete the key/s); }); it('parses cleanly without the retired flag, and never produces the key', () => { @@ -465,7 +465,7 @@ describe('EngineUpdateOptionsSchema', () => { where: { status: 'inactive' }, upsert: true, }), - ).toThrow(/`update\.options\.upsert` was removed .*#8057, ADR-0049.*Delete the key/s); + ).toThrow(/`update\.options\.upsert` was removed .*ADR-0049.*Delete the key/s); }); it('never produces the retired key on a clean parse', () => { diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index 621c82e863..22a38a55b4 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -219,11 +219,11 @@ export const DataEngineInsertOptionsSchema = lazySchema(() => BaseEngineOptionsS * `BatchOptions.validateOnly` / `ListNotificationsRequest.cursor` disposition). */ export const ENGINE_UPDATE_UPSERT_REMOVED = - '`update.options.upsert` was removed in @objectstack/spec 17 (#8057, ADR-0049) — it was ' + '`update.options.upsert` was removed in @objectstack/spec 17 (ADR-0049) — it was ' + 'declared and allowlisted but never implemented: no engine or driver path ever read it, so ' + '`{ upsert: true }` was accepted and silently dropped and the update stayed a plain update. ' + 'Delete the key. Express create-if-absent explicitly: a by-id update whose id names no row ' - + "throws RECORD_NOT_FOUND (#7867's not-found gate) rather than inserting, so read the row " + + 'throws RECORD_NOT_FOUND (the by-id not-found gate) rather than inserting, so read the row ' + 'first (`findOne`) and call `insert` or `update` on what you find. A first-class upsert, if ' + 'ever built, must reconcile with that gate by design rather than through this silent flag.'; @@ -298,8 +298,8 @@ export const DroppedFieldsEventSchema = lazySchema(() => z.object({ * the same register as the two read-only arms — each answers "what about this * FIELD caused the strip?". */ - reason: z.enum(['readonly', 'readonly_when', 'primary_key']).describe('Why the fields were dropped: static readonly (#2948), a TRUE readonlyWhen predicate (#3042), or the primary-key strip of a payload id the engine ruled is not an identifier (#6437)'), -}).describe('A write-path strip event: caller-supplied fields legally dropped from the payload (#3407)')); + reason: z.enum(['readonly', 'readonly_when', 'primary_key']).describe('Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, or the primary-key strip of a payload id the engine ruled is not an identifier'), +}).describe('A write-path strip event: caller-supplied fields legally dropped from the payload')); // -------------------------------------------------------------------------- // Legacy: DataEngineUpdateOptionsSchema (DEPRECATED) diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index 30849e3fe3..c004d5ab8c 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -41,7 +41,7 @@ import { resolveDriverId, validateDriverConfig } from './driver/config-registry. */ const CAPABILITIES_REMOVED_PREFIX = - '`datasource.capabilities` was removed in @objectstack/spec 17.0.0 (#4583, ADR-0049) — ' + '`datasource.capabilities` was removed in @objectstack/spec 17.0.0 (ADR-0049) — ' + 'all eleven flags were declared, strict-guarded and read by nobody. '; /** @@ -66,8 +66,8 @@ const RETIRED_CAPABILITIES: Record = { + 'so a datasource labelled a read replica accepted writes exactly like any other. The one ' + 'enforced datasource-wide write gate is `external.allowWrites: false`, and it applies ONLY ' + 'to a federated datasource (`schemaMode` other than `managed`) — for a managed datasource ' - + 'there is no read-only gate at all, so delete the key rather than trusting it. #4584 ' - + 'settled that this stays so ON PURPOSE: grant the connection SELECT-only at the database ' + + 'there is no read-only gate at all, so delete the key rather than trusting it. This ' + + 'stays so ON PURPOSE: grant the connection SELECT-only at the database ' + '(`GRANT SELECT`), which no direct connection, migration or DDL can talk past — an ' + 'application-layer flag holds in the ObjectQL path only, and one that looks like a boundary ' + 'without being one is worse than none.', @@ -84,7 +84,7 @@ const RETIRED_CAPABILITIES: Record = { */ const RETIRED_DATASOURCE_BLOCKS: Record = { retryPolicy: - '`datasource.retryPolicy` was removed in @objectstack/spec 17.0.0 (#4583, ADR-0049) — no ' + '`datasource.retryPolicy` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'connect or query path ever retried on it. Connection failure is handled by the boot ' + 'policy in the datasource connection service (degraded boot, or `bootCritical` fail-fast), ' + 'which does not retry on a schedule. Delete the block. ' @@ -94,7 +94,7 @@ const RETIRED_DATASOURCE_BLOCKS: Record = { + 'actually want that hook or job retried. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', healthCheck: - '`datasource.healthCheck` was removed in @objectstack/spec 17.0.0 (#4583, ADR-0049) — no ' + '`datasource.healthCheck` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'health-check loop ever read it, so `enabled: true` scheduled nothing and the two timeouts ' + 'bounded nothing. Connection liveness is probed ON DEMAND through the driver handle ' + '(`ping()` / `checkHealth()`), which the datasource admin service calls for "Test ' @@ -102,12 +102,12 @@ const RETIRED_DATASOURCE_BLOCKS: Record = { + 'which checks SCHEMA DRIFT — a different concern, not a liveness probe. Delete the block. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', externalLabel: - '`external.label` was removed in @objectstack/spec 17.0.0 (#4583, ADR-0049) — nothing read ' + '`external.label` was removed in @objectstack/spec 17.0.0 (ADR-0049) — nothing read ' + "the federation block's own label. Use the datasource's TOP-LEVEL `label`, which is what " + 'Setup → Datasources actually renders. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', externalRequirePermission: - '`external.requirePermission` was removed in @objectstack/spec 17.0.0 (#4583, ADR-0049) — no ' + '`external.requirePermission` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'authorization check ever consulted it, so a permission named here gated nothing. Access to ' + "a federated datasource's data is governed by the ordinary object permission sets and RLS, " + 'exactly as for a managed datasource. Naming a permission that is never required is the ' @@ -165,7 +165,7 @@ const belongsInConfig = (key: string, canonical: string = key) => * rename onto a key that is also gone. */ const RETIRED_READ_REPLICAS = - '`datasource.readReplicas` was removed in @objectstack/spec 17.0.0 (#4468, ADR-0049) — ' + '`datasource.readReplicas` was removed in @objectstack/spec 17.0.0 (ADR-0049) — ' + 'it described replica connections nothing ever opened: no driver reads the key, and no ' + 'query path separates reads from writes, so every statement always went to the primary. ' + 'Delete the key. There is no read-replica routing to migrate to — if your database fronts ' @@ -195,7 +195,7 @@ export const DriverDefinitionSchema = lazySchema(() => strictObject( capabilities: RETIRED_CAPABILITIES.capabilities, capability: RETIRED_CAPABILITIES.capabilities, }, - history: 'Until #4001 these were dropped silently — the driver still registered.', + history: 'Until this shape was closed, these were dropped silently — the driver still registered.', }, { id: z.string().describe('Unique driver identifier (e.g. "postgres")'), @@ -285,7 +285,7 @@ export const ExternalDatasourceSettingsSchema = strictObject( + 'enforced datasource-wide write gate (checked by the ObjectQL engine before any write ' + 'to a federated datasource).', }, - history: 'Until #4001 these were dropped silently — federation ran on the defaults instead.', + history: 'Until this shape was closed, these were dropped silently — federation ran on the defaults instead.', }, { allowedSchemas: z.array(z.string()).optional() @@ -304,7 +304,7 @@ export const ExternalDatasourceSettingsSchema = strictObject( checkinterval: 'checkIntervalMs', }, history: - 'Until #4001 these were dropped silently — drift checking ran on the defaults ' + 'Until this shape was closed, these were dropped silently — drift checking ran on the defaults ' + '(fail on mismatch, check at boot) regardless of what was written.', }, { @@ -318,7 +318,7 @@ export const ExternalDatasourceSettingsSchema = strictObject( .default({ onMismatch: 'fail', checkOnBoot: true }).describe('Boot/drift validation policy'), credentialsRef: z.string().optional() .describe('Reference into the secrets store; never inline credentials. ' - + 'Valid in every schemaMode — the one `external` key a managed datasource may carry (#8153).'), + + 'Valid in every schemaMode — the one `external` key a managed datasource may carry.'), queryTimeoutMs: z.number().default(30_000) .describe('Hard cap on per-query execution time.'), }) @@ -377,7 +377,7 @@ export type ExternalDatasourceSettingsParsed = z.infer strictObject( healthcheck: RETIRED_DATASOURCE_BLOCKS.healthCheck, }, history: - 'Until #4001 these were dropped silently — a connection key written one level too high ' + 'Until this shape was closed, these were dropped silently — a connection key written one level too high ' + 'left the datasource connecting on driver defaults rather than failing.', }, { @@ -574,7 +574,7 @@ export const DatasourceSchema = lazySchema(() => strictObject( acquiretimeoutmillis: 'connectionTimeoutMillis', }, history: - 'Until #4001 these were dropped silently — the pool ran on its defaults (min 0, max 10) ' + 'Until this shape was closed, these were dropped silently — the pool ran on its defaults (min 0, max 10) ' + 'no matter what was written. Note both timeouts end in `Millis`, not `Ms`.', }, { @@ -615,7 +615,7 @@ export const DatasourceSchema = lazySchema(() => strictObject( + '`rejectUnauthorized: false` — deliberately, and never against a production database.', }, history: - 'Until #4001 these were dropped silently — which meant a TLS setting that never took ' + 'Until this shape was closed, these were dropped silently — which meant a TLS setting that never took ' + 'effect looked identical to one that did.', }, { diff --git a/packages/spec/src/data/driver-nosql.zod.ts b/packages/spec/src/data/driver-nosql.zod.ts index b763299ff1..b5d81c7254 100644 --- a/packages/spec/src/data/driver-nosql.zod.ts +++ b/packages/spec/src/data/driver-nosql.zod.ts @@ -422,7 +422,7 @@ export const NoSQLIndexSchema = lazySchema(() => z.object({ * `UniqueScopeSchema` here (import it — never fork the union) in the same * change. */ - unique: z.boolean().default(false).describe("Enforce uniqueness over exactly the listed `fields`. Boolean on purpose — no ADR-0120 scope vocabulary here: this is the raw driver-descriptor layer, below tenancy. The 'organization'/'global' boundary is stated on the authorable surfaces (FieldSchema.unique, IndexSchema.unique) and resolved into physical key columns before a descriptor like this is built, so an organization key part, when there is one, is already a listed field (#11215)"), + unique: z.boolean().default(false).describe("Enforce uniqueness over exactly the listed `fields`. Boolean on purpose — no ADR-0120 scope vocabulary here: this is the raw driver-descriptor layer, below tenancy. The 'organization'/'global' boundary is stated on the authorable surfaces (FieldSchema.unique, IndexSchema.unique) and resolved into physical key columns before a descriptor like this is built, so an organization key part, when there is one, is already a listed field"), /** * Sparse index (only index documents with the field) diff --git a/packages/spec/src/data/driver.test.ts b/packages/spec/src/data/driver.test.ts index b7a7cff9c3..7c6ec69eeb 100644 --- a/packages/spec/src/data/driver.test.ts +++ b/packages/spec/src/data/driver.test.ts @@ -90,7 +90,7 @@ describe('[#4634] the 31 inert capability bits are tombstoned, not stripped', () it('the streaming prescription carries the #4484 findStream story and the paged-find fix', () => { expect(() => DriverCapabilitiesSchema.parse({ streaming: true })).toThrow( - /DriverCapabilities\.streaming.*removed.*findStream.*#4484.*`find\(\)` with `limit`\/`offset`.*Delete the key/s, + /DriverCapabilities\.streaming.*removed.*findStream.*`find\(\)` with `limit`\/`offset`.*Delete the key/s, ); }); diff --git a/packages/spec/src/data/driver.zod.ts b/packages/spec/src/data/driver.zod.ts index 88e94f53cf..1fa8913aee 100644 --- a/packages/spec/src/data/driver.zod.ts +++ b/packages/spec/src/data/driver.zod.ts @@ -303,9 +303,9 @@ export const DriverCapabilitiesSchema = lazySchema(() => z.object({ 'No geospatial query path exists in the platform — declaring the bit advertised a ' + 'capability nothing delivers.')), streaming: retiredKey( - '`DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (#4634, ADR-0049 ' + '`DriverCapabilities.streaming` was removed in @objectstack/spec 17.0.0 (ADR-0049 ' + 'enforce-or-remove) — no code in any repository ever read it, and `findStream`, the only ' - + 'read this bit could describe, was itself removed in 17.0.0 (#4484): nothing ever called ' + + 'read this bit could describe, was itself removed in 17.0.0: nothing ever called ' + 'it, and two of its three implementations materialised the entire result set before ' + 'yielding. The bit carried the same defect one level up (`SqlDriver` implemented ' + '`findStream` yet declared `streaming: false`; `InMemoryDriver` declared `true` over a ' diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts index 84f28a769c..2256776311 100644 --- a/packages/spec/src/data/driver/common.zod.ts +++ b/packages/spec/src/data/driver/common.zod.ts @@ -27,7 +27,7 @@ import { z } from 'zod'; * applies only non-destructive alters (relax NOT NULL, widen varchar). */ export const SqlAutoMigrateSchema = z.enum(['off', 'safe']) - .describe('Dev-only non-destructive schema self-heal (#2186)'); + .describe('Dev-only non-destructive schema self-heal'); export type SqlAutoMigrate = z.input; @@ -690,7 +690,7 @@ export function refusedInlineCredentialKey(key: string, formTitle: string) { return z.never({ error: () => INLINE_CREDENTIAL_REFUSED(key) }).optional() .describe( "Set through the connection form's secret field or `external.credentialsRef` — " - + 'encrypted into `sys_secret`, never stored in `config` (#7990)', + + 'encrypted into `sys_secret`, never stored in `config`', ) .meta({ title: formTitle, format: 'password' }); } diff --git a/packages/spec/src/data/driver/driver-credential-refusal.test.ts b/packages/spec/src/data/driver/driver-credential-refusal.test.ts index 7dd80e8830..2d1389b673 100644 --- a/packages/spec/src/data/driver/driver-credential-refusal.test.ts +++ b/packages/spec/src/data/driver/driver-credential-refusal.test.ts @@ -722,7 +722,7 @@ describe('datasource — bound credentialsRef + user-less mongo url refused (#90 const result = parse(ds); if (result.success) return undefined; return result.error.issues.find( - (i) => i.path.join('.') === 'config.url' && i.message.includes('#9041'), + (i) => i.path.join('.') === 'config.url' && i.message.includes("the URL's own userinfo"), ); }; @@ -830,7 +830,7 @@ describe('datasource — bound credentialsRef + user-less mongo url refused (#90 external: { ...BOUND }, }); expect(unnamed.success).toBe(false); - expect(unnamed.error!.issues.some((i) => i.message.includes('#9041'))).toBe(false); + expect(unnamed.error!.issues.some((i) => i.message.includes("the URL's own userinfo"))).toBe(false); expect(unnamed.error!.issues.some((i) => i.path.join('.') === 'config.url')).toBe(false); }); @@ -868,7 +868,7 @@ describe('datasource — bound credentialsRef + user-less mongo url refused (#90 // The driver-config parse reports the type error at the same path; the // #9041 refusal stays silent rather than judging a value that has no // userinfo to read. - expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(false); + expect(result.error!.issues.some((i) => i.message.includes("the URL's own userinfo"))).toBe(false); }); it('composes with the #9040 passthrough refusal — one artefact, both findings, own paths', () => { @@ -888,7 +888,7 @@ describe('datasource — bound credentialsRef + user-less mongo url refused (#90 const paths = result.error!.issues.map((i) => i.path.join('.')); expect(paths).toContain('config.url'); expect(paths).toContain('config.options.auth.password'); - expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(true); + expect(result.error!.issues.some((i) => i.message.includes("the URL's own userinfo"))).toBe(true); expect(result.error!.issues.some((i) => i.message.includes('#9040'))).toBe(true); }); @@ -920,7 +920,7 @@ describe('datasource — bound credentialsRef + user-less mongo url refused (#90 }); expect(result.success).toBe(false); expect(result.error!.issues.some((i) => i.message.includes('#8082'))).toBe(true); - expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(false); + expect(result.error!.issues.some((i) => i.message.includes("the URL's own userinfo"))).toBe(false); }); }); @@ -955,7 +955,7 @@ describe('datasource — bound credentialsRef + composed mongo config naming no const result = parse(ds); if (result.success) return undefined; return result.error.issues.find( - (i) => i.path.join('.') === 'config.username' && i.message.includes('#9147'), + (i) => i.path.join('.') === 'config.username' && i.message.includes("add `username` to `config`"), ); }; @@ -1016,8 +1016,8 @@ describe('datasource — bound credentialsRef + composed mongo config naming no external: { ...BOUND }, }); expect(result.success).toBe(false); - expect(result.error!.issues.some((i) => i.message.includes('#9147'))).toBe(true); - expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(false); + expect(result.error!.issues.some((i) => i.message.includes("add `username` to `config`"))).toBe(true); + expect(result.error!.issues.some((i) => i.message.includes("the URL's own userinfo"))).toBe(false); }); // ── the fence: each single condition absent is still ACCEPTED ───────────── @@ -1045,8 +1045,8 @@ describe('datasource — bound credentialsRef + composed mongo config naming no external: { ...BOUND }, }); expect(result.success).toBe(false); - expect(result.error!.issues.some((i) => i.message.includes('#9041'))).toBe(true); - expect(result.error!.issues.some((i) => i.message.includes('#9147'))).toBe(false); + expect(result.error!.issues.some((i) => i.message.includes("the URL's own userinfo"))).toBe(true); + expect(result.error!.issues.some((i) => i.message.includes("add `username` to `config`"))).toBe(false); }); it('near-miss ② a discrete `username` present — the branch where the bound secret is LIVE (#8696)', () => { @@ -1105,7 +1105,7 @@ describe('datasource — bound credentialsRef + composed mongo config naming no // The driver-config parse reports the type error at the same path; this // refusal stays silent rather than judging a value with no branch to predict. expect(result.error!.issues.some((i) => i.path.join('.') === 'config.username')).toBe(true); - expect(result.error!.issues.some((i) => i.message.includes('#9147'))).toBe(false); + expect(result.error!.issues.some((i) => i.message.includes("add `username` to `config`"))).toBe(false); }); it('composes with the #9040 passthrough refusal — one artefact, both findings, own paths', () => { @@ -1119,7 +1119,7 @@ describe('datasource — bound credentialsRef + composed mongo config naming no const paths = result.error!.issues.map((i) => i.path.join('.')); expect(paths).toContain('config.username'); expect(paths).toContain('config.options.auth.password'); - expect(result.error!.issues.some((i) => i.message.includes('#9147'))).toBe(true); + expect(result.error!.issues.some((i) => i.message.includes("add `username` to `config`"))).toBe(true); expect(result.error!.issues.some((i) => i.message.includes('#9040'))).toBe(true); }); }); diff --git a/packages/spec/src/data/driver/memory.zod.ts b/packages/spec/src/data/driver/memory.zod.ts index 04a1cfdeea..f5a2a21311 100644 --- a/packages/spec/src/data/driver/memory.zod.ts +++ b/packages/spec/src/data/driver/memory.zod.ts @@ -48,7 +48,7 @@ import { * header, would have silently dropped this file's published description. */ const PERSISTENCE_HISTORY = - "Until #4410 nothing parsed `datasource.config` at all, so an unrecognised key here was " + "Until this shape was closed, nothing parsed `datasource.config` at all, so an unrecognised key here was " + 'accepted in silence and the requested persistence mode came up on its defaults instead.'; /** @@ -279,7 +279,7 @@ export const MemoryConfigSchema = lazySchema(() => strictObject( readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, }, history: - 'Until #4410 nothing validated `datasource.config` at all — an unrecognised key was accepted ' + 'Until this shape was closed, nothing validated `datasource.config` at all — an unrecognised key was accepted ' + 'in silence and the store came up on the driver defaults instead.', }, { diff --git a/packages/spec/src/data/driver/mongo.zod.ts b/packages/spec/src/data/driver/mongo.zod.ts index 271a0b8050..6241ffe71b 100644 --- a/packages/spec/src/data/driver/mongo.zod.ts +++ b/packages/spec/src/data/driver/mongo.zod.ts @@ -67,7 +67,7 @@ export const MongoConfigSchema = lazySchema(() => strictObject( + '`url` (`?tls=true`) or in the `options` passthrough the Mongo client reads.', }, history: - 'Until #4410 nothing validated `datasource.config` at all — an unrecognised connection key ' + 'Until this shape was closed, nothing validated `datasource.config` at all — an unrecognised connection key ' + 'was accepted in silence and the datasource then connected to mongodb://localhost:27017 ' + 'rather than failing.', }, diff --git a/packages/spec/src/data/driver/mysql.zod.ts b/packages/spec/src/data/driver/mysql.zod.ts index c6d88ed013..9b7b22a475 100644 --- a/packages/spec/src/data/driver/mysql.zod.ts +++ b/packages/spec/src/data/driver/mysql.zod.ts @@ -69,7 +69,7 @@ export const MysqlConfigSchema = lazySchema(() => strictObject( + 'actually receives it.', }, history: - 'Until #4410 nothing validated `datasource.config` at all — an unrecognised connection key ' + 'Until this shape was closed, nothing validated `datasource.config` at all — an unrecognised connection key ' + 'was accepted in silence and the datasource then connected on the client defaults ' + '(localhost:3306) rather than failing.', }, diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index 0d5f2e5638..6308eaca77 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -171,9 +171,9 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( rejectUnauthorized: SSL_DETAIL_BELONGS_ON_DATASOURCE, }, history: - 'Until #4410 nothing validated `datasource.config` at all — an unrecognised connection key ' + 'Until this shape was closed, nothing validated `datasource.config` at all — an unrecognised connection key ' + 'was accepted in silence and the datasource then connected on the client defaults ' - + "(localhost:5432), which is #4001's original bug one level down.", + + '(localhost:5432), which is the same silent-drop bug one level down.', }, { /** diff --git a/packages/spec/src/data/driver/sqlite.zod.ts b/packages/spec/src/data/driver/sqlite.zod.ts index 59490a0848..c13ba705c4 100644 --- a/packages/spec/src/data/driver/sqlite.zod.ts +++ b/packages/spec/src/data/driver/sqlite.zod.ts @@ -40,7 +40,7 @@ const FILENAME_ALIASES = { } as const; const sqliteHistory = - 'Until #4410 nothing validated `datasource.config` at all — a misspelled `filename` was ' + 'Until this shape was closed, nothing validated `datasource.config` at all — a misspelled `filename` was ' + 'accepted in silence and the database silently became an ephemeral `:memory:` one, so the ' + 'data was gone on the next boot with nothing having reported a problem.'; diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 3dfe00b361..4375edd1bd 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -116,7 +116,7 @@ export const TursoConfigSchema = lazySchema(() => strictObject( + 'use `url: "file:./data/objectstack.db"`.', }, history: - 'Until #6345 a turso `config` was validated against nothing at all: the driver ships in an ' + 'Until this shape was closed, a turso `config` was validated against nothing at all: the driver ships in an ' + 'optional package, so it was not a builtin and `validateDriverConfig` answered ' + '"{ known: false }" for it. A misspelled `token:` was therefore accepted in silence and ' + 'the connection was attempted unauthenticated.', diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index b0a0b180f1..cdeccbf646 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -186,7 +186,7 @@ export const RUNTIME_OWNED_FIELD_TYPES: ReadonlySet = new Set([' * available fix each time was a comment. This is the fix those comments wanted. */ const FIELD_HISTORY = - 'Until #4001 closed this shape these were dropped silently — the field was still created, ' + 'Until this shape was closed these were dropped silently — the field was still created, ' + 'minus whatever the key was meant to constrain, protect or compute.'; /** @@ -684,7 +684,7 @@ function fieldKeyGuidanceAsStrictOptions() { * hide; these shapes close it at publish time. */ const INLINE_GRID_COLUMN_HISTORY = - 'Until #9227 closed this shape these parsed as `z.any()` — a mis-keyed column published ' + 'Until this shape was closed these parsed as `z.any()` — a mis-keyed column published ' + 'clean and rendered as blank cells, with nothing naming the wrong key.'; /** @@ -722,7 +722,7 @@ export const InlineGridColumnSchema = lazySchema(() => strictObject({ hidden: 'defaultHidden', }, }, { - name: z.string().min(1).describe('Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name, #3951). The retired `field` spelling is refused.'), + name: z.string().min(1).describe('Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name). The retired `field` spelling is refused.'), label: z.string().optional().describe("Column header; defaults to the child field's label via hydration."), type: z.enum(['text', 'number', 'currency', 'date', 'datetime', 'time', 'select', 'lookup', 'file']).optional().describe("Cell control, derived from the child field's type when omitted. Declaring it opts the column out of schema hydration — supply the extras (options / reference / …) yourself."), width: z.number().positive().optional().describe('Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed).'), @@ -795,7 +795,7 @@ export const FieldSchema = lazySchema(() => { // these are the removals recorded only as comments on this object, and a // comment is visible to everyone except the author who got it wrong. columnName: - '`columnName` was removed in the 16.x line (#2377) — the SQL driver hardcodes the physical ' + '`columnName` was removed in the 16.x line — the SQL driver hardcodes the physical ' + 'column to the field key, so a custom name was ignored. External/federated objects map ' + 'physical columns with `external.columnMap` (ADR-0062 D7).', // `currency` is not, and has never been, a declared FieldSchema key — it is @@ -811,7 +811,7 @@ export const FieldSchema = lazySchema(() => { + '{ currencyMode: \'fixed\', defaultCurrency: \'JPY\' }`. A field without one uses ' + 'the tenant default at runtime.', referenceFilters: - '`referenceFilters` (string[]) was removed in the 16.x line (#2377) — the lookup picker only ' + '`referenceFilters` (string[]) was removed in the 16.x line — the lookup picker only ' + 'ever read the structured form. Use `lookupFilters: [{ field, operator, value }]`.', // `notNull` is aliased to `required` above for the common case, but ADR-0113 // makes the two deliberately distinct and the distinction IS the point, so @@ -854,7 +854,7 @@ export const FieldSchema = lazySchema(() => { * always representable (it reads back as `[]`, never `null` — see * `multiple`), so the required check judges emptiness, not absence. */ - required: z.boolean().default(false).describe('Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (#9447, maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field.'), + required: z.boolean().default(false).describe('Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field.'), /** * Physical storage constraints (ADR-0113). Deliberately separate from the @@ -882,12 +882,12 @@ export const FieldSchema = lazySchema(() => { * branch. Same ruling: `required` on a multi-value lookup means non-empty * array (see `required` above). */ - multiple: z.boolean().default(false).describe('Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18).'), + multiple: z.boolean().default(false).describe('Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18).'), // `true` = unique WITHIN the tenant on a tenant-scoped object (composite // `(tenantField, field)` index); `'global'` = platform-wide single-column // unique. See {@link UniqueScopeSchema} for the scope vocabulary (ADR-0120). unique: UniqueScopeSchema.default(false).describe("Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'"), - defaultValue: z.unknown().optional().describe('Default applied on INSERT when the field is omitted or null (`\'\'` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine\'s own order: a CEL Expression envelope `{ dialect: \'cel\', source: \'today()\' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: \'sys_user\'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field\'s own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message.'), + defaultValue: z.unknown().optional().describe('Default applied on INSERT when the field is omitted or null (`\'\'` is a real value, not absence). Three legal shapes, discriminated in the engine\'s own order: a CEL Expression envelope `{ dialect: \'cel\', source: \'today()\' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: \'sys_user\'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field\'s own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message.'), /** Text/String Constraints */ // #11566 — a character length is a positive integer, so `0`, `-5` and `12.5` @@ -960,7 +960,7 @@ export const FieldSchema = lazySchema(() => { * `number` field should present until the renderer half of this contract * (objectui#4033) lands and retires the interim heuristic. */ - useGrouping: z.boolean().optional().describe('Digit-grouping presentation hint for `number` fields (#7768) — maps to `Intl.NumberFormat`\'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on.'), + useGrouping: z.boolean().optional().describe('Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`\'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on.'), /** * Media Constraints (ADR-0104 D3 wave 2) @@ -1097,7 +1097,7 @@ export const FieldSchema = lazySchema(() => { * renderer's measured reads — an unknown or retired key (`field`) is a * named rejection at publish time, never a blank cell at render time. */ - inlineColumns: z.array(InlineGridColumnSchema).optional().describe("Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column ({ name, label?, type?, … } — objectui GridColumn, #3951); identity-only entries ({ name }) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse."), + inlineColumns: z.array(InlineGridColumnSchema).optional().describe("Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column ({ name, label?, type?, … } — objectui GridColumn); identity-only entries ({ name }) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse."), /** Optional numeric child field summed for the inline grid running total. */ inlineAmountField: z.string().optional().describe('Numeric child field summed for the inline grid total'), @@ -1366,7 +1366,7 @@ export const FieldSchema = lazySchema(() => { */ visibleWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'`"), readonlyWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`"), - requiredWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17 (#3855)."), + requiredWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17."), /** * [REMOVED in protocol 17 — #3855] The deprecated alias of `requiredWhen`. @@ -1378,7 +1378,7 @@ export const FieldSchema = lazySchema(() => { * fails `tsc` at the authoring site before any parse runs. */ conditionalRequired: retiredKey( - '`conditionalRequired` was removed in @objectstack/spec 17 (#3855) — use `requiredWhen`. ' + + '`conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. ' + 'Rename the key; the value (a CEL predicate) is unchanged. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), @@ -1431,9 +1431,9 @@ export const FieldSchema = lazySchema(() => { * a `text` column is never collected. ADR-0049: enforced from landing day, at * `Engine.maskSecretFields`. */ - internal: z.boolean().optional().describe("[#7728] Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column."), + internal: z.boolean().optional().describe("Never return this field's value on the generic data path — the engine OMITS the key from `find`/`findOne` results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in `?select=`. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on `text` columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a `required` column."), - readonly: z.boolean().default(false).describe('Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE (#2948/#3003) and on INSERT (#3043; a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`, #3493) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply (#6640): a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips.'), + readonly: z.boolean().default(false).describe('Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE and on INSERT (a create can no longer directly seed e.g. `approval_status: "approved"`), symmetric with `readonlyWhen`. A stripped INSERT field still falls back to its `defaultValue`. Exempt from the strip on BOTH paths: `isSystem` writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (`preserveAudit`) — which admits a whitelist (the audit/timestamp family plus author-declared business `readonly` fields). On INSERT the exemption does NOT apply: a non-system create that requests `preserveAudit` still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips.'), /** * [ADR-0066 D3] Capabilities required to READ/EDIT this field. A field @@ -1480,7 +1480,7 @@ export const FieldSchema = lazySchema(() => { * the mask character) is refused with `400 VALIDATION_ERROR` rather than * silently destroying the stored value. */ - maskingRule: FieldMaskingRuleSchema.optional().describe("[#8993] Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or { keepHead, keepTail }. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field."), + maskingRule: FieldMaskingRuleSchema.optional().describe("Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138****5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j***@example.com, 'name' keep first char) or { keepHead, keepTail }. Masked for every non-system caller unless the field's `requiredPermissions` are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field."), /** * [ADR-0100] Author's explicit acknowledgment that a generic (non-auth) @@ -1492,7 +1492,7 @@ export const FieldSchema = lazySchema(() => { * starts clean (#3420). No runtime effect beyond the diagnostic; ignored on * non-`password` fields. */ - ackPlaintextMasking: z.boolean().optional().describe("[ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning (#3420). No effect on non-password fields."), + ackPlaintextMasking: z.boolean().optional().describe("[ADR-0100] Affirm a generic `password` field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning. No effect on non-password fields."), system: z.boolean().optional().describe('Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag.'), sortable: z.boolean().optional().default(true).describe('Whether field is sortable in list views'), inlineHelpText: z.string().optional().describe('Help text displayed below the field in forms'), diff --git a/packages/spec/src/data/filter-comparand-type.test.ts b/packages/spec/src/data/filter-comparand-type.test.ts index 8333a25a97..f3227fd3af 100644 --- a/packages/spec/src/data/filter-comparand-type.test.ts +++ b/packages/spec/src/data/filter-comparand-type.test.ts @@ -194,7 +194,7 @@ describe('bigint — accepted, and NARROWED copy-on-write (#7872; the memory cra }); describe('boundaries the door deliberately does not cross', () => { - it('leaves a FieldReference alone at every position — #5222/#7596/#7597 own its fate', () => { + it('leaves a FieldReference alone at every position — #5222/scalar comparison/#7597 own its fate', () => { for (const filter of [ { amount: { $gt: { $field: 'budget' } } }, { amount: { $eq: { $field: 'budget' } } }, diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index b8bbb1f602..5594df4533 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -195,7 +195,7 @@ describe('SetOperatorSchema', () => { expect(issue?.path).toEqual(['$in', 1]); expect(issue?.message).toContain('$in member at index 1'); expect(issue?.message).toContain('$eq/$ne/$gt/$gte/$lt/$lte'); - expect(issue?.message).toContain('#7596'); + expect(issue?.message).toContain('scalar comparison'); }); it('refuses a $field member of $nin — the direction that WIDENS a scope', () => { @@ -383,7 +383,7 @@ describe('RangeOperatorSchema', () => { expect(issue?.path).toEqual(['$between', 0]); expect(issue?.message).toContain('$between endpoint at index 0'); expect(issue?.message).toContain('$eq/$ne/$gt/$gte/$lt/$lte'); - expect(issue?.message).toContain('#7596'); + expect(issue?.message).toContain('scalar comparison'); }); it('refuses a $field UPPER bound, naming index 1', () => { @@ -413,13 +413,13 @@ describe('RangeOperatorSchema', () => { const boolMax = RangeOperatorSchema.safeParse({ $between: ['2026-01-01', true] }); expect(boolMax.success).toBe(false); expect(boolMax.error?.issues[0]?.path).toEqual(['$between', 1]); - expect(boolMax.error?.issues[0]?.message).not.toContain('#7596'); + expect(boolMax.error?.issues[0]?.message).not.toContain('scalar comparison'); // An object that is NOT a reference is refused as it always was: this // check reads the SHAPE, and `{ nope: 1 }` never carried a `$field` key. const objectMin = RangeOperatorSchema.safeParse({ $between: [{ nope: 1 }, '2026-12-31'] }); expect(objectMin.success).toBe(false); - expect(objectMin.error?.issues[0]?.message).not.toContain('#7596'); + expect(objectMin.error?.issues[0]?.message).not.toContain('scalar comparison'); }); it('is matched by the enforced copy — FieldOperatorsSchema', () => { diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index b659723f07..60d8a08c8a 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -325,8 +325,8 @@ const setMembershipSchema = (op: '$in' | '$nin') => /** The `describe()` both `$in` and `$nin` carry, stating the one ruled-out member shape. */ const SET_MEMBER_DESCRIPTION = 'Membership list. Members are literal values of any type the column stores. A ' - + '{ $field } reference is NOT a member shape: no backend resolves one inside a list ' - + '(#7596) — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead.'; + + '{ $field } reference is NOT a member shape: no backend resolves one inside a list' + + ' — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead.'; /** * Set operators for membership checks. @@ -359,7 +359,7 @@ export const SetOperatorSchema = lazySchema(() => z.object({ const RANGE_ENDPOINT_DESCRIPTION = 'Closed interval [min, max]. Each endpoint is a number, a Date, or a string. ' + 'A { $field } reference is NOT an endpoint shape: no backend resolves one ' - + 'inside a list (#7596) — put it in a scalar comparison ' + + 'inside a list — put it in a scalar comparison ' + '($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. ' + 'STRING is the form the ' + 'platform itself produces: the date-macro resolver walks INTO arrays, so ' @@ -628,10 +628,10 @@ export const StringOperatorSchema = lazySchema(() => z.object({ + 'sqlite-wasm) folds ASCII only, so a Unicode promise here would be a ' + 'guarantee three of the five could not keep. The comparand is matched ' + 'LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not ' - + 'wildcards. Case-SENSITIVE containment is $contains. [#5701 declared it; #5702 ' - + 'lowered it on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on ' - + 'both transports); #6520 lowered it on every JS evaluation face, so it is ' - + 'portable across every backend the platform ships.]' + + 'wildcards. Case-SENSITIVE containment is $contains. Lowered on the SQL ' + + 'family (driver-sql, driver-sqlite-wasm, driver-turso on both transports) ' + + 'and on every JS evaluation face, so it is ' + + 'portable across every backend the platform ships.' ), /** @@ -648,13 +648,13 @@ export const StringOperatorSchema = lazySchema(() => z.object({ + 'with no wildcards is an exact comparison, NOT a substring search; write ' + '$contains for containment. A pattern ending in a lone unpaired backslash ' + 'is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract ' - + 'as $contains (#4706 Q2 = A); $ilike is the case-insensitive twin. ' - + '[#7536. Answered by the SQL family (driver-sql, driver-sqlite-wasm, ' + + 'as $contains (Q2 = A); $ilike is the case-insensitive twin. ' + + 'Answered by the SQL family (driver-sql, driver-sqlite-wasm, ' + 'driver-turso on both transports), by driver-memory and by ' + '@objectstack/formula. driver-mongodb, objectql `having` and ' + 'service-analytics REFUSE it in the INVALID_FILTER envelope rather than ' + 'approximating it — see FILTER_OPERATORS for why it is staged out of that ' - + 'allowlist.]' + + 'allowlist.' ), /** @@ -664,10 +664,10 @@ export const StringOperatorSchema = lazySchema(() => z.object({ $ilike: z.string().optional().describe( 'Whole-string pattern match like $like — "%" / "_" wildcards bound by the ' + 'caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) ' - + 'and ONLY ASCII case: "café" does NOT match "CAFÉ", the same #4706 Q1 = A ' + + 'and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A ' + 'boundary $icontains declares, because SQLite\'s fold is ASCII-only and ' - + 'three of the five backends are SQLite underneath. [#7536; staged with ' - + '$like — see FILTER_OPERATORS.]' + + 'three of the five backends are SQLite underneath. Staged with ' + + '$like — see FILTER_OPERATORS.' ), })); @@ -2117,7 +2117,7 @@ export const FilterArraySchema: z.ZodType = z.lazy(() + 'FilterCondition at the single sink parseFilterAST (@objectstack/spec/data) ' + 'the moment it arrives; it is never stored and never travels the wire as ' + 'an array. A query "where" is a FilterCondition and does not accept this ' - + 'shape (#5158).' + + 'shape.' ) ) as z.ZodType; diff --git a/packages/spec/src/data/hook-body.zod.ts b/packages/spec/src/data/hook-body.zod.ts index 3d08695286..cda842a02d 100644 --- a/packages/spec/src/data/hook-body.zod.ts +++ b/packages/spec/src/data/hook-body.zod.ts @@ -110,7 +110,7 @@ export const ExpressionBodySchema = strictObject( timeoutMs: `\`timeoutMs\` ${L2_ONLY_ON_L1}`, memoryMb: `\`memoryMb\` ${L2_ONLY_ON_L1}`, }, - history: 'Until #4001 these were dropped silently.', + history: 'Until this shape was closed, these were dropped silently.', }, { language: z.literal('expression'), @@ -190,7 +190,7 @@ export const ScriptBodySchema = strictObject( body: 'source', }, history: - 'Until #4001 these were dropped silently — the body still ran, just not under the ' + 'Until this shape was closed, these were dropped silently — the body still ran, just not under the ' + 'limits or grants that were written.', }, { diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 18ed93d49b..d14db06138 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -62,7 +62,7 @@ import type { IScopedContext } from '../contracts/scoped-context'; */ const hookTargetError = 'A hook `object` target must name at least one object. An empty target is not ' - + '"no target": until #4001 `\'\'` and `[]` were widened to the wildcard `\'*\'`, ' + + '"no target": until this shape was closed, `\'\'` and `[]` were widened to the wildcard `\'*\'`, ' + 'registering the hook on EVERY object, and `[\'\']` registered it on an object ' + 'name nothing matches, so it could never fire. Name the object(s) — ' + "`object: 'account'` or `object: ['account', 'contact']` — or, if firing on " @@ -172,7 +172,7 @@ export const HookSchema = lazySchema(() => strictObject( '`active` is not a hook key — a hook has no on/off switch. Gate it with `condition`, ' + 'or remove the hook.', }, - history: 'Until #4001 these were dropped silently — the hook still registered and ran.', + history: 'Until this shape was closed, these were dropped silently — the hook still registered and ran.', }, { /** @@ -291,7 +291,7 @@ export const HookSchema = lazySchema(() => strictObject( delayms: 'backoffMs', }, history: - 'Until #4001 these were dropped silently — the hook retried on the defaults rather ' + 'Until this shape was closed, these were dropped silently — the hook retried on the defaults rather ' + 'than the policy that was written. Note a datasource retryPolicy spells its delay ' + '`baseDelayMs`; a hook spells it `backoffMs`.', }, @@ -619,7 +619,7 @@ export const HookContextSchema = lazySchema(() => z.object({ mode: z.enum(['record', 'per-row']).describe("'record' = this call is the caller's whole write; 'per-row' = one of N dispatches for one write"), index: z.number().int().nonnegative().describe('0-based position in the per-row fan-out; always 0 when mode is "record"'), scope: z.record(z.string(), z.unknown()).describe('Scratch shared by every dispatch of one caller write, across both phases (same object identity)'), - }).optional().describe('How this hook call relates to the caller\'s write (engine-produced; #6966)'), + }).optional().describe('How this hook call relates to the caller\'s write (engine-produced)'), /** * Execution Session @@ -732,7 +732,7 @@ export const HookContextSchema = lazySchema(() => z.object({ preserveAudit: z.boolean().optional().describe( 'True when this write is a historical import that must KEEP its caller-supplied ' + 'updated_at/updated_by (and the readonly audit family) instead of being stamped with ' - + 'the import instant (#3493). Server-set, opt-in, absent on normal writes; read by the ' + + 'the import instant. Server-set, opt-in, absent on normal writes; read by the ' + 'built-in audit hook. A stamping policy, not an authorization input.', ), // `roles` REMOVED (#5050, ADR-0049 D2). It was DECLARED here, READ by two @@ -782,8 +782,8 @@ export const HookContextSchema = lazySchema(() => z.object({ // field named `roles` or a comparison against the string 'admin' // (ADR-0090 D3 bans the `role` spelling outright). roles: retiredKey( - '`HookContext.session.roles` was removed in @objectstack/spec 17.0.0 (#5050, ADR-0049 D2) — ' - + 'it was declared, read by two dead exemption branches (removed in #5049), and never ' + '`HookContext.session.roles` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — ' + + 'it was declared, read by two dead exemption branches, and never ' + 'produced: ObjectQL\'s `buildSession()` builds the session field by field and has never ' + 'written `roles`, so every read resolved `undefined` and a guard keyed on it was dead ' + 'code that merely LOOKED like an authorization decision. Delete the key. To gate a hook ' @@ -794,7 +794,7 @@ export const HookContextSchema = lazySchema(() => z.object({ + 'outright). Nothing to migrate: a HookContext is built per operation by the engine and ' + 'never stored, so no metadata source carries this key. NOTE an ACTION body\'s ' + '`ctx.session` is a different object and still carries its own `roles` array today; ' - + 'that surface is tracked separately (#5613) and is not what this key was.', + + 'that surface is tracked separately and is not what this key was.', ), }).optional().describe('Current session context'), @@ -823,8 +823,8 @@ export const HookContextSchema = lazySchema(() => z.object({ * truth — there was no caller (#4586). */ provenance: z.object({ - flowRunId: z.string().optional().describe('Id of the automation flow run performing this write, when it originates from a flow data node. Lets a hook recognize the run that OWNS state that run itself opened — the approvals record lock exempts the run holding the pending request (#3456).'), - attributedUserId: z.string().optional().describe('The real human credited for a write whose authorization subject was the SYSTEM — e.g. the admin whose better-auth `update-member-role` call the identity adapter executes as `isSystem` (#4586). ATTRIBUTION ONLY: the audit writer records it as `sys_audit_log.user_id`; no security middleware reads it, and it never becomes the subject the write is authorized as.'), + flowRunId: z.string().optional().describe('Id of the automation flow run performing this write, when it originates from a flow data node. Lets a hook recognize the run that OWNS state that run itself opened — the approvals record lock exempts the run holding the pending request.'), + attributedUserId: z.string().optional().describe('The real human credited for a write whose authorization subject was the SYSTEM — e.g. the admin whose better-auth `update-member-role` call the identity adapter executes as `isSystem`. ATTRIBUTION ONLY: the audit writer records it as `sys_audit_log.user_id`; no security middleware reads it, and it never becomes the subject the write is authorized as.'), }).optional().describe('Server-stamped write provenance (never client-supplied, never an authorization input)'), diff --git a/packages/spec/src/data/mapping.zod.ts b/packages/spec/src/data/mapping.zod.ts index cc744f5ebc..3e2879127d 100644 --- a/packages/spec/src/data/mapping.zod.ts +++ b/packages/spec/src/data/mapping.zod.ts @@ -17,7 +17,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; * green run. */ const MAPPING_HISTORY = - 'Until #4001 closed this shape these were dropped silently — the mapping still ran to ' + 'Until this shape was closed these were dropped silently — the mapping still ran to ' + 'completion and reported success, minus whatever the key was meant to control.'; /** @@ -39,7 +39,7 @@ const MAPPING_HISTORY = * which is the behaviour we want here. */ const RETIRED_EXTRACT_QUERY = - '`mapping.extractQuery` was removed in @objectstack/spec 17.0.0 (#4509, ADR-0049) — no ' + '`mapping.extractQuery` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'exporter ever read a mapping artifact, so "Query to run for export only" promised an ' + 'export path that does not exist. Delete the key. Exports run through the ordinary ' + 'query API (`POST /api/v1/data/:object/query`); if a mapping-driven export is ever ' @@ -47,14 +47,14 @@ const RETIRED_EXTRACT_QUERY = + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.'; const RETIRED_ERROR_POLICY = - '`mapping.errorPolicy` was removed in @objectstack/spec 17.0.0 (#4509, ADR-0049) — no ' + '`mapping.errorPolicy` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'import code ever read it, so `skip` / `abort` / `retry` selected between three ' + 'behaviours that were all the same behaviour. Delete the key. Error handling on the ' + 'import path belongs to the import REQUEST\'s own options, not to the stored mapping. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.'; const RETIRED_BATCH_SIZE = - '`mapping.batchSize` was removed in @objectstack/spec 17.0.0 (#4509, ADR-0049) — no ' + '`mapping.batchSize` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'import code ever batched by it; the write path sizes its own batches. Delete the key. ' + 'CAREFUL — do NOT "fix" this by relocating the value to a neighbouring `batchSize`: ' + '`bulkActionDef.batchSize`, `connector.batchSize`, `sync.batchSize`, `offline.batchSize`, ' @@ -101,7 +101,7 @@ const MAPPING_RETIRED_KEY_GUIDANCE: Readonly> = { * key that is also gone. */ const RETIRED_LOOKUP_OBJECT = - '`fieldMapping[].params.object` was removed in @objectstack/spec 17 (#10329, ADR-0049) — the ' + '`fieldMapping[].params.object` was removed in @objectstack/spec 17 (ADR-0049) — the ' + '`lookup` transform never read it: the cell is copied through unchanged and the import ' + 'pipeline resolves references from the TARGET FIELD\'s own metadata (the field\'s declared ' + '`reference` names the lookup object), so "Lookup Object" steered nothing. Delete the key; ' @@ -110,21 +110,21 @@ const RETIRED_LOOKUP_OBJECT = + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; const RETIRED_LOOKUP_FROM_FIELD = - '`fieldMapping[].params.fromField` was removed in @objectstack/spec 17 (#10329, ADR-0049) — ' + '`fieldMapping[].params.fromField` was removed in @objectstack/spec 17 (ADR-0049) — ' + 'the `lookup` transform never read it: the import pipeline matches the cell\'s display ' + 'value (name / email / id) against the referenced object itself, not against a ' + 'mapping-declared match field, so "Match on" steered nothing. Delete the key. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; const RETIRED_LOOKUP_TO_FIELD = - '`fieldMapping[].params.toField` was removed in @objectstack/spec 17 (#10329, ADR-0049) — ' + '`fieldMapping[].params.toField` was removed in @objectstack/spec 17 (ADR-0049) — ' + 'the `lookup` transform never read it: reference resolution always writes the referenced ' + 'record\'s id (what a reference column stores), so "Value to take" steered nothing. ' + 'Delete the key. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; const RETIRED_LOOKUP_AUTO_CREATE = - '`fieldMapping[].params.autoCreate` was removed in @objectstack/spec 17 (#10329, ADR-0049) — ' + '`fieldMapping[].params.autoCreate` was removed in @objectstack/spec 17 (ADR-0049) — ' + 'it read as "create the referenced record when nothing matches", and nothing was ever ' + 'created: with or without this key, a cell that resolves to no record FAILS its row with an ' + 'unresolved-reference error (`import_reference_not_found`). Delete the key; create or ' diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 6a59171602..c2496b688a 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -50,7 +50,7 @@ describe('ObjectCapabilities', () => { expect(result.success).toBe(false); const message = result.success ? '' : result.error.issues.map((i) => i.message).join('\n'); expect(message).toContain(`\`${key}\``); - expect(message).toContain('#2377'); + expect(message, 'the migration command is the durable reference').not.toMatch(/#\d{3,5}/); // The prescription names the source rewrite (the #3207 conversion). expect(message).toContain('os migrate meta --from 16'); } @@ -60,8 +60,9 @@ describe('ObjectCapabilities', () => { const trash = ObjectCapabilities.safeParse({ trash: false }); expect(trash.success).toBe(false); const trashMsg = trash.success ? '' : trash.error.issues.map((i) => i.message).join('\n'); - expect(trashMsg).toContain('#3146'); - expect(trashMsg).not.toContain('#1893'); + expect(trashMsg).toContain('soft delete is parked'); + expect(trashMsg, 'the prescription carries ADR ids and the migration command, never a tracker id') + .not.toMatch(/#\d{3,5}/); }); it('rejects unknown capability keys instead of stripping them', () => { @@ -102,7 +103,7 @@ describe('ObjectCapabilities', () => { const message = capabilityRejection({ trash: false, searchible: true }); const preamble = 'Unrecognized key(s) on `enable`:'; const fix = 'os migrate meta --from 16'; - const history = 'every flag carries an enforcement contract (#2707)'; + const history = 'every flag carries an enforcement contract'; expect(message.startsWith(preamble)).toBe(true); expect(message.indexOf(fix)).toBeGreaterThan(message.indexOf(preamble)); @@ -113,7 +114,7 @@ describe('ObjectCapabilities', () => { it('the tombstone text survives the fold byte-for-byte', () => { expect(capabilityRejection({ mru: true })).toContain( - '`enable.mru` was removed from @objectstack/spec in the 16.x line (#2377/#3207, ' + '`enable.mru` was removed from @objectstack/spec in the 16.x line (' + 'ADR-0049) — Most-Recently-Used tracking was never implemented; no reader ' + 'existed, so the flag changed nothing.', ); @@ -1270,7 +1271,8 @@ describe('ObjectSchema.create()', () => { } expect(message).toContain('highlightFields'); expect(message).toContain('11.7.0'); - expect(message).toContain('#2536'); + expect(message).toContain('ADR-0085 semantic roles'); + expect(message).toContain('retired in 11.9.1'); }); it('tombstone: dead metadata keys removed in 16.0 (#2377) carry upgrade guidance', () => { @@ -1293,7 +1295,7 @@ describe('ObjectSchema.create()', () => { message = (e as Error).message; } expect(message, `${key} should be rejected`).toContain(key); - expect(message, `${key} should cite #2377`).toContain('#2377'); + expect(message, `${key} should name the removal`).toContain('was removed'); expect(message, `${key} should hint at the replacement`).toContain(needle); } }); @@ -1949,7 +1951,7 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', () const result = TenancyConfigSchema.safeParse({ enabled: true, strategy: 'isolated' }); expect(result.success).toBe(false); const message = result.error!.issues.map((i) => i.message).join('\n'); - expect(message).toContain('removed from @objectstack/spec after v15.0 (#2763)'); + expect(message).toContain('removed from @objectstack/spec after v15.0'); expect(message).toContain('environment/deployment'); expect(message).toContain('`tenancy.enabled` + `tenancy.tenantField`'); }); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index b0531e63e1..3b59e0aee8 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -134,16 +134,16 @@ export function stripLegacyApiMethods( */ const CAPABILITIES_RETIRED_KEY_GUIDANCE: Record = { trash: - '`enable.trash` was removed from @objectstack/spec in the 16.x line (#2377/#3207, ' + + '`enable.trash` was removed from @objectstack/spec in the 16.x line (' + 'ADR-0049) — it never had a runtime consumer: every delete has always been a ' + 'hard delete, and a default-true flag promising a recycle bin was a false ' + 'affordance (authors wrote `trash: false` believing they were opting out of a ' + 'soft-delete that never ran). Delete the key. For recoverability use per-field ' + - '`trackHistory` (audit trail) or a `lifecycle` policy; soft delete is parked at ' + - '#3146 and, if built, returns as a live enforced flag (ADR-0049 prune-or-build). ' + + '`trackHistory` (audit trail) or a `lifecycle` policy; soft delete is parked, ' + + 'and if built returns as a live enforced flag (ADR-0049 prune-or-build). ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', mru: - '`enable.mru` was removed from @objectstack/spec in the 16.x line (#2377/#3207, ' + + '`enable.mru` was removed from @objectstack/spec in the 16.x line (' + 'ADR-0049) — Most-Recently-Used tracking was never implemented; no reader ' + 'existed, so the flag changed nothing. Delete the key. If MRU tracking is ' + 'built it returns as a live enforced flag (ADR-0049 prune-or-build). ' + @@ -187,8 +187,8 @@ const CAPABILITIES_RETIRED_KEY_GUIDANCE: Record = { */ const CAPABILITIES_HISTORY = 'Until this shape was closed an unknown flag was dropped without a word — the object ' - + 'shipped as if the author had never written it (#1535); `enable` is a closed vocabulary ' - + 'in which every flag carries an enforcement contract (#2707).'; + + 'shipped as if the author had never written it; `enable` is a closed vocabulary ' + + 'in which every flag carries an enforcement contract.'; /** * Capability Flags @@ -437,15 +437,15 @@ const DeclaredIndexUniqueScopeSchema = lazySchema(() => export const IndexSchema = lazySchema(() => strictObject({ surface: 'this index', history: - 'Until #4001 批 20 closed this site (its held 14th, closed once objectui#4772 ' + - "converged the console's drifted index editor), an unknown key here was dropped " + + 'Until this site was closed (held back until the console\'s drifted index ' + + 'editor converged), an unknown key here was dropped ' + 'silently: the index still parsed and registered, minus whatever the author ' + 'believed the key did.', guidance: { where: '`where` has never been an index key in this protocol — it was the console ' + - "fallback editor's drifted spelling for a partial-index predicate (objectui#4772 " + - 'removed the control), and no driver ever emitted a predicate under either ' + + "fallback editor's drifted spelling for a partial-index predicate (since " + + 'removed), and no driver ever emitted a predicate under either ' + 'spelling. Delete the key. A partial index is built at the database layer, not ' + 'the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from ' + "a runtime migration (what `metadata-protocol`'s `ensureOverlayIndex` already " + @@ -480,7 +480,7 @@ export const IndexSchema = lazySchema(() => strictObject({ // `fields: ['organization_id', 'code']`" survives as valid legacy input, // but new code says `unique: 'organization'` — the hand-written composite // is NOT NULL-safe (#5030). - unique: DeclaredIndexUniqueScopeSchema.optional().default(false).describe("Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization'"), + unique: DeclaredIndexUniqueScopeSchema.optional().default(false).describe("Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18) — state the scope. 'tenant'/'org' are rejected — the word is 'organization'"), // ── Tombstones (ADR-0049 / ADR-0087) ───────────────────────────────── // Kept LAST in the shape on purpose — see the #5606 note in the block @@ -492,7 +492,7 @@ export const IndexSchema = lazySchema(() => strictObject({ // `object-index-type-partial-removed` strips both from stored/authored // sources on the protocol-17 migration. type: retiredKey( - '`indexes[].type` was removed in @objectstack/spec 17.0.0 (#5248, ADR-0049) — no driver ever ' + + '`indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever ' + 'read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex\'s ' + '`table.index()` / `table.unique()`, which cannot express an access method, so the value ' + 'changed no DDL; its `.default(\'btree\')` merely made an inert knob show up in every parse ' + @@ -502,7 +502,7 @@ export const IndexSchema = lazySchema(() => strictObject({ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), partial: retiredKey( - '`indexes[].partial` was removed in @objectstack/spec 17.0.0 (#5248, #4943, ADR-0049) — no ' + + '`indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a ' + 'FULL index and the predicate silently did nothing. Delete the key. Partial indexes are ' + 'built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … ' + @@ -523,13 +523,13 @@ export const IndexSchema = lazySchema(() => strictObject({ */ const TENANCY_RETIRED_KEY_GUIDANCE: Record = { strategy: - '`tenancy.strategy` was removed from @objectstack/spec after v15.0 (#2763) — it ' + + '`tenancy.strategy` was removed from @objectstack/spec after v15.0 — it ' + 'never had a consumer. The platform has exactly two tenancy modes and neither is ' + 'object-level config: database-per-tenant isolation is an environment/deployment ' + 'choice (each environment carries its own database URL), and row-level isolation ' + 'is `tenancy.enabled` + `tenancy.tenantField`. Delete the key.', crossTenantAccess: - '`tenancy.crossTenantAccess` was removed from @objectstack/spec after v15.0 (#2763) — it ' + + '`tenancy.crossTenantAccess` was removed from @objectstack/spec after v15.0 — it ' + 'never had a consumer; setting it granted nothing. Cross-tenant visibility is ' + 'governed by sharing rules / OWD (ADR-0056), `externalSharingModel` (ADR-0090 ' + 'D11), and the object access posture. Delete the key.', @@ -665,10 +665,10 @@ export const TenancyConfigSchema = lazySchema(() => strictObject({ '`organization_id`, the kernel-injected column the RLS predicates and ' + '`tenantPolicy()` also assume. A declared name is honoured only when the ' + 'object really has that field — otherwise the same `organization_id` ' + - 'fallback applies. No default is materialized here on purpose (#5315).', + 'fallback applies. No default is materialized here on purpose.', ), organizationField: z.string().optional().describe( - 'STAMP-ONLY (#8778, widened by cloud#1395): column carrying the ' + + 'STAMP-ONLY: column carrying the ' + 'organization a row is ABOUT, consulted by the three sanctioned ' + 'platform-row writers — audit stamping, the approval-row writer ' + '(`plugin-approvals`), and the automation-run recorder ' + @@ -725,7 +725,7 @@ export function isTenancyDisabled(schema: unknown): boolean { export const ObjectAccessConfigSchema = lazySchema(() => strictObject({ surface: "this object's `access` block", history: - 'Until #4001 these were dropped silently — the block still parsed, so an object the ' + + 'Until this shape was closed, these were dropped silently — the block still parsed, so an object the ' + 'author declared `private` shipped `public`: covered by every `\'*\'` wildcard grant, ' + 'with no signal that the posture had been discarded.', aliases: { @@ -863,7 +863,7 @@ const lifecycleOnlyWhenSchema = z.record( export const LifecycleSchema = lazySchema(() => strictObject({ surface: "this object's `lifecycle` block", history: - 'Until #4001 these were dropped silently — the block still parsed, so a bounding ' + + 'Until this shape was closed, these were dropped silently — the block still parsed, so a bounding ' + 'policy written one level too high left the object with NO policy at all. ADR-0057 ' + "§3.5's own refine then passed, because the key it looks for was never there.", aliases: { rotation: 'storage' }, @@ -911,7 +911,7 @@ export const LifecycleSchema = lazySchema(() => strictObject({ retention: strictObject({ surface: "this object's `lifecycle.retention` block", history: - 'Until #4001 these were dropped silently — the retention window still parsed, so a ' + + 'Until this shape was closed, these were dropped silently — the retention window still parsed, so a ' + 'row filter written under the wrong key reaped rows the author had meant to exempt.', aliases: { filter: 'onlyWhen', where: 'onlyWhen', when: 'onlyWhen', age: 'maxAge' }, guidance: { @@ -932,7 +932,7 @@ export const LifecycleSchema = lazySchema(() => strictObject({ ttl: strictObject({ surface: "this object's `lifecycle.ttl` block", history: - 'Until #4001 these were dropped silently — the TTL block still parsed, so rows the ' + + 'Until this shape was closed, these were dropped silently — the TTL block still parsed, so rows the ' + 'author expected to auto-expire lived forever.', aliases: { expiresAfter: 'expireAfter', after: 'expireAfter', timestampField: 'field', on: 'field', filter: 'onlyWhen', where: 'onlyWhen', when: 'onlyWhen' }, guidance: { @@ -951,7 +951,7 @@ export const LifecycleSchema = lazySchema(() => strictObject({ storage: strictObject({ surface: "this object's `lifecycle.storage` block", history: - 'Until #4001 these were dropped silently — the rotation block still parsed, so a ' + + 'Until this shape was closed, these were dropped silently — the rotation block still parsed, so a ' + 'telemetry table declared as rotating kept every shard it ever cut.', aliases: { count: 'shards', interval: 'unit', period: 'unit', granularity: 'unit' }, guidance: { @@ -973,7 +973,7 @@ export const LifecycleSchema = lazySchema(() => strictObject({ archive: strictObject({ surface: "this object's `lifecycle.archive` block", history: - 'Until #4001 these were dropped silently — the archive block still parsed, so audit ' + + 'Until this shape was closed, these were dropped silently — the archive block still parsed, so audit ' + 'rows were reaped hot with no cold copy ever written.', aliases: { datasource: 'to', target: 'to', destination: 'to', retain: 'keep' }, guidance: { @@ -1122,7 +1122,7 @@ export const LifecycleSchema = lazySchema(() => strictObject({ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ surface: 'this field group', history: - 'Until #4001 these were dropped silently — the group still parsed AND still rendered, ' + + 'Until this shape was closed, these were dropped silently — the group still parsed AND still rendered, ' + 'which is the worst version of the failure: the section appeared, so the author had ' + 'every reason to believe the setting they wrote had been applied.', aliases: { @@ -1240,7 +1240,7 @@ export type ObjectFieldGroupParsed = z.infer; export const ObjectExternalBindingSchema = strictObject({ surface: "this object's `external` binding (ADR-0015)", history: - 'Until #4001 these were dropped silently — the binding still parsed, so a federated ' + + 'Until this shape was closed, these were dropped silently — the binding still parsed, so a federated ' + 'object bound to the wrong remote table, or shipped read-only after the author had ' + 'explicitly asked for writes.', aliases: { @@ -1331,8 +1331,8 @@ export const RowCrudActionOverrideSchema = strictObject({ // here — but the rejection was zod's own bare `Unrecognized key: "visible"`, // which names neither the surface nor a key to write instead. #7832. history: - 'This shape has been closed since objectui#2614, so the key was never silently dropped — ' - + 'until #7832 the rejection just could not tell you which key to write instead.', + 'This shape has been closed, so the key was never silently dropped — ' + + 'until this shape was closed, the rejection just could not tell you which key to write instead.', aliases: { // `showWhen` carries no boolean reading: whatever surface an author borrowed // it from, they meant a predicate, and the predicate slot here is @@ -1467,12 +1467,12 @@ const UNKNOWN_KEY_GUIDANCE: Record = { '`namespace` was retired — the object `name` IS the canonical id ' + 'everywhere (API, ObjectQL, REST, SDK, DB table), so there is no separate namespace ' + 'to declare. Embed the module prefix in the name instead: `namespace: "sys", ' + - 'name: "user"` becomes `name: "sys_user"`. Until #4001 closed this shape on the ' + + 'name: "user"` becomes `name: "sys_user"`. Until this shape was closed on the ' + 'parse path it was stripped in silence, so an object declaring one shipped under ' + 'the unprefixed name its author did not intend.', compactLayout: '`compactLayout` was renamed to `highlightFields` in @objectstack/spec 11.7.0 ' + - '(ADR-0085 semantic roles) and the alias was retired in 11.9.1 (#2536). ' + + '(ADR-0085 semantic roles) and the alias was retired in 11.9.1. ' + 'Rename the key — the value shape (ordered field-name list) is unchanged.', detail: 'The `detail` UI-hints block was removed by ADR-0085 (spec 11.7.0). Its ' + @@ -1487,42 +1487,42 @@ const UNKNOWN_KEY_GUIDANCE: Record = { '(`highlightFields`, `stageField`, `fieldGroups`) for hints and `listViews` ' + 'for named list views.', defaultDetailForm: - '`defaultDetailForm` was never implemented and was removed from the spec ' + - '(#2402). Curate the record page by assigning a custom Page schema; form ' + + '`defaultDetailForm` was never implemented and was removed from the spec' + + '. Curate the record page by assigning a custom Page schema; form ' + 'layout derives from `fieldGroups` + `Field.group`.', softDelete: - '`softDelete` was removed from the spec in 16.0 (#2377, ADR-0049 ' + + '`softDelete` was removed from the spec in 16.0 (ADR-0049 ' + 'enforce-or-remove) — there is no soft-delete/recycle-bin runtime, so it ' + 'stored nothing and implied restore semantics that do not exist. Deletes ' + 'are hard deletes; remove the key.', versioning: - '`versioning` was removed from the spec in 16.0 (#2377, ADR-0049) — no ' + + '`versioning` was removed from the spec in 16.0 (ADR-0049) — no ' + 'record-versioning engine ever read it (it snapshotted no history). Use ' + 'per-field `Field.trackHistory` for field-level history, or a data ' + 'lifecycle policy (`lifecycle`) for retention.', search: '`search` (the SearchConfig block) was removed from the spec in 16.0 ' + - '(#2377, ADR-0049) — no search-engine config was consumed. Declare the ' + + '(ADR-0049) — no search-engine config was consumed. Declare the ' + 'indexed fields with `searchableFields` (ADR-0061); records stay queryable ' + 'via the normal data API regardless.', recordName: - '`recordName` was removed from the spec in 16.0 (#2377, ADR-0049) — it was ' + + '`recordName` was removed from the spec in 16.0 (ADR-0049) — it was ' + 'never read. Auto-naming is modelled as a `Field` of type \'autonumber\' ' + '(with `autonumberFormat`) designated as the object\'s `nameField`.', keyPrefix: - '`keyPrefix` was removed from the spec in 16.0 (#2377, ADR-0049) — record ' + + '`keyPrefix` was removed from the spec in 16.0 (ADR-0049) — record ' + 'ids are not prefixed from it (no Salesforce-style key-prefix runtime). ' + 'Remove the key; it had no effect.', tags: - '`tags` (object-level categorization) was removed from the spec (#2377, ' + + '`tags` (object-level categorization) was removed from the spec (' + 'ADR-0049) — it had no runtime reader. Remove the key; use `managedBy` for ' + 'lifecycle bucketing or a real field for per-record tagging.', active: - '`active` was removed from the spec (#2377, ADR-0049) — no runtime reader ' + + '`active` was removed from the spec (ADR-0049) — no runtime reader ' + 'gated on it, so an "inactive" object was still fully queryable and usable. ' + 'Remove the key; gate availability with permissions/sharing instead.', abstract: - '`abstract` was removed from the spec (#2377, ADR-0049) — object ' + + '`abstract` was removed from the spec (ADR-0049) — object ' + 'inheritance/abstraction is not implemented, so an abstract object still ' + 'got a table and was instantiable. Remove the key.', }; @@ -1548,8 +1548,8 @@ const ObjectSchemaBase = strictObject( aliases: { capabilities: 'enable', features: 'enable' }, guidance: UNKNOWN_KEY_GUIDANCE, history: - 'Until #4001 closed this shape these were dropped silently on the PARSE path — ' - + '`ObjectSchema.create()` has rejected them since #1535, but `defineStack({ objects })`, ' + 'Until this shape was closed these were dropped silently on the PARSE path — ' + + '`ObjectSchema.create()` has rejected them, but `defineStack({ objects })`, ' + '`/api/v1/meta/types/object` and the Studio form all go through `parse()`, which did not.', }, { @@ -1716,7 +1716,7 @@ const ObjectSchemaBase = strictObject( userActions: strictObject({ surface: "this object's `userActions` block", history: - 'Until #4001 these were dropped silently — the block still parsed, so an affordance ' + + 'Until this shape was closed, these were dropped silently — the block still parsed, so an affordance ' + 'the author meant to hide stayed on the toolbar, and the `managedBy`-derived ' + 'default silently won.', aliases: { @@ -1845,7 +1845,7 @@ const ObjectSchemaBase = strictObject( strictObject({ surface: "this object's `systemFields` block", history: - 'Until #4001 these were dropped silently — the block still parsed, so an ' + + 'Until this shape was closed, these were dropped silently — the block still parsed, so an ' + 'opt-out the author wrote had no effect and the registry injected the column ' + 'anyway.', aliases: { @@ -2001,7 +2001,7 @@ const ObjectSchemaBase = strictObject( activityMilestones: z.array(strictObject({ surface: 'this activity milestone', history: - 'Until #4001 these were dropped silently — the milestone still parsed, so a ' + + 'Until this shape was closed, these were dropped silently — the milestone still parsed, so a ' + 'mis-keyed template shipped a timeline row with the wrong text, or the milestone ' + 'never fired at all.', aliases: { @@ -2100,7 +2100,7 @@ const ObjectSchemaBase = strictObject( * it. objectui's `ObjectSchemaClientExtensions.editMode` mirror retires in a * release-gated follow-up and the spec derivation carries it from then on. */ - editMode: z.enum(['modal', 'page']).optional().describe("Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (#11408, #10144 family)."), + editMode: z.enum(['modal', 'page']).optional().describe("Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family)."), /** * Built-in List Views @@ -2134,7 +2134,7 @@ const ObjectSchemaBase = strictObject( /** * Search Engine Config */ - searchableFields: z.array(z.string()).optional().describe('Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused (#6674) — mirror the value onto a stored text field and declare that.'), + searchableFields: z.array(z.string()).optional().describe('Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that.'), /** * System Capabilities @@ -2187,7 +2187,7 @@ const ObjectSchemaBase = strictObject( publicSharing: strictObject({ surface: "this object's `publicSharing` policy", history: - 'Until #4001 these were dropped silently — the policy still parsed, so a redaction ' + + 'Until this shape was closed, these were dropped silently — the policy still parsed, so a redaction ' + 'list or an expiry cap the author wrote was never applied to the links the platform ' + 'went on to issue. On a policy whose whole job is to be restrictive, a silently ' + 'dropped key fails OPEN.', @@ -2983,7 +2983,7 @@ export type ObjectOwnership = z.input; export const ObjectExtensionSchema = lazySchema(() => strictObject({ surface: 'this object extension', history: - 'Until #4001 these were dropped silently — the extension still parsed and still ' + + 'Until this shape was closed, these were dropped silently — the extension still parsed and still ' + 'registered, so fields or rules an author meant to merge into someone else\'s object ' + 'simply never arrived, on a surface where the target is owned by another package and ' + 'the absence is easy to blame on precedence.', diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 8e8ffe15b2..429d501a7d 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -54,7 +54,7 @@ export const SortNodeSchema = lazySchema(() => strictObject( { surface: 'this sort node', history: - 'Until #4721 an unknown key here was dropped silently and `order` fell back to its `asc` ' + 'Until this shape was closed, an unknown key here was dropped silently and `order` fell back to its `asc` ' + 'default, so a descending request came back ascending — and with `limit`, a different ' + 'set of rows under an ordinary 200.', aliases: { direction: 'order' }, @@ -215,7 +215,7 @@ export const GroupByNodeSchema = lazySchema(() => z.union([ * protocol-17 semantic migration `aggregation-node-distinct-retired`. */ const AGGREGATION_DISTINCT_REMOVED = - '`query.aggregations[].distinct` was removed in @objectstack/spec 17 (#6815, ADR-0049) — ' + '`query.aggregations[].distinct` was removed in @objectstack/spec 17 (ADR-0049) — ' + 'exactly ONE of the six faces that read an aggregation honoured it. The objectql in-memory ' + 'fallback deduplicated the values before applying the function, while `driver-sql`, ' + '`driver-turso`, `driver-mongodb`, `driver-memory` and the service-analytics SQL builder ' @@ -224,7 +224,7 @@ const AGGREGATION_DISTINCT_REMOVED = + 'datasource: one query, two numbers, chosen by which backend happened to serve it. Both ' + 'answers are plausible, so nothing surfaced the divergence. Delete the key. For a ' + 'deduplicated COUNT the live spelling is the `count_distinct` aggregation function, which ' - + 'every SQL face compiles to `COUNT(DISTINCT field)` (#6409) and the in-memory fallback ' + + 'every SQL face compiles to `COUNT(DISTINCT field)` and the in-memory fallback ' + 'computes identically. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no ' + 'backend ever computed them here, and a per-row measure that needs deduplicating is a ' + 'modelling problem to fix in the data, not a flag on the read.'; @@ -282,7 +282,7 @@ export const AggregationNodeSchema = lazySchema(() => z.object({ * Over a group whose rows the filter excludes entirely, count/sum answer 0 * and avg/min/max answer null (`emptyGroupValueFor`, aggregation-policy.ts). */ - filter: FilterConditionSchema.optional().describe('Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate (#10576): lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it.'), + filter: FilterConditionSchema.optional().describe('Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate: lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it.'), })); // ─── Joins: REMOVED (#4286, ADR-0049) ──────────────────────────────────────── @@ -375,7 +375,7 @@ export const FieldNodeSchema = z.string({ * semantic migrations `query-joins-retired` / `query-window-functions-retired`). */ const QUERY_JOINS_REMOVED = - '`query.joins` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no engine or driver ' + '`query.joins` was removed in @objectstack/spec 17 (ADR-0049) — no engine or driver ' + 'ever read it: a query carrying `joins` behaved exactly as if the key were absent, while ' + 'its name squatted on the reserved REST parameter set. Delete the key. Related records are ' + "read through `expand` — `expand: { owner_id: { object: 'user', fields: ['name'] } }` — which " @@ -383,7 +383,7 @@ const QUERY_JOINS_REMOVED = + "record's own columns. Keep the foreign key in your own projection (`fields: ['title', " + "'owner_id']`): the relation is carried by that column, so projecting it away leaves " + 'expansion nothing to resolve. A dotted `fields` path is NOT a replacement — no driver ever ' - + 'resolved one and the ingress refuses it (`400 INVALID_FIELD`, #7532).'; + + 'resolved one and the ingress refuses it (`400 INVALID_FIELD`).'; /** * Exported (unlike the two above) because `EngineQueryOptionsSchema` @@ -391,7 +391,7 @@ const QUERY_JOINS_REMOVED = * same prescription — one string, two rejection sites. */ export const QUERY_CURSOR_REMOVED = - '`query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever ' + '`query.cursor` was removed in @objectstack/spec 17 (ADR-0049) — no driver ever ' + 'implemented keyset pagination, so the cursor was accepted and ignored and every page came ' + 'back identical (a caller looping "until hasMore is false" never terminates). Delete the ' + 'key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary ' @@ -402,7 +402,7 @@ export const QUERY_CURSOR_REMOVED = /** See {@link QUERY_CURSOR_REMOVED} for why this one is exported. */ export const QUERY_DISTINCT_REMOVED = - '`query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no ' + '`query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no ' + "driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: " + 'the REST list path treated a distinct query as not countable and silently degraded ' + '`total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete ' @@ -412,7 +412,7 @@ export const QUERY_DISTINCT_REMOVED = + 'count, the `count_distinct` aggregation.'; const QUERY_WINDOW_FUNCTIONS_REMOVED = - '`query.windowFunctions` was removed in @objectstack/spec 17 (#4286, ADR-0049) — `find()` ' + '`query.windowFunctions` was removed in @objectstack/spec 17 (ADR-0049) — `find()` ' + 'never applied it: no engine or driver read the key on the query path, so every OVER ' + 'clause it declared was silently dropped. Delete the key. Window functions are a ' + 'SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` ' @@ -439,12 +439,12 @@ const QUERY_WINDOW_FUNCTIONS_REMOVED = export const FullTextSearchSchema = lazySchema(() => z.object({ query: z.string().describe('Search query text'), fields: z.array(z.string()).optional().describe('Fields to search in (if not specified, searches all text fields)'), - fuzzy: z.boolean().optional().default(false).describe('[EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag (#4286).'), - operator: z.enum(['and', 'or']).optional().default('or').describe('[EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag (#4286).'), - boost: z.record(z.string(), z.number()).optional().describe('[EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results (#4286).'), - minScore: z.number().optional().describe('[EXPERIMENTAL — not enforced] Minimum relevance score threshold. No executor scores results (#4286).'), - language: z.string().optional().describe('[EXPERIMENTAL — not enforced] Language for text analysis (e.g., "en", "zh", "es"). No executor selects an analyzer (#4286).'), - highlight: z.boolean().optional().default(false).describe('[EXPERIMENTAL — not enforced] Search result highlighting. No executor emits highlights (#4286).'), + fuzzy: z.boolean().optional().default(false).describe('[EXPERIMENTAL — not enforced] Fuzzy matching (tolerate typos). The ADR-0061 expansion reads only `query` + `fields`; no executor receives this flag.'), + operator: z.enum(['and', 'or']).optional().default('or').describe('[EXPERIMENTAL — not enforced] Logical operator between terms. The ADR-0061 expansion applies its own term semantics; no executor receives this flag.'), + boost: z.record(z.string(), z.number()).optional().describe('[EXPERIMENTAL — not enforced] Field-specific relevance boosting (field name -> boost factor). No executor scores results.'), + minScore: z.number().optional().describe('[EXPERIMENTAL — not enforced] Minimum relevance score threshold. No executor scores results.'), + language: z.string().optional().describe('[EXPERIMENTAL — not enforced] Language for text analysis (e.g., "en", "zh", "es"). No executor selects an analyzer.'), + highlight: z.boolean().optional().default(false).describe('[EXPERIMENTAL — not enforced] Search result highlighting. No executor emits highlights.'), })); export type FullTextSearch = z.input; @@ -506,7 +506,7 @@ const BaseQuerySchema = z.object({ object: z.string().describe('Object name (e.g. account)'), /** Select Clause */ - fields: z.array(FieldNodeSchema).optional().describe("Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD` (#7532). Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes (#6924)."), + fields: z.array(FieldNodeSchema).optional().describe("Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes."), /** Where Clause (Filtering) */ where: FilterConditionSchema.optional().describe('Filtering criteria (WHERE)'), diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts index b048725fbe..5fbf6b38c3 100644 --- a/packages/spec/src/data/seed.zod.ts +++ b/packages/spec/src/data/seed.zod.ts @@ -33,7 +33,7 @@ export const SeedMode = z.enum([ export const SeedSchema = lazySchema(() => strictObject({ surface: 'this seed', history: - 'Until #4001 these were dropped silently — the seed still applied, on the defaults ' + 'Until this shape was closed, these were dropped silently — the seed still applied, on the defaults ' + '(`mode: upsert`, `externalId: name`) rather than what was written.', aliases: { objectname: 'object', diff --git a/packages/spec/src/data/validation.zod.ts b/packages/spec/src/data/validation.zod.ts index 9b59988aff..67dec46237 100644 --- a/packages/spec/src/data/validation.zod.ts +++ b/packages/spec/src/data/validation.zod.ts @@ -145,7 +145,7 @@ const BASE_VALIDATION_SHAPE = { export const ScriptValidationSchema = lazySchema(() => strictObject({ surface: 'this script validation rule', history: - 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', aliases: { formula: 'condition', expression: 'condition', predicate: 'condition', rule: 'condition' }, }, { ...BASE_VALIDATION_SHAPE, @@ -160,14 +160,14 @@ export const ScriptValidationSchema = lazySchema(() => strictObject({ export const StateMachineValidationSchema = lazySchema(() => strictObject({ surface: 'this state-machine validation rule', history: - 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', aliases: { states: 'transitions', statefield: 'field', from: 'transitions', initial: 'initialStates', initialstate: 'initialStates' }, }, { ...BASE_VALIDATION_SHAPE, type: z.literal('state_machine'), field: z.string().describe('State field (e.g. status)'), transitions: z.record(z.string(), z.array(z.string())).describe('Map of { OldState: [AllowedNewStates] }'), - initialStates: z.array(z.string()).optional().describe('States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert). #3165.'), + initialStates: z.array(z.string()).optional().describe('States a record may be CREATED in. When set, an INSERT whose state field carries a value outside this list is rejected (server-enforced) — the FSM entry point. `transitions` only governs UPDATE, and a `select` field permits ANY declared option as an initial value, so without this a record could be born mid-flow (e.g. created already `approved`). Omit to keep the legacy behavior (no initial-state check on insert).'), })); /** @@ -177,7 +177,7 @@ export const StateMachineValidationSchema = lazySchema(() => strictObject({ export const FormatValidationSchema = lazySchema(() => strictObject({ surface: 'this format validation rule', history: - 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', aliases: { pattern: 'regex', fieldname: 'field' }, }, { ...BASE_VALIDATION_SHAPE, @@ -259,7 +259,7 @@ export const FormatValidationSchema = lazySchema(() => strictObject({ export const CrossFieldValidationSchema = lazySchema(() => strictObject({ surface: 'this cross-field validation rule', history: - 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', aliases: { formula: 'condition', expression: 'condition' }, }, { ...BASE_VALIDATION_SHAPE, @@ -280,7 +280,7 @@ export const CrossFieldValidationSchema = lazySchema(() => strictObject({ export const JSONValidationSchema = lazySchema(() => strictObject({ surface: 'this JSON-schema validation rule', history: - 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', aliases: { jsonschema: 'schema', fieldname: 'field' }, }, { ...BASE_VALIDATION_SHAPE, @@ -511,7 +511,7 @@ export const ValidationRuleSchema: z.ZodType strictObject({ surface: 'this conditional validation rule', history: - 'Until #4001 closed this shape these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the rule still registered and ran, minus whatever the key was meant to configure.', aliases: { if: 'when', condition: 'when', match: 'when', else: 'otherwise' }, }, { ...BASE_VALIDATION_SHAPE, diff --git a/packages/spec/src/identity/position.zod.ts b/packages/spec/src/identity/position.zod.ts index 1204981182..989e07cae4 100644 --- a/packages/spec/src/identity/position.zod.ts +++ b/packages/spec/src/identity/position.zod.ts @@ -69,11 +69,11 @@ export const PositionSchema = lazySchema(() => strictObject( 'created in Setup or by an app\'s kernel:ready binder). The `sys_position` ' + 'row column of the same name — a "JSON-serialized array of permission ' + 'strings" textarea no producer ever wrote and no runtime path ever read — ' + - 'was retired under ADR-0049 enforce-or-remove (#9885). Delete the key; to ' + + 'was retired under ADR-0049 enforce-or-remove. Delete the key; to ' + 'grant capability, bind permission sets to the position instead.', }, history: - 'Until #4001 these were dropped silently — the position still parsed, so the ' + + 'Until this shape was closed, these were dropped silently — the position still parsed, so the ' + 'author believed a distribution property was declared that the runtime never saw.', }, { diff --git a/packages/spec/src/integration/connector-author-shape.test.ts b/packages/spec/src/integration/connector-author-shape.test.ts index 1aecde7815..d801078bfc 100644 --- a/packages/spec/src/integration/connector-author-shape.test.ts +++ b/packages/spec/src/integration/connector-author-shape.test.ts @@ -356,7 +356,7 @@ describe('[#5515] the schema rejects them at RUNTIME too, and how it says so', ( expect(result.success).toBe(false); const issues = result.error!.issues; expect(issues[0]!.code).toBe('unrecognized_keys'); - expect(issues[0]!.message).toContain('#3494'); + expect(issues[0]!.message).toContain('`retryPolicy` was removed'); expect(issues[0]!.message).toContain('There is no replacement'); }); @@ -399,7 +399,7 @@ describe('[#5515] the schema rejects them at RUNTIME too, and how it says so', ( expect(result.success).toBe(false); const issue = result.error!.issues.find((i) => i.path.join('.') === 'transform'); expect(issue).toBeDefined(); - expect(issue!.message).toMatch(/`FieldMapping\.transform`.*removed.*#5552/s); + expect(issue!.message).toMatch(/`FieldMapping\.transform`.*removed/s); // It must point at the transform pipeline that DOES run, not just refuse. expect(issue!.message).toMatch(/mapping\.fieldMapping\[\]\.transform/s); } diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index 9eacd0526e..a4e516d59b 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -994,7 +994,7 @@ describe('[#4703] FieldMapping no longer names three declarations', () => { ]) { const result = schema.safeParse({ source: 'a', target: 'b', transform: unionForm }); expect(result.success).toBe(false); - expect(result.error!.issues.some((i) => /#5552/.test(i.message))).toBe(true); + expect(result.error!.issues.some((i) => /FieldMappingTransform/.test(i.message))).toBe(true); } // The enum form does not get in either — retired is retired, whatever the // value's shape. diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 2d23a42724..a2794eebde 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -342,7 +342,7 @@ export const WebhookConfigSchema = lazySchema(() => WebhookSchema.extend({ * Events to listen for * Connector-specific events like sync completion, auth expiry, etc. */ - events: z.array(WebhookEventSchema).optional().describe('Connector events to subscribe to (not yet enforced — no runtime dispatches these; see #3197)'), + events: z.array(WebhookEventSchema).optional().describe('Connector events to subscribe to '), /** * Signature algorithm for webhook security @@ -729,7 +729,7 @@ export const ConnectorSchema = lazySchema(() => z.object({ * because a published row lands whole in `sys_metadata`. */ authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' }).describe( - 'Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets (#7990): use `auth.credentialRef` on a provider-bound instance.', + 'Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance.', ), /** @@ -776,7 +776,7 @@ export const ConnectorSchema = lazySchema(() => z.object({ /** Zapier-style Capabilities */ actions: z.array(ConnectorActionSchema).optional(), - triggers: z.array(ConnectorTriggerSchema).optional().describe('Trigger definitions (not yet enforced — never read at registration; see #3197)'), + triggers: z.array(ConnectorTriggerSchema).optional().describe('Trigger definitions '), /** * Data synchronization configuration @@ -792,7 +792,7 @@ export const ConnectorSchema = lazySchema(() => z.object({ /** * Webhook configuration */ - webhooks: z.array(WebhookConfigSchema).optional().describe('Webhook configurations (not yet enforced — never read at registration; see #3197)'), + webhooks: z.array(WebhookConfigSchema).optional().describe('Webhook configurations '), /** * REMOVED (#4911) — outbound rate limiting. See the block above @@ -802,7 +802,7 @@ export const ConnectorSchema = lazySchema(() => z.object({ * two channels an upgrading author actually hits — `tsc` and the parse. */ rateLimitConfig: retiredKey( - '`connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (#4911, ADR-0049 D2) — ' + + '`connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — ' + 'the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its ' + '`RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ' + 'ever existed. The platform\'s only token bucket (runtime `security/rate-limit.ts`) throttles ' + @@ -839,7 +839,7 @@ export const ConnectorSchema = lazySchema(() => z.object({ * the boot audit warning for declared-but-unregistered connectors (#2612). */ enabled: z.boolean().optional().default(true).describe( - 'Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612).', + 'Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor.', ), /** diff --git a/packages/spec/src/kernel/activation-events-retirement.test.ts b/packages/spec/src/kernel/activation-events-retirement.test.ts index 99f39b01d7..58adc6b6a5 100644 --- a/packages/spec/src/kernel/activation-events-retirement.test.ts +++ b/packages/spec/src/kernel/activation-events-retirement.test.ts @@ -111,6 +111,6 @@ describe('[#4657] ActivationEventSchema removal — no entry exports the name', ...manifest, activationEvents: [{ type: 'onStartup', pattern: '*' }], }), - ).toThrow(/activationEvents.*removed.*#4657/s); + ).toThrow(/activationEvents.*removed/s); }); }); diff --git a/packages/spec/src/kernel/additional-types-retirement.test.ts b/packages/spec/src/kernel/additional-types-retirement.test.ts index 8cd6fa6e29..1938be78ba 100644 --- a/packages/spec/src/kernel/additional-types-retirement.test.ts +++ b/packages/spec/src/kernel/additional-types-retirement.test.ts @@ -56,7 +56,7 @@ describe('[#8586] MetadataPluginConfig.additionalTypes retirement', () => { expect(issue!.path).toEqual(['additionalTypes']); // The prescription itself — this string IS the migration doc for whoever // hits it, so it is contract, not commentary. - expect(issue!.message).toMatch(/`config\.additionalTypes`.*removed.*17.*#8586/s); + expect(issue!.message).toMatch(/`config\.additionalTypes`.*removed.*17/s); expect(issue!.message).toMatch(/Delete the key/s); // The live mechanism must be named: how a kind ACTUALLY enters the set. expect(issue!.message).toMatch(/registering an ITEM/s); diff --git a/packages/spec/src/kernel/context.zod.ts b/packages/spec/src/kernel/context.zod.ts index 6b0678a709..0b58d48da5 100644 --- a/packages/spec/src/kernel/context.zod.ts +++ b/packages/spec/src/kernel/context.zod.ts @@ -28,7 +28,7 @@ const RUNTIME_MODE_PREVIEW_RETIRED = + 'experience becomes a product capability it re-declares fresh, with the ' + 'production-posture hard-refusal as the first-landed half (#11846 ruling record).'; const PREVIEW_MODE_RETIRED = - '`context.previewMode` was removed in @objectstack/spec 17 (#11846, ADR-0049 ' + '`context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 ' + 'enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, ' + '`simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) ' + 'had a consumer in any repo, so an authored block parsed cleanly and configured ' @@ -38,7 +38,7 @@ const PREVIEW_MODE_RETIRED = + 'which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); ' + '`OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience ' + 'becomes a product capability it re-declares fresh, with the production-posture ' - + 'hard-refusal as the first-landed half (#11846 ruling record).'; + + 'hard-refusal as the first-landed half (ruling record).'; /** * Runtime Mode Enum diff --git a/packages/spec/src/kernel/execution-context.test.ts b/packages/spec/src/kernel/execution-context.test.ts index a116bb36c1..4fcdf01865 100644 --- a/packages/spec/src/kernel/execution-context.test.ts +++ b/packages/spec/src/kernel/execution-context.test.ts @@ -176,8 +176,8 @@ describe('ExecutionContextSchema.preserveAudit — the published description (#6 expect(description).toMatch(/\bINSERT\b/); }); - it('states that the exemption does NOT reach INSERT, anchored to #6640', () => { - expect(description).toMatch(/#6640/); + it('states that the exemption does NOT reach INSERT', () => { + expect(description).toMatch(/the exemption does NOT apply/); expect(description).toMatch(/\bnot\b/i); }); diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index afeb15856b..b5707420bc 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -389,7 +389,7 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ * field-level security are unaffected: this changes only which audit/readonly * values the runtime overwrites, never who may write the record. */ - preserveAudit: z.boolean().optional().describe('Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now" (#3493). Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply (#6640): a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected.'), + preserveAudit: z.boolean().optional().describe('Historical import: preserve the ORIGINAL audit timeline for this write instead of stamping it "now". Opt-in and server-constructed only, never client-supplied. On the UPDATE path it admits a whitelist — the audit/timestamp family (created_at / created_by / updated_at / updated_by) plus author-declared business `readonly` fields — while platform-managed `system` columns (tenancy, generated) stay stripped. On INSERT the exemption does NOT apply: a create is stripped earlier, at the DataProtocol ingress, whose only exemption is `context.isSystem`, so a non-system create carrying `preserveAudit` still has those fields stripped and is warned (WARN) that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. Permissions / RLS / field-level security are unaffected.'), /** * OAuth 2.1 scopes granted to the access token that authenticated this diff --git a/packages/spec/src/kernel/manifest.test.ts b/packages/spec/src/kernel/manifest.test.ts index 22f55b777d..0e7c52b324 100644 --- a/packages/spec/src/kernel/manifest.test.ts +++ b/packages/spec/src/kernel/manifest.test.ts @@ -449,7 +449,7 @@ describe('contributes dead-member retirement (#10724, ADR-0049 — tombstoned, n ); expect(issue).toBeDefined(); expect(issue!.message).toMatch( - new RegExp(`manifest\\.contributes\\.${member}.*removed in @objectstack/spec 17.*#10724.*Delete the key`, 's'), + new RegExp(`manifest\\.contributes\\.${member}.*removed in @objectstack/spec 17.*Delete the key`, 's'), ); }); @@ -493,7 +493,7 @@ describe('contributes.routes retirement (#10726, ADR-0049 — maintainer-ruled O ); expect(issue).toBeDefined(); expect(issue!.message).toMatch( - /manifest\.contributes\.routes.*removed in @objectstack\/spec 17.*#10726.*Delete the key.*http\.server/s, + /manifest\.contributes\.routes.*removed in @objectstack\/spec 17.*Delete the key.*http\.server/s, ); }); @@ -529,7 +529,7 @@ describe('contributes.kinds[].globs retirement (#11169, ADR-0049 — maintainer- ); expect(issue).toBeDefined(); expect(issue!.message).toMatch( - /manifest\.contributes\.kinds\[\]\.globs.*removed in @objectstack\/spec 17.*#11169.*filePatterns.*Delete the key/s, + /manifest\.contributes\.kinds\[\]\.globs.*removed in @objectstack\/spec 17.*filePatterns.*Delete the key/s, ); }); diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index ddb98ef47c..3890bc98a3 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -350,7 +350,7 @@ export const ManifestSchema = z.object({ id: z.string().describe('The generic identifier of the kind (e.g., "sys.bi.report")'), /** REMOVED (#11169) — discovery reads the metadata type registry's `filePatterns`, never this. */ globs: retiredKey( - '`manifest.contributes.kinds[].globs` was removed in @objectstack/spec 17 (#11169, ' + + '`manifest.contributes.kinds[].globs` was removed in @objectstack/spec 17 (' + 'ADR-0049 enforce-or-remove) — it never had an effect: file-type discovery globs ' + '`filePatterns` off the metadata type registry, which `contributes.kinds` does not ' + 'extend (`metadata-plugin.zod.ts` states this outright), so the watch patterns ' + @@ -364,7 +364,7 @@ export const ManifestSchema = z.object({ /** REMOVED (#10724) — the declaration drove nothing; subscribe in plugin code. */ events: retiredKey( - '`manifest.contributes.events` was removed in @objectstack/spec 17 (#10724, ' + + '`manifest.contributes.events` was removed in @objectstack/spec 17 (' + 'ADR-0049 enforce-or-remove) — nothing ever read the list: its only in-repo ' + 'author already subscribed imperatively in plugin code, so the declaration was ' + 'decorative. Delete the key. Subscribe to system events in the plugin itself — ' + @@ -374,7 +374,7 @@ export const ManifestSchema = z.object({ /** REMOVED (#10724) — use app `navigation` / `manifest.navigationContributions`. */ menus: retiredKey( - '`manifest.contributes.menus` was removed in @objectstack/spec 17 (#10724, ' + + '`manifest.contributes.menus` was removed in @objectstack/spec 17 (' + 'ADR-0049 enforce-or-remove) — no renderer ever read it; two alias maps already ' + 'redirected this spelling to `navigation`. Delete the key. Declare navigation in ' + "the app's `navigation` tree, or inject items into another package's app via " + @@ -383,7 +383,7 @@ export const ManifestSchema = z.object({ /** REMOVED (#10724) — this `{id,label,path}` shape had no reader anywhere. */ themes: retiredKey( - '`manifest.contributes.themes` was removed in @objectstack/spec 17 (#10724, ' + + '`manifest.contributes.themes` was removed in @objectstack/spec 17 (' + 'ADR-0049 enforce-or-remove) — it never had an effect: theme registration reaches ' + 'the registry only through the stack-level `themes` collection (a ' + '`ThemeSchema` surface, unrelated to this `{ id, label, path }` shape), never ' + @@ -394,7 +394,7 @@ export const ManifestSchema = z.object({ /** REMOVED (#10724) — use the `translation` metadata type / stack `translations`. */ translations: retiredKey( '`manifest.contributes.translations` was removed in @objectstack/spec 17 ' + - '(#10724, ADR-0049 enforce-or-remove) — no loader ever read these `{ locale, ' + + '(ADR-0049 enforce-or-remove) — no loader ever read these `{ locale, ' + 'path }` entries; authoring them registered no translations. Delete the key. ' + 'Declare translations as `translation` metadata: `defineTranslationBundle({ … })` ' + "in the stack's `translations` collection (`defineStack({ translations: […] })`), " + @@ -403,7 +403,7 @@ export const ManifestSchema = z.object({ /** REMOVED (#10724) — use the stack `actions` collection / `registerAction`. */ actions: retiredKey( - '`manifest.contributes.actions` was removed in @objectstack/spec 17 (#10724, ' + + '`manifest.contributes.actions` was removed in @objectstack/spec 17 (' + 'ADR-0049 enforce-or-remove) — nothing ever read it; actions declared here were ' + 'never invocable. Delete the key. Declare actions in the stack `actions` ' + 'collection (registered by the engine) or register imperatively via ' + @@ -412,7 +412,7 @@ export const ManifestSchema = z.object({ /** REMOVED (#10724) — a driver is a `driver.*` kernel service, not a declaration. */ drivers: retiredKey( - '`manifest.contributes.drivers` was removed in @objectstack/spec 17 (#10724, ' + + '`manifest.contributes.drivers` was removed in @objectstack/spec 17 (' + 'ADR-0049 enforce-or-remove) — it never had an effect: a storage driver is wired ' + 'by registering a kernel SERVICE named `driver.*` (the objectql plugin picks it ' + 'up and calls `registerDriver`), and its only in-repo author was registered that ' + @@ -421,7 +421,7 @@ export const ManifestSchema = z.object({ /** REMOVED (#10724) — no field-type registration seam exists. */ fieldTypes: retiredKey( - '`manifest.contributes.fieldTypes` was removed in @objectstack/spec 17 (#10724, ' + + '`manifest.contributes.fieldTypes` was removed in @objectstack/spec 17 (' + 'ADR-0049 enforce-or-remove) — there is no `registerFieldType` seam anywhere: ' + 'the declaration advertised an extension point the platform does not have, so ' + 'authoring it configured nothing. Delete the key. The field-type vocabulary is ' + @@ -431,7 +431,7 @@ export const ManifestSchema = z.object({ /** REMOVED (#10724) — use `defineStack({ functions })` → `registerFunction`. */ functions: retiredKey( - '`manifest.contributes.functions` was removed in @objectstack/spec 17 (#10724, ' + + '`manifest.contributes.functions` was removed in @objectstack/spec 17 (' + 'ADR-0049 enforce-or-remove) — nothing ever read it; ObjectQL functions declared ' + 'here were never registered. Delete the key. Declare functions on the stack ' + '(`defineStack({ functions: […] })`), which the hook binder registers via ' + @@ -440,7 +440,7 @@ export const ManifestSchema = z.object({ /** REMOVED (#10726) — mount code-handler routes on the `http.server` service; declarative endpoints are `defineStack({ apis })`. */ routes: retiredKey( - '`manifest.contributes.routes` was removed in @objectstack/spec 17 (#10726, ' + + '`manifest.contributes.routes` was removed in @objectstack/spec 17 (' + 'ADR-0049 enforce-or-remove) — nothing ever read it: the HttpDispatcher never ' + 'registered a prefix from the declaration, so an entry here parsed cleanly and ' + 'served nothing while published material kept recommending it. Delete the key. ' + @@ -458,7 +458,7 @@ export const ManifestSchema = z.object({ * `objectstack.config.ts` plugins array no longer determines CLI commands." */ commands: retiredKey( - '`manifest.contributes.commands` was removed in @objectstack/spec 17 (#10724, ' + + '`manifest.contributes.commands` was removed in @objectstack/spec 17 (' + 'ADR-0049 enforce-or-remove) — the CLI never resolved commands from this ' + 'declaration: commands are auto-discovered through oclif\'s native plugin system ' + "(the plugin package declares an `oclif` section in its own `package.json`; see " + @@ -521,7 +521,7 @@ export const ManifestSchema = z.object({ * made this a security concern and not merely tidying. */ loading: retiredKey( - '`manifest.loading` was removed in @objectstack/spec 17.0.0 (#4914, ADR-0049 ' + + '`manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 ' + 'enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, ' + '`dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, ' + '`sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it ' + diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index ae19c3eb53..bc998cb102 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -526,7 +526,7 @@ export const MetadataPluginConfigSchema = lazySchema(() => z.object({ */ additionalTypes: retiredKey( '`config.additionalTypes` was removed from `MetadataPluginConfig` in @objectstack/spec 17 ' + - '(#8586, ADR-0049 enforce-or-remove) — it never had an effect: the only production writer ' + + '(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 ' + diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts index 2bc223877d..4cc6503e07 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts @@ -84,8 +84,9 @@ describe('Plugin Lifecycle Advanced Schemas', () => { // whoever hits it. Assert the load-bearing clauses, not the bytes. const message = result.success ? '' : result.error.issues[0]?.message ?? ''; expect(message).toContain('was removed'); - expect(message).toContain('#12032'); expect(message).toContain('ADR-0049'); + expect(message, 'the ADR is the durable reference; the tracker id is not') + .not.toMatch(/#\d{3,5}/); expect(message).toContain('Delete the key'); // The measured fact, in every one of the three prescriptions: the // restart was only ever a destroy. @@ -263,7 +264,7 @@ describe('Plugin Lifecycle Advanced Schemas', () => { // whoever hits it. Assert the load-bearing clauses, not the byte string. const message = result.success ? '' : result.error.issues[0]?.message ?? ''; expect(message).toContain('was removed'); - expect(message).toContain('#12428'); + expect(message).toContain('nothing ever read it'); expect(message).toContain('ADR-0049'); expect(message).toContain('scheduleReload'); diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts index 184ea7c495..73278a66ff 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts @@ -77,22 +77,22 @@ const RESTART_REPLACEMENT = const AUTO_RESTART_RETIRED = '`PluginHealthCheck.autoRestart` was removed in @objectstack/spec 18 ' - + '(#12032, ADR-0049 enforce-or-remove) — it never restarted a plugin. ' + + '(ADR-0049 enforce-or-remove) — it never restarted a plugin.' + RESTART_NOT_IMPLEMENTED + ' Delete the key. ' + RESTART_REPLACEMENT; const MAX_RESTART_ATTEMPTS_RETIRED = '`PluginHealthCheck.maxRestartAttempts` was removed in @objectstack/spec 18 ' - + '(#12032, ADR-0049 enforce-or-remove) — it capped a restart that never ' - + 'happened. ' + RESTART_NOT_IMPLEMENTED + + '(ADR-0049 enforce-or-remove) — it capped a restart that never ' + + 'happened.' + RESTART_NOT_IMPLEMENTED + ' The cap counted destroy calls, so raising it only scheduled further ' + '"restarts" of a plugin that was never brought back up. Delete the key. ' + RESTART_REPLACEMENT; const RESTART_BACKOFF_RETIRED = '`PluginHealthCheck.restartBackoff` was removed in @objectstack/spec 18 ' - + '(#12032, ADR-0049 enforce-or-remove) — it delayed a restart that never ' - + 'happened. ' + RESTART_NOT_IMPLEMENTED + + '(ADR-0049 enforce-or-remove) — it delayed a restart that never ' + + 'happened.' + RESTART_NOT_IMPLEMENTED + ' The chosen strategy only moved when the destroy landed. Delete the key. ' + RESTART_REPLACEMENT; @@ -252,7 +252,7 @@ const HOT_RELOAD_STATE_STRATEGY_RETIRED = */ const HOT_RELOAD_WATCH_PATTERNS_RETIRED = '`HotReloadConfig.watchPatterns` was removed in @objectstack/spec 18 ' - + '(#12428, ADR-0049 enforce-or-remove) — nothing ever read it. Its only two ' + + '(ADR-0049 enforce-or-remove) — nothing ever read it. Its only two ' + 'uses were log lines in `HotReloadManager`, and one of them announced ' + "'File watching started' at INFO level while no watcher was ever " + 'constructed: `startWatching` held a placeholder, and `watchHandles` was ' diff --git a/packages/spec/src/kernel/plugin-loading-retirement.test.ts b/packages/spec/src/kernel/plugin-loading-retirement.test.ts index 849cafc5a3..523062a98c 100644 --- a/packages/spec/src/kernel/plugin-loading-retirement.test.ts +++ b/packages/spec/src/kernel/plugin-loading-retirement.test.ts @@ -63,7 +63,7 @@ describe('[#4914] manifest.loading retirement', () => { expect(issue!.path).toEqual(['loading']); // The prescription itself — this string IS the migration doc for whoever // hits it, so it is contract, not commentary. - expect(issue!.message).toMatch(/`manifest\.loading`.*removed.*17\.0\.0.*#4914/s); + expect(issue!.message).toMatch(/`manifest\.loading`.*removed.*17\.0\.0/s); expect(issue!.message).toMatch(/Delete the key/s); }); diff --git a/packages/spec/src/kernel/preview-mode-retirement.test.ts b/packages/spec/src/kernel/preview-mode-retirement.test.ts index 1af469a3f5..683dfe71b0 100644 --- a/packages/spec/src/kernel/preview-mode-retirement.test.ts +++ b/packages/spec/src/kernel/preview-mode-retirement.test.ts @@ -71,7 +71,7 @@ describe("[#11846] RuntimeMode 'preview' retirement", () => { const message = JSON.stringify(result.error.issues); // The prescription itself, not a bare "invalid enum value": it must name // the value, say it was removed, and tell the author what to do instead. - expect(message).toMatch(/`context\.mode: 'preview'`.*was removed.*17.*#11846/s); + expect(message).toMatch(/`context\.mode: 'preview'`.*was removed.*17/s); expect(message).toMatch(/no layer of the platform ever branched on it/s); expect(message).toMatch(/Delete the value/s); // The live mechanism must be named: preview DEPLOYMENTS are the @@ -94,7 +94,7 @@ describe("[#11846] RuntimeMode 'preview' retirement", () => { const issue = result.error.issues.find((i) => i.path[0] === 'mode'); expect(issue, 'the refusal must land at `mode`').toBeDefined(); - expect(issue!.message).toMatch(/`context\.mode: 'preview'`.*was removed.*#11846/s); + expect(issue!.message).toMatch(/`context\.mode: 'preview'`.*was removed/s); }); it("gives an UNKNOWN mode zod's own message, not the retirement one", () => { @@ -135,7 +135,7 @@ describe('[#11846] KernelContext.previewMode retirement', () => { expect(issue!.path).toEqual(['previewMode']); // The prescription IS the migration doc for whoever hits it — contract, // not commentary. - expect(issue!.message).toMatch(/`context\.previewMode`.*was removed.*17.*#11846/s); + expect(issue!.message).toMatch(/`context\.previewMode`.*was removed.*17/s); expect(issue!.message).toMatch(/nothing ever read the block/s); expect(issue!.message).toMatch(/Delete the key/s); // The live mechanism: the deployment layer owns preview deployments. diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 762c022915..98e35daf70 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -71,7 +71,7 @@ describe('migration chain (ADR-0087 D3)', () => { // pin is on the tense, which is the whole defect: the claim may appear as // history ("was enforced by the shell only"), never as current fact. expect(rationale17()).not.toMatch(/is enforced by the shell only/i); - expect(rationale17()).toMatch(/was CLOSED by #4722/); + expect(rationale17()).toMatch(/was CLOSED by/); }); it('names #4722 and the two trees an item gate is now enforced in', () => { @@ -107,7 +107,7 @@ describe('migration chain (ADR-0087 D3)', () => { // erase it — an upgrading author needs to know the keys were fail-open, // not merely unread. const r = rationale17(); - expect(r).toMatch(/#4651/); + expect(r).toMatch(/areas\[\]\.visible/); expect(r).toMatch(/FAIL-OPEN access gates/); expect(r).toMatch(/At the time of the retirement/); expect(r).toMatch(/must not invent an authorization mechanism/); @@ -194,7 +194,7 @@ describe('migration chain (ADR-0087 D3)', () => { // guard, which is what distinguishes this from a bare "it is enforced now". const a = entry()!.acceptanceCriteria; expect(a).toMatch(/refuseUndeclaredSuspension/); - expect(a).toMatch(/#6667/); + expect(a).toMatch(/refuses that suspension/); expect(a).toMatch(/guard-class/); expect(a).toMatch(/`fault` edge/); }); diff --git a/packages/spec/src/security/capabilities.ts b/packages/spec/src/security/capabilities.ts index a69735b590..1024629645 100644 --- a/packages/spec/src/security/capabilities.ts +++ b/packages/spec/src/security/capabilities.ts @@ -152,7 +152,7 @@ export const CapabilityDeclarationSchema = lazySchema(() => strictObject({ 'separate shapes, and only the requirement side carries arguments).', }, history: - 'Until #5961 `capability` resolved no registered schema at all, so `PUT ' + + 'Until this shape was closed, `capability` resolved no registered schema at all, so `PUT ' + '/api/v1/meta/capability/:name` stored ANY JSON under an AUTHORIZATION type ' + 'and stray keys on a declaration were dropped in silence.', }, { diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index 6f983b6b07..deb060711b 100644 --- a/packages/spec/src/security/permission.test.ts +++ b/packages/spec/src/security/permission.test.ts @@ -132,7 +132,7 @@ describe('allowRestore / allowPurge are RETIRED (#12497, ADR-0049)', () => { const r = ObjectPermissionSchema.safeParse({ [key]: true } as never); expect(r.success).toBe(false); const messages = r.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain('#12497'); + expect(messages).toContain('ObjectQL operation it claimed'); expect(messages).toContain('removed in @objectstack/spec 17'); expect(messages).toContain('Delete the key'); expect(messages).toContain('M2'); @@ -148,7 +148,7 @@ describe('allowRestore / allowPurge are RETIRED (#12497, ADR-0049)', () => { // so this pin proves refusal text === declared prescription, byte for byte. for (const key of ['allowRestore', 'allowPurge'] as const) { const declared = (ObjectPermissionSchema.shape[key].description ?? '').replace(/^\[REMOVED\] /, ''); - expect(declared).toContain('#12497'); + expect(declared).toContain('ObjectQL operation it claimed'); const r = ObjectPermissionSchema.safeParse({ allowRead: true, [key]: true } as never); expect(r.success).toBe(false); const issue = r.error!.issues.find((i) => i.path[i.path.length - 1] === key)!; @@ -167,7 +167,7 @@ describe('allowRestore / allowPurge are RETIRED (#12497, ADR-0049)', () => { const r = ObjectPermissionSchema.safeParse({ [key]: true } as never); expect(r.success).toBe(false); const messages = r.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain('#12497'); + expect(messages).toContain('ObjectQL operation it claimed'); expect(messages).not.toContain(`\`${key}\` → \``); } }); @@ -239,7 +239,7 @@ describe('[#12840] the RETIRED DEFAULT parses as inert residue and strips (class for (const wrong of [true, 0, '', null, 'false'] as const) { const r = ObjectPermissionSchema.safeParse({ allowRestore: wrong } as never); expect(r.success, `value ${JSON.stringify(wrong)} must NOT be tolerated`).toBe(false); - expect(r.error!.issues.map((i) => i.message).join('\n')).toContain('#12497'); + expect(r.error!.issues.map((i) => i.message).join('\n')).toContain('ObjectQL operation it claimed'); } }); diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index eae0a4617c..1c726da8af 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -100,7 +100,7 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject( aliases: OBJECT_PERMISSION_KEY_ALIASES, guidance: { apiOperations: - '`apiOperations` is the server-resolved effective operation set (#3391) — it exists ' + + '`apiOperations` is the server-resolved effective operation set — it exists ' + 'only on the RESPONSE surface (`/me/permissions`) and is never authored. Grant ' + 'capability with the `allow*` bits here; tighten an object\'s exposure with ' + '`apiMethods` on the object schema.', @@ -111,21 +111,21 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject( // prescription directly. restore: '`restore` was the alias of `objects..allowRestore`, which was removed in ' + - '@objectstack/spec 17 (#12497, ADR-0049) — the `restore` ObjectQL operation it claimed ' + + '@objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed ' + 'to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete ' + 'the key — a dispatched `restore` stays denied fail-closed by the permission ' + 'evaluator\'s destructive-operation backstop, and the bit returns with the M2 ' + - 'lifecycle initiative (#1883) alongside the operation it gates.', + 'lifecycle initiative alongside the operation it gates.', purge: '`purge` was the alias of `objects..allowPurge`, which was removed in ' + - '@objectstack/spec 17 (#12497, ADR-0049) — the `purge` ObjectQL operation it claimed ' + + '@objectstack/spec 17 (ADR-0049) — the `purge` ObjectQL operation it claimed ' + 'to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete ' + 'the key — a dispatched `purge` stays denied fail-closed by the permission ' + 'evaluator\'s destructive-operation backstop, and the bit returns with the M2 ' + - 'lifecycle initiative (#1883) alongside the operation it gates.', + 'lifecycle initiative alongside the operation it gates.', }, history: - 'Until #4001 these were dropped silently — the permission set still parsed, so the ' + + 'Until this shape was closed, these were dropped silently — the permission set still parsed, so the ' + 'author believed a grant or restriction was in place that the runtime never saw.', }, { @@ -172,7 +172,7 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject( * (`GET /data/:object/export` → 403 `EXPORT_NOT_PERMITTED`); the frontend * renders that set and never reads this bit directly. */ - allowExport: z.boolean().optional().describe('[#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords.'), + allowExport: z.boolean().optional().describe('User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords.'), /** * Lifecycle Operations. @@ -189,7 +189,7 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject( * grants the ownership-write capability now; the dedicated M2 `transfer` * operation will reuse the same bit. */ - allowTransfer: z.boolean().default(false).describe('[RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id)'), + allowTransfer: z.boolean().default(false).describe('[RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id)'), /** * REMOVED — `allowRestore` / `allowPurge` claimed to gate `restore` / @@ -218,21 +218,21 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject( * it. Every other value still lands here, prescription intact. */ allowRestore: retiredKey( - '`objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — ' + + '`objects..allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — ' + 'the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so ' + 'granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied ' + 'fail-closed by the permission evaluator\'s destructive-operation backstop, and the bit ' + - 'returns with the M2 lifecycle initiative (#1883) alongside the operation it gates. ' + + 'returns with the M2 lifecycle initiative alongside the operation it gates. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', ), allowPurge: retiredKey( - '`objects..allowPurge` was removed in @objectstack/spec 17 (#12497, ADR-0049) — ' + + '`objects..allowPurge` was removed in @objectstack/spec 17 (ADR-0049) — ' + 'the `purge` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so ' + 'granting the bit delivered nothing (a compliance/GDPR erase the author believed was ' + 'permission-locked was not — the operation itself does not exist). Delete the key — a ' + 'dispatched `purge` stays denied fail-closed by the permission evaluator\'s ' + - 'destructive-operation backstop, and the bit returns with the M2 lifecycle initiative ' + - '(#1883) alongside the operation it gates. ' + + 'destructive-operation backstop, and the bit returns with the M2 lifecycle initiative' + + ' alongside the operation it gates. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', ), @@ -264,7 +264,7 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject( * not taken — what moved here is only the declaration, so that it stops * over-claiming (ADR-0049 `declared ≠ enforced`). */ - modifyAllRecords: z.boolean().default(false).describe('Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies (#6698).'), + modifyAllRecords: z.boolean().default(false).describe('Modify All Data (Bypass Sharing) — bypasses sharing rules and ownership on the objects record sharing enforces on; on an object with NO owner field sharing abstains, so the platform created_by write floor still applies.'), /** * [ADR-0057 D1] Read access DEPTH (Dataverse-style access level), layered on @@ -324,7 +324,7 @@ export const EffectiveObjectPermissionSchema = lazySchema(() => acceptRetiredDefaultResidue( (ObjectPermissionBaseSchema as unknown as z.ZodObject).extend({ apiOperations: z.array(ApiOperationSchema).optional().describe( - 'Server-resolved effective API operations for this object (#3391). Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs, #3543), not the authored six-value ApiMethod enum.', + 'Server-resolved effective API operations for this object. Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs), not the authored six-value ApiMethod enum.', ), // WIRE shape: `.extend()` inherits the authoring schema's `.strict()`, and a // strict response parser is forward-incompatible — a newer server adding a @@ -358,7 +358,7 @@ export const AdminScopeSchema = lazySchema(() => strictObject( { surface: 'this admin scope', history: - 'Until #4001 these were dropped silently — the scope still parsed, so a delegation ' + + 'Until this shape was closed, these were dropped silently — the scope still parsed, so a delegation ' + 'boundary the author intended was never enforced.', }, { @@ -404,7 +404,7 @@ export const FieldPermissionSchema = lazySchema(() => strictObject( '`readable: false` to hide the field.', }, history: - 'Until #4001 these were dropped silently — the entry still parsed, so field-level ' + + 'Until this shape was closed, these were dropped silently — the entry still parsed, so field-level ' + 'security the author intended was never applied.', }, { @@ -483,7 +483,7 @@ export const PermissionSetSchema = lazySchema(() => strictObject( '(`sys_user_permission_set` / positions), never authored on the set (ADR-0090).', }, history: - 'Until #4001 these were dropped silently — the set still parsed, so the author ' + + 'Until this shape was closed, these were dropped silently — the set still parsed, so the author ' + 'believed a capability boundary was declared that the runtime never saw.', }, { diff --git a/packages/spec/src/security/rls.test.ts b/packages/spec/src/security/rls.test.ts index fe318f5828..0027c0a298 100644 --- a/packages/spec/src/security/rls.test.ts +++ b/packages/spec/src/security/rls.test.ts @@ -539,7 +539,7 @@ describe('unknown keys are rejected, not stripped (#4001)', () => { const result = RowLevelSecurityPolicySchema.safeParse({ ...policy, priority: 10 }); expect(result.success).toBe(false); const messages = result.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain('#3896'); + expect(messages).toContain('`rowLevelSecurity[].priority` was removed'); expect(messages).toContain('Delete the key'); }); }); diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 7914484740..20eddc3993 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -233,7 +233,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( where: 'using', }, history: - 'Until #4001 these were dropped silently — the policy still parsed, so a ' + + 'Until this shape was closed, these were dropped silently — the policy still parsed, so a ' + 'row-level restriction the author wrote was never compiled into the filter.', }, { @@ -460,7 +460,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( * strip. */ priority: retiredKey( - '`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0 (#3896 security audit). ' + + '`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. ' + 'It never had an effect and could not: applicable policies OR-combine (most permissive wins), ' + 'so there is no conflict to order. Delete the key — policy outcomes are unchanged. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', diff --git a/packages/spec/src/security/sharing.zod.ts b/packages/spec/src/security/sharing.zod.ts index 8cdd38f820..36603d40d9 100644 --- a/packages/spec/src/security/sharing.zod.ts +++ b/packages/spec/src/security/sharing.zod.ts @@ -138,7 +138,7 @@ const BaseSharingRuleSchema = strictObject( '(§7.3.1) or business-unit depth scopes (ADR-0057).', }, history: - 'Until #4001 these were dropped silently — the rule still parsed, so a share the ' + + 'Until this shape was closed, these were dropped silently — the rule still parsed, so a share the ' + 'author intended was never materialised (or a constraint never applied).', }, { @@ -160,7 +160,7 @@ const BaseSharingRuleSchema = strictObject( surface: 'this sharing-rule recipient', aliases: { id: 'value', target: 'value' }, history: - 'Until #4001 these were dropped silently — the recipient still parsed, so the ' + + 'Until this shape was closed, these were dropped silently — the recipient still parsed, so the ' + 'grant could land on the wrong principal without a diagnostic.', }, { diff --git a/packages/spec/src/shared/editability-boundary.test.ts b/packages/spec/src/shared/editability-boundary.test.ts index a0e1d51506..34f695eac4 100644 --- a/packages/spec/src/shared/editability-boundary.test.ts +++ b/packages/spec/src/shared/editability-boundary.test.ts @@ -364,7 +364,7 @@ describe('#8201 — an option is offered or withheld, never shown-but-unselectab it('the history sentence still comes last', () => { const m = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabled: true }); - expect(m.indexOf(OPTION_TEXT)).toBeLessThan(m.indexOf('Until #4001 closed this shape')); + expect(m.indexOf(OPTION_TEXT)).toBeLessThan(m.indexOf('Until this shape was closed')); }); it('it is NOT the containers\' prescription — an option has no fields inside it', () => { diff --git a/packages/spec/src/shared/editability-boundary.ts b/packages/spec/src/shared/editability-boundary.ts index 897451210e..4fde0b49e9 100644 --- a/packages/spec/src/shared/editability-boundary.ts +++ b/packages/spec/src/shared/editability-boundary.ts @@ -102,7 +102,7 @@ const EDITABILITY_BOUNDARY_GUIDANCE: KeySetGuidance = { keys: EDITABILITY_BOUNDARY_KEYS, prescription: 'Editability is a FIELD-level concern. This shape gates VISIBILITY only — a ' - + 'deliberate boundary, not a missing key (#7887): a section / page component has ' + + 'deliberate boundary, not a missing key: a section / page component has ' + 'no read-only semantics of its own to enforce. Write `readonly: true` (or the ' + 'conditional `readonlyWhen` predicate) on the form field(s) inside it instead; to ' + 'hide the whole section or component, use `visibleWhen`.', diff --git a/packages/spec/src/shared/mapping.test.ts b/packages/spec/src/shared/mapping.test.ts index ae164e998d..ca1ce81249 100644 --- a/packages/spec/src/shared/mapping.test.ts +++ b/packages/spec/src/shared/mapping.test.ts @@ -85,7 +85,7 @@ describe('[#5552] FieldMapping.transform is retired, and says so', () => { expect(result.success).toBe(false); // The `s` flag: the guidance spans lines once a reporter wraps it. expect(result.error!.issues[0]!.message).toMatch( - /`FieldMapping\.transform`.*removed.*17\.0\.0.*#5552/s, + /`FieldMapping\.transform`.*removed.*17\.0\.0/s, ); // It must name the live mechanism, not merely refuse: an author who wrote a // transform wants to know where transforms actually run. diff --git a/packages/spec/src/shared/mapping.zod.ts b/packages/spec/src/shared/mapping.zod.ts index dd880155ea..3fc3a765e9 100644 --- a/packages/spec/src/shared/mapping.zod.ts +++ b/packages/spec/src/shared/mapping.zod.ts @@ -91,10 +91,10 @@ export const FieldMappingSchema = lazySchema(() => z.object({ transform: retiredKey( '`FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and ' + '`externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 ' - + '(#5552, ADR-0049), and the whole `FieldMappingTransform` union went with it ' + + '(ADR-0049), and the whole `FieldMappingTransform` union went with it ' + '(`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed ' + 'any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect ' - + 'retired in #3278. Delete the key. The transform pipeline that IS enforced is the ' + + 'retired. Delete the key. The transform pipeline that IS enforced is the ' + "import mapping's: `mapping.fieldMapping[].transform` (a string enum — " + '`none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), ' + 'applied by the REST import path, which rejects `javascript` with a 400 rather than ' diff --git a/packages/spec/src/shared/retry-policy.zod.ts b/packages/spec/src/shared/retry-policy.zod.ts index f7cb041ee7..9ac7fc9c4e 100644 --- a/packages/spec/src/shared/retry-policy.zod.ts +++ b/packages/spec/src/shared/retry-policy.zod.ts @@ -121,7 +121,7 @@ export function retryPolicyShape() { // rename, where a bare unknown-key rejection would only carry the key. // `retry-policy-converged` rewrites it on the load path. retryDelayMs: retiredKey( - '`retryDelayMs` was removed in @objectstack/spec 17.0.0 (#4661, #4964) — the retry policy now ' + + '`retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now ' + 'has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, ' + "a `try_catch` node's `retry` and `flow.errorHandling`. " + 'Rename the key to `backoffMs`; the value (milliseconds before the first retry) ' + diff --git a/packages/spec/src/shared/strict-object.test.ts b/packages/spec/src/shared/strict-object.test.ts index 7a91e831cc..5b58922c31 100644 --- a/packages/spec/src/shared/strict-object.test.ts +++ b/packages/spec/src/shared/strict-object.test.ts @@ -263,7 +263,7 @@ describe('message order — the fix comes before the history (#5955)', () => { * 4. a set speaks once per message, at the first key that matched it. */ describe('strictObject guidanceSets — the set-keyed prescription channel (#6619)', () => { - const HISTORY = 'Until #4001 these were dropped silently.'; + const HISTORY = 'Until this shape was closed, these were dropped silently.'; const SetSchema = lazySchema(() => strictObject( { @@ -397,7 +397,7 @@ describe('strictObject × retiredKey — suggestions never point at a dead key', strictObject( { surface: 'this thing', - history: 'Until #4001 these were dropped silently.', + history: 'Until this shape was closed, these were dropped silently.', }, { label: z.string(), diff --git a/packages/spec/src/stack-top-level-strict.test.ts b/packages/spec/src/stack-top-level-strict.test.ts index e029a20919..68b8ff7f32 100644 --- a/packages/spec/src/stack-top-level-strict.test.ts +++ b/packages/spec/src/stack-top-level-strict.test.ts @@ -99,12 +99,12 @@ describe('#8687 — unknown top-level stack keys are refused at parse', () => { // ADR-0020: record state machines are a validation rule. ['workflows', 'state_machine'], // #3464: the collection was removed outright. - ['portals', '#3464'], + ['portals', 'nothing ever consumed it'], // #10485 (ADR-0049): the themes carrier retired; app.branding is the // one colour surface. ['themes', 'app.branding'], // #4212: the uninvoked lifecycle family. - ['onDisable', '#4212'], + ['onDisable', 'uninvoked lifecycle'], ]; for (const [key, mustContain] of cases) { const result = parseTopLevel({ [key]: [] }); @@ -141,7 +141,7 @@ describe('#10485 — the `themes` carrier is retired (ADR-0049; ruled 退役授 expect(issue.path).toEqual([]); expect((issue as unknown as { keys: string[] }).keys).toContain('themes'); // The prescription itself — removal citation, and where colour lives now. - expect(issue.message).toContain('#10485'); + expect(issue.message).toContain('authored themes'); expect(issue.message).toContain('app.branding'); expect(issue.message).toContain('primaryColor'); // A retirement is a prescription, never a rename suggestion (finding 7). diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 109c8a2ba0..e7ec422eff 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -200,7 +200,7 @@ function applyApiEndpointGates( export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ surface: 'this stack definition', history: - 'Until #8687 closed this surface (the outermost #4001 door), an unknown top-level stack ' + 'Until this surface was closed (the outermost door), an unknown top-level stack ' + 'key parsed green and its value was silently dropped — a one-character typo could ship ' + 'an artifact missing a whole metadata family while `os validate` exited 0. The declared ' + 'keys are enumerated by `ObjectStackDefinitionSchema` (@objectstack/spec, stack.zod.ts) ' @@ -221,18 +221,18 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ 'there is no top-level `workflows` collection (ADR-0020): a record state machine is a ' + '`state_machine` validation rule on the object it governs.', portals: - 'the top-level `portals` collection was removed (#3464) — nothing ever consumed it. ' + 'the top-level `portals` collection was removed — nothing ever consumed it. ' + 'Author external-user UI with `apps`/`views` plus positions and permission sets.', themes: - '`themes` was removed in @objectstack/spec 17.1 (#10485, ADR-0049) — authored themes ' + '`themes` was removed in @objectstack/spec 17.1 (ADR-0049) — authored themes ' + 'were parsed and stored, but no framework package ever read them back, no first-party ' + 'app mounted the spec-aware theme provider, and nothing selected an active theme, so ' + 'a declared theme changed nothing on screen. Delete the key. To colour the shipped ' + 'console, set `app.branding.primaryColor` / `accentColor` — the one live colour ' + 'surface (it drives `--primary`, `--accent` and their derived variables).', onDisable: - 'no kernel, runtime or service ever called `onDisable` (#4212 retired the uninvoked ' - + 'lifecycle family), so a value written here goes nowhere. Do teardown inside the ' + 'no kernel, runtime or service ever called `onDisable` (the uninvoked lifecycle ' + + 'family is retired), so a value written here goes nowhere. Do teardown inside the ' + 'resources `onEnable` acquires.', }, }, { @@ -335,7 +335,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ }).optional().describe( '[MACHINE-ASSEMBLED] Non-container view artifacts of a runtime-assembled manifest ' + '(standalone ViewItems, flattened overlays) — written by package export and artifact ' - + 'factories, refused in authored stack sources (#5320).', + + 'factories, refused in authored stack sources.', ), pages: z.array(PageSchema).optional().describe('Custom Pages'), dashboards: z.array(DashboardSchema).optional().describe('Dashboards'), @@ -425,7 +425,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ */ apis: z.array(ApiEndpointSchema) .optional() - .describe('API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121, #5040)'), + .describe('API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121)'), webhooks: z.array(WebhookSchema).optional().describe('Outbound Webhooks'), /** @@ -443,7 +443,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ * silently strip it and the author's intent would vanish without a word. */ requireAuth: retiredKey( - '`api.requireAuth` was removed in @objectstack/spec 17 (#3963). Anonymous access to object data ' + '`api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data ' + 'is now always denied. Delete the key; publish public surfaces by declaration instead — a public ' + "form view, a share link, or `book.audience: 'public'`. A stack that mounts no auth at all now " + 'fails at boot rather than silently serving object data to anonymous callers. ' @@ -593,7 +593,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ * (#4095, `GRAFTABLE_RUNTIME_MEMBERS`, derived from * `STACK_RUNTIME_MEMBERS`). */ - onEnable: z.function().optional().describe('App lifecycle hook invoked by AppPlugin at start() with the host context (ctx.ql) — register action handlers and drivers here. Executed off the authored bundle; never serialized into the JSON artifact (#4095 grafts it back on artifact boot).'), + onEnable: z.function().optional().describe('App lifecycle hook invoked by AppPlugin at start() with the host context (ctx.ql) — register action handlers and drivers here. Executed off the authored bundle; never serialized into the JSON artifact (grafts it back on artifact boot).'), mappings: z.array(MappingSchema).optional().describe('Data Import/Export Mappings'), analyticsCubes: z.array(CubeSchema).optional().describe('Analytics Semantic Layer Cubes'), @@ -626,7 +626,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ 'External System Connectors. A provider-bound entry (has `provider`: openapi/mcp/rest) is materialized into a ' + 'live, dispatchable connector at boot and referenced by flows via `connector_action`; credentials are `auth.credentialRef` ' + 'references, never inline secrets. An entry with no `provider` is a catalog descriptor only (NOT dispatchable) — set ' + - '`enabled: false` on deliberate descriptors. Unknown provider / unresolvable credentialRef / name conflict ⇒ hard boot error (ADR-0097, #2977).', + '`enabled: false` on deliberate descriptors. Unknown provider / unresolvable credentialRef / name conflict ⇒ hard boot error (ADR-0097).', ), /** diff --git a/packages/spec/src/studio/flow-builder.zod.ts b/packages/spec/src/studio/flow-builder.zod.ts index 3bb5c60f00..4c8fe214f0 100644 --- a/packages/spec/src/studio/flow-builder.zod.ts +++ b/packages/spec/src/studio/flow-builder.zod.ts @@ -50,7 +50,7 @@ import { strictObject } from '../shared/strict-object'; * not in their spelling. */ const FLOW_BUILDER_HISTORY = - 'Until #4001 closed these shapes an unknown key was dropped silently — the builder still ' + 'Until these shapes were closed an unknown key was dropped silently — the builder still ' + 'rendered, without whatever the key was meant to configure.'; export const FlowNodeShapeSchema = lazySchema(() => z.enum([ diff --git a/packages/spec/src/studio/object-designer.zod.ts b/packages/spec/src/studio/object-designer.zod.ts index 0943ef618e..e455dfe3db 100644 --- a/packages/spec/src/studio/object-designer.zod.ts +++ b/packages/spec/src/studio/object-designer.zod.ts @@ -80,7 +80,7 @@ import { strictObject } from '../shared/strict-object'; * looks like it is working. */ const OBJECT_DESIGNER_HISTORY = - 'Until #4001 closed these shapes an unknown key was dropped silently — the designer still ' + 'Until these shapes were closed an unknown key was dropped silently — the designer still ' + 'rendered, without whatever the key was meant to configure.'; export const FieldPropertySectionSchema = lazySchema(() => strictObject({ diff --git a/packages/spec/src/studio/plugin.test.ts b/packages/spec/src/studio/plugin.test.ts index 9d00a5ae9a..7098297be0 100644 --- a/packages/spec/src/studio/plugin.test.ts +++ b/packages/spec/src/studio/plugin.test.ts @@ -251,7 +251,7 @@ describe('StudioPluginManifestSchema', () => { expect(() => StudioPluginManifestSchema.parse({ ...minimalManifest, activationEvents: [{ type: 'onMetadataType', pattern: 'object' }], - })).toThrow(/activationEvents.*removed.*17\.0\.0.*#4657.*Delete the key/s); + })).toThrow(/activationEvents.*removed.*17\.0\.0.*Delete the key/s); }); it('rejects activationEvents with the retirement prescription — pre-v17 string form', () => { @@ -260,7 +260,7 @@ describe('StudioPluginManifestSchema', () => { expect(() => StudioPluginManifestSchema.parse({ ...minimalManifest, activationEvents: ['onMetadataType:object'], - })).toThrow(/activationEvents.*removed.*#4657/s); + })).toThrow(/activationEvents.*removed/s); }); it('rejects the former VS Code-flavoured aliases with the same prescription', () => { @@ -271,7 +271,7 @@ describe('StudioPluginManifestSchema', () => { expect(() => StudioPluginManifestSchema.parse({ ...minimalManifest, [key]: ['*'], - })).toThrow(/activationEvents.*removed.*#4657/s); + })).toThrow(/activationEvents.*removed/s); } }); diff --git a/packages/spec/src/studio/plugin.zod.ts b/packages/spec/src/studio/plugin.zod.ts index ae7a40511a..80fe4b0aff 100644 --- a/packages/spec/src/studio/plugin.zod.ts +++ b/packages/spec/src/studio/plugin.zod.ts @@ -73,7 +73,7 @@ import { strictObject } from '../shared/strict-object'; * registration bug, not a spelling one. */ const STUDIO_PLUGIN_HISTORY = - 'Until #4001 closed these shapes an unknown key was dropped silently — the plugin still ' + 'Until these shapes were closed an unknown key was dropped silently — the plugin still ' + 'loaded and activated, contributing less than its manifest declared.'; export const ViewModeSchema = lazySchema(() => z.enum(['preview', 'design', 'code', 'data', 'history'])); @@ -314,7 +314,7 @@ export type StudioPluginContributionsParsed = z.infer strictObject({ surface: 'this book', history: - 'Until #4001 closed this shape these were dropped silently — the book still ' + 'Until this shape was closed these were dropped silently — the book still ' + 'registered, minus whatever the key was meant to configure.', aliases: { title: 'label', sections: 'groups', chapters: 'groups', toc: 'groups', diff --git a/packages/spec/src/system/doc.zod.ts b/packages/spec/src/system/doc.zod.ts index d56d0021d0..4a77eb10fc 100644 --- a/packages/spec/src/system/doc.zod.ts +++ b/packages/spec/src/system/doc.zod.ts @@ -31,7 +31,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; export const DocSchema = lazySchema(() => strictObject({ surface: 'this doc', history: - 'Until #4001 these were dropped silently — the doc still registered, just without ' + 'Until this shape was closed, these were dropped silently — the doc still registered, just without ' + 'whatever the key was meant to configure.', aliases: { title: 'label', diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index 5bde3fcbf6..fdda4070c0 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -47,7 +47,7 @@ export type EmailTemplateDefinitionVariableParsed = z.infer strictObject({ surface: 'this email template', history: - 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the item still registered, minus whatever the key was meant to configure.', // #5013 — five of these pointed at `body` and `fromAddress`, neither of which // this schema declares: every one of them prescribed a second rejection. The // real slots are the two bodies and the per-template From override. diff --git a/packages/spec/src/system/environment-artifact.zod.ts b/packages/spec/src/system/environment-artifact.zod.ts index 69e2f7d7af..6ae63da098 100644 --- a/packages/spec/src/system/environment-artifact.zod.ts +++ b/packages/spec/src/system/environment-artifact.zod.ts @@ -125,21 +125,21 @@ export const EnvironmentArtifactSchema = lazySchema(() => z.object({ // conversion chain — and no producer ever emitted these keys. functions: retiredKey( - '`environmentArtifact.functions` was removed in @objectstack/spec 17.0.0 (#4740, ADR-0049) — ' + '`environmentArtifact.functions` was removed in @objectstack/spec 17.0.0 (ADR-0049) — ' + 'the v0 inline-functions block never had a producer or reader: `objectstack compile` has always ' + 'shipped function code as standalone runtime modules referenced from the compiled definition. ' + 'Delete the key; function code travels inside `metadata` (the compiled ObjectStackDefinition).', ), manifest: retiredKey( - '`environmentArtifact.manifest` was removed in @objectstack/spec 17.0.0 (#4740, ADR-0049) — ' + '`environmentArtifact.manifest` was removed in @objectstack/spec 17.0.0 (ADR-0049) — ' + 'the v0 plugin/driver requirements block never had a producer or reader. Delete the key; ' + 'package/plugin requirements live in the stack manifest inside `metadata` ' + '(`metadata.manifest`, ManifestSchema).', ), payloadRef: retiredKey( - '`environmentArtifact.payloadRef` was removed in @objectstack/spec 17.0.0 (#4740, ADR-0049) — ' + '`environmentArtifact.payloadRef` was removed in @objectstack/spec 17.0.0 (ADR-0049) — ' + 'the reserved out-of-band payload indirection was never implemented; every artifact inlines ' + '`metadata`. Delete the key.', ), diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts index 72642bfe79..8598c62675 100644 --- a/packages/spec/src/system/job.zod.ts +++ b/packages/spec/src/system/job.zod.ts @@ -117,7 +117,7 @@ import { RetryPolicySchema } from '../shared/retry-policy.zod'; * jobs, they were one job declared twice, with the second silently winning. */ const JOB_ID_RETIRED = - '`job.id` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — nothing ever read ' + '`job.id` was removed in @objectstack/spec 17.0.0 (ADR-0049) — nothing ever read ' + 'it, and its own description ("defaults to `name` when omitted") advertised an identity ' + 'override that did not exist. `name` IS the job\'s identity everywhere: the scheduling ' + 'key, the `sys_job` row key, and the `JobExecution.jobId` stamp. Two jobs differing only ' @@ -128,7 +128,7 @@ const JOB_ID_RETIRED = export const JobSchema = lazySchema(() => strictObject({ surface: 'this job', history: - 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the item still registered, minus whatever the key was meant to configure.', aliases: { cron: 'schedule', interval: 'schedule', fn: 'handler', function: 'handler', retry: 'retryPolicy', enabled_: 'enabled', timeoutMs: 'timeout' }, guidance: { id: JOB_ID_RETIRED }, }, { @@ -138,8 +138,8 @@ export const JobSchema = lazySchema(() => strictObject({ description: z.string().optional().describe('Job description / purpose'), schedule: ScheduleSchema.describe('Job schedule configuration'), handler: z.string().describe('Handler function name (must match a key in `defineStack({ functions })`)'), - retryPolicy: RetryPolicySchema.optional().describe('Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt (#3494). Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 (#4661) — state a count to opt in.'), - timeout: z.number().int().positive().optional().describe('Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout" (#3494). The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit.'), + retryPolicy: RetryPolicySchema.optional().describe('Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt. Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 — state a count to opt in.'), + timeout: z.number().int().positive().optional().describe('Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit.'), enabled: z.boolean().default(true).describe('Whether the job is enabled'), // ADR-0010 — runtime protection envelope (internal — set by the loader). diff --git a/packages/spec/src/system/migration.zod.ts b/packages/spec/src/system/migration.zod.ts index 2c3a63e613..de1d58347b 100644 --- a/packages/spec/src/system/migration.zod.ts +++ b/packages/spec/src/system/migration.zod.ts @@ -203,7 +203,7 @@ export const DataMigrationFlagSchema = lazySchema(() => z.object({ details: z.string().optional() .describe('JSON-encoded counts from the last run, for diagnostics'), deviation_observed_at: z.string().datetime().nullable().optional() - .describe('When this deployment last ADMITTED a value the verified contract rejects, via an OS_ALLOW_LAX_* escape hatch. Does not clear verified_at — it withdraws the irreversible half of what the certificate authorises (#4797)'), + .describe('When this deployment last ADMITTED a value the verified contract rejects, via an OS_ALLOW_LAX_* escape hatch. Does not clear verified_at — it withdraws the irreversible half of what the certificate authorises'), deviation_detail: z.string().nullable().optional() .describe('JSON-encoded first counterexample behind deviation_observed_at (object, field, type, parse issue), for diagnostics'), }).describe('Deployment-level record that a data migration ran here and its self-check passed — the evidence gate consumers read instead of the platform version')); diff --git a/packages/spec/src/system/notification.zod.ts b/packages/spec/src/system/notification.zod.ts index 0a16324a15..aeb74fe937 100644 --- a/packages/spec/src/system/notification.zod.ts +++ b/packages/spec/src/system/notification.zod.ts @@ -24,7 +24,7 @@ export const NotificationChannelSchema = lazySchema(() => z.enum([ 'slack', 'teams', 'webhook', -]).describe('Notification delivery channel (implemented today: inbox, email, sms — push/slack/teams/webhook are not yet implemented and dead-letter; see #3197)')); +]).describe('Notification delivery channel ')); // [#4610] `NotificationConfigSchema` / `NotificationConfig` were removed from // this module (dual-source cleanup, #4535 C3). The "unified notification diff --git a/packages/spec/src/system/stack-server.test.ts b/packages/spec/src/system/stack-server.test.ts index 974b821abd..e2a8af2f00 100644 --- a/packages/spec/src/system/stack-server.test.ts +++ b/packages/spec/src/system/stack-server.test.ts @@ -70,9 +70,9 @@ describe('server: carries only keys with a consumer (#4938 stays shut)', () => { it.each([ ['port', /objectstack serve -p/], ['host', /belongs to the deployment/], - ['compression', /#4938/], - ['requestTimeout', /#4938/], - ['bodyLimit', /#4938/], + ['compression', /retired in v17/], + ['requestTimeout', /retired in v17/], + ['bodyLimit', /retired in v17/], ['static', /transport plugin/], ['cors', /OS_CORS_ORIGIN/], ])('rejects the unconsumed HttpServerConfig key `%s` with a prescription', (key, expected) => { diff --git a/packages/spec/src/system/stack-server.zod.ts b/packages/spec/src/system/stack-server.zod.ts index e5c3459511..807254ef44 100644 --- a/packages/spec/src/system/stack-server.zod.ts +++ b/packages/spec/src/system/stack-server.zod.ts @@ -82,7 +82,7 @@ export const ServerRateLimitConfigSchema = lazySchema(() => strictObject( { surface: 'server.security.rateLimit', history: - 'This key is new in v17 (#4910) and strict from birth — an unknown key here was never accepted.', + 'This key is new in v17 and strict from birth — an unknown key here was never accepted.', aliases: { window: 'windowMs', windowSeconds: 'windowMs', @@ -142,7 +142,7 @@ export const StackServerSecuritySchema = lazySchema(() => strictObject( { surface: 'server.security', history: - 'This key is new in v17 (#4910) and strict from birth — an unknown key here was never accepted.', + 'This key is new in v17 and strict from birth — an unknown key here was never accepted.', guidance: { helmet: 'Not authorable here. Response hardening headers are configured on the dispatcher plugin ' @@ -180,31 +180,31 @@ export const StackServerConfigSchema = lazySchema(() => strictObject( { surface: 'the stack `server` block', history: - 'This key is new in v17 (#4910) and strict from birth — an unknown key here was never accepted.', + 'This key is new in v17 and strict from birth — an unknown key here was never accepted.', guidance: { port: 'Not authorable. The listening port belongs to the deployment, not the stack — pass ' - + '`objectstack serve -p ` or set PORT. (`HttpServerConfig.port` was retired in v17, #4938.)', + + '`objectstack serve -p ` or set PORT. (`HttpServerConfig.port` was retired in v17,.)', host: 'Not authorable. The bind address belongs to the deployment, not the stack — pass it to ' - + '`objectstack serve`. (`HttpServerConfig.host` was retired in v17, #4938.)', + + '`objectstack serve`. (`HttpServerConfig.host` was retired in v17,.)', cors: 'Not authorable here. CORS is owned by the transport adapter and configured by ' + 'OS_CORS_ORIGIN / OS_CORS_CREDENTIALS / OS_CORS_MAX_AGE. (`HttpServerConfig.cors` was retired in ' - + 'v17, #4938; it is the registered first candidate for a `server.cors` key, which will arrive with ' + + 'v17; it is the registered first candidate for a `server.cors` key, which will arrive with ' + 'its executor.)', compression: - 'Not authorable — nothing reads it. `HttpServerConfig.compression` was retired in v17 (#4938) ' + 'Not authorable — nothing reads it. `HttpServerConfig.compression` was retired in v17 ' + 'rather than mounted here; response compression is the transport adapter\'s concern.', requestTimeout: - 'Not authorable — nothing reads it. `HttpServerConfig.requestTimeout` was retired in v17 (#4938) ' + 'Not authorable — nothing reads it. `HttpServerConfig.requestTimeout` was retired in v17 ' + 'rather than mounted here.', bodyLimit: - 'Not authorable — nothing reads it. `HttpServerConfig.bodyLimit` was retired in v17 (#4938) ' + 'Not authorable — nothing reads it. `HttpServerConfig.bodyLimit` was retired in v17 ' + 'rather than mounted here.', static: 'Not authorable. Static mounts are configured on the transport plugin (`staticMounts`). ' - + '(`HttpServerConfig.static` was retired in v17, #4938.)', + + '(`HttpServerConfig.static` was retired in v17,.)', }, }, { diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index ba41ad55d6..c7e74b6b88 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -844,7 +844,7 @@ describe('translation unknown-key strictness (#4001)', () => { const result = parse({ new_lead_form: { submitLabel: 'Create' } }); expect(result.success).toBe(false); const message = result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message; - expect(message).toContain('`submitLabel` was removed in @objectstack/spec 17 (#10926'); + expect(message).toContain('`submitLabel` was removed in @objectstack/spec 17 (ADR-0049)'); expect(message).toContain('`submitText`'); }); @@ -1219,7 +1219,7 @@ describe('translation unknown-key strictness (#4001)', () => { }); expect(result.success).toBe(false); expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) - .toContain('#3494'); + .toContain(`\`${key}\` was removed`); }, ); }); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 8ddfed8185..19724cdb0e 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -23,7 +23,7 @@ export type Locale = z.input; * the bug looks like missing coverage forever. */ const TRANSLATION_HISTORY = - 'Until #4001 closed these shapes an unknown key was dropped silently — the bundle still ' + 'Until these shapes were closed an unknown key was dropped silently — the bundle still ' + 'loaded, and whatever it was meant to translate rendered in the source language with no ' + 'diagnostic, indistinguishable from a translation nobody had written yet.'; @@ -337,7 +337,7 @@ const TRANSLATION_KEY_GUIDANCE: Record ({ aliases: { name: 'title', heading: 'title', text: 'label', caption: 'description', help: 'description', helpText: 'description', empty: 'emptyText', emptyState: 'emptyText' }, guidance: { submitLabel: - '`submitLabel` was removed in @objectstack/spec 17 (#10926, ADR-0049) — its only ' - + 'spec-declared carrier, `element:form`, retired whole (#9249), so no component in ' + '`submitLabel` was removed in @objectstack/spec 17 (ADR-0049) — its only ' + + 'spec-declared carrier, `element:form`, retired whole, so no component in ' + '`ComponentPropsMap` declares it and the resolver no longer overlays it. The live form ' + "surface's submit copy is `object-form`'s `submitText` (`I18nLabelSchema`), localizable " + 'at its own authoring site. Delete the key. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', submit: '`submit` was the alias spelling of `submitLabel`, which was removed in ' - + '@objectstack/spec 17 (#10926, ADR-0049) — no component in `ComponentPropsMap` ' - + 'declares a submit label since `element:form` retired whole (#9249). Delete the key; ' + + '@objectstack/spec 17 (ADR-0049) — no component in `ComponentPropsMap` ' + + 'declares a submit label since `element:form` retired whole. Delete the key; ' + "the live form surface's submit copy is `object-form`'s `submitText`.", }, }, { @@ -706,11 +706,11 @@ const translationDataShape = () => ({ guidance: { successMessage: '`flow.successMessage` / `flow.errorMessage` are not part of the flows translation surface — ' - + 'it carries the flow label, per-screen headings and per-screen field copy (#7646). The terminal ' + + 'it carries the flow label, per-screen headings and per-screen field copy. The terminal ' + 'toast renders the string authored on the flow.', errorMessage: '`flow.errorMessage` / `flow.successMessage` are not part of the flows translation surface — ' - + 'it carries the flow label, per-screen headings and per-screen field copy (#7646). The terminal ' + + 'it carries the flow label, per-screen headings and per-screen field copy. The terminal ' + 'toast renders the string authored on the flow.', }, }, { @@ -726,7 +726,7 @@ const translationDataShape = () => ({ guidance: { description: "`description` — a screen's body text (`config.description`) — is not part of the flows " - + 'translation surface, which carries the heading (`title`) and per-field copy (#7646).', + + 'translation surface, which carries the heading (`title`) and per-field copy.', }, }, { // Overlays the screen node's `config.title`. A screen that declares no @@ -966,7 +966,7 @@ export function defineTranslationBundle(config: z.input strictObject({ surface: 'the i18n configuration', history: - 'Until #4001 closed this shape an unknown key was dropped silently — the stack still ' + 'Until this shape was closed an unknown key was dropped silently — the stack still ' + 'built, with the setting the author wrote never reaching any runtime.', aliases: { locale: 'defaultLocale', defaultLanguage: 'defaultLocale', locales: 'supportedLocales', languages: 'supportedLocales', fallback: 'fallbackLocale', fallbackLanguage: 'fallbackLocale' }, // #3494 removed these four. They were the exemplar the liveness audit @@ -975,10 +975,10 @@ export const TranslationConfigSchema = lazySchema(() => strictObject({ // that was a silent no-op for a second reason. A tombstone is the only thing // that tells an author upgrading from <17 why their setting vanished. guidance: { - fileOrganization: '`fileOrganization` was removed in #3494 — no runtime ever read it; how you split bundle files is a convention of your source tree, and the loader reads whatever `translations` you hand `defineStack`', - messageFormat: '`messageFormat` was removed in #3494 — there is no ICU engine; interpolation is always simple `{variable}` substitution', - lazyLoad: '`lazyLoad` was removed in #3494 — no runtime ever read it, so setting it changed nothing', - cache: '`cache` was removed in #3494 — no runtime ever read it, so setting it changed nothing', + fileOrganization: '`fileOrganization` was removed — no runtime ever read it; how you split bundle files is a convention of your source tree, and the loader reads whatever `translations` you hand `defineStack`', + messageFormat: '`messageFormat` was removed — there is no ICU engine; interpolation is always simple `{variable}` substitution', + lazyLoad: '`lazyLoad` was removed — no runtime ever read it, so setting it changed nothing', + cache: '`cache` was removed — no runtime ever read it, so setting it changed nothing', }, }, { /** Default locale for the application */ diff --git a/packages/spec/src/ui/action-params.test.ts b/packages/spec/src/ui/action-params.test.ts index 0f25e4319c..7c208ae0e6 100644 --- a/packages/spec/src/ui/action-params.test.ts +++ b/packages/spec/src/ui/action-params.test.ts @@ -363,7 +363,7 @@ describe('#5779 — ActionSession `positions` canonical + `roles` deprecated ali expect(doc).toMatch(/DEPRECATED alias of `positions`/); expect(doc).toMatch(/action-session-roles-to-positions/); - expect(doc).toMatch(/#3290/); + expect(doc).toMatch(/v16 session-alias removal path/); expect(doc).toMatch(/security service/i); // ...and that phase 1's now-obsolete prohibition is GONE. Leaving it would diff --git a/packages/spec/src/ui/action-params.zod.ts b/packages/spec/src/ui/action-params.zod.ts index d8cb09a010..b8159b4270 100644 --- a/packages/spec/src/ui/action-params.zod.ts +++ b/packages/spec/src/ui/action-params.zod.ts @@ -370,10 +370,10 @@ export const ActionSessionSchema = lazySchema(() => z.object({ positions: z.array(z.string()).optional().describe( 'Position names held by the caller (ADR-0090 D3 vocabulary; the value of ' + '`ExecutionContext.positions`, whose schema comment reads "Formerly `roles`") — the CANONICAL ' - + 'spelling at this boundary and the key an action body should read. Within the #5613 ' + + 'spelling at this boundary and the key an action body should read. Within the ' + 'deprecation window `buildActionSession()` emits the same array under both this key and the ' + 'deprecated `roles`, so migrating is a change of key and nothing else; `roles` is then removed ' - + 'on the v16 session-alias removal path (#3280 deprecate → #3290 remove: one window, then gone). ' + + 'on the v16 session-alias removal path (deprecate → remove: one window, then gone). ' + 'Never gate PRIVILEGE on this array — ask the security service, which evaluates capability ' + 'grants, placements and the derived posture (ADR-0095), never a position-name string ' + 'comparison.', @@ -403,11 +403,11 @@ export const ActionSessionSchema = lazySchema(() => z.object({ roles: z.array(z.string()).optional().describe( 'DEPRECATED alias of `positions` — the same caller position names under the one spelling ' + 'ADR-0090 D3 forbids (the value is `ExecutionContext.positions`, "Formerly `roles`"). Read ' - + '`positions` instead: within the #5613 deprecation window `buildActionSession()` emits both ' + + '`positions` instead: within the deprecation window `buildActionSession()` emits both ' + 'keys with identical values, so migrating is a change of key and nothing else. The migration ' + 'prescription and its acceptance criteria are the ADR-0087 semantic migration ' + '`action-session-roles-to-positions`; removal follows the v16 session-alias removal path ' - + '(#3280 deprecated → #3290 removed). Never gate PRIVILEGE on this array — ask the ' + + '(deprecated → removed). Never gate PRIVILEGE on this array — ask the ' + 'security service, which evaluates capability grants, placements and the derived posture ' + '(ADR-0095), never a role-name string comparison.', ), diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index 6f600141ac..c98ca5b86b 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -1561,7 +1561,7 @@ describe('#3896 close-out — retired shortcut/bulkEnabled', () => { ActionSchema.parse({ name: 'save_now', label: 'Save', type: 'script', target: 'h', shortcut: 'Ctrl+S' }); } catch (e) { message = String((e as Error).message); } expect(message).toMatch(/keyboard stack/); - expect(message).toMatch(/#3896/); + expect(message).toMatch(/audit close-out/); }); it('REJECTS the retired `bulkEnabled` and points at the view bulkActions', () => { let message = ''; @@ -1569,7 +1569,7 @@ describe('#3896 close-out — retired shortcut/bulkEnabled', () => { ActionSchema.parse({ name: 'close_all', label: 'Close', type: 'script', target: 'h', bulkEnabled: true }); } catch (e) { message = String((e as Error).message); } expect(message).toMatch(/bulkActions/); - expect(message).toMatch(/#3896/); + expect(message).toMatch(/audit close-out/); }); }); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index a9db2dcbbd..bce52429ea 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -143,7 +143,7 @@ const actionParamOptionColorGuidance = + '`data/field.zod.ts`), read where a STORED value is displayed — the grid cell and the ' + 'detail badge. An action param\'s options are never rendered that way: the dialog builds ' + 'an INPUT from them, submits the picked value and discards the list, so no renderer would ' - + 'read `color` here even if this shape declared it (#5016 measured this). Drop the key — to ' + + 'read `color` here even if this shape declared it (measured this). Drop the key — to ' + 'colour the value once it is stored, declare the option list on the FIELD.'; /** @@ -172,7 +172,7 @@ export const ActionParamSchema = lazySchema(() => strictObject( surface: 'this action param', aliases: ACTION_PARAM_KEY_ALIASES, history: - 'Until #3405 these were dropped silently — the param still parsed, so a mis-spelled ' + + 'Until this shape was closed, these were dropped silently — the param still parsed, so a mis-spelled ' + 'config shipped as a control that quietly ignored it.', }, { @@ -270,7 +270,7 @@ export const ActionParamSchema = lazySchema(() => strictObject( options: z.array(strictObject({ surface: 'this action param option', history: - 'Until #4001 批 14 closed this shape these were dropped silently — the param still ' + 'Until this shape was closed these were dropped silently — the param still ' + 'rendered its picker, minus whatever the key was meant to colour, gate or disable.', aliases: { // Carried over from `SelectOptionSchema`'s table — same idea, and these @@ -692,7 +692,7 @@ const ActionAiCategorySchema = z.enum([ * action AROUND the param stayed open for three more releases. */ const ACTION_HISTORY = - 'Until #4001 closed this shape these were dropped silently — the action still registered ' + 'Until this shape was closed these were dropped silently — the action still registered ' + 'and still ran, without whatever the key was meant to configure or gate.'; export const ActionAiSchema = strictObject({ @@ -714,7 +714,7 @@ export const ActionAiSchema = strictObject({ permissions: 'AI invocation is not gated by a key here — an agent reaches this action only if a ' + "surface-compatible SKILL declares it (ADR-0064), and who may talk to that agent is " - + "gated by the agent's `access` / `permissions` (enforced at the chat route since #1884). " + + "gated by the agent's `access` / `permissions`. " + 'For a human-approval step on the call itself, use `requiresConfirmation: true`.', approval: 'there is no approval workflow key here — `requiresConfirmation: true` forces a ' @@ -922,7 +922,7 @@ const actionObject = () => strictObject({ * (≥40 chars, required when `ai.exposed`); this one is human-facing dialog * copy and is never sent to a model. */ - description: I18nLabelSchema.optional().describe('Explanatory 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 — #7278). Not the LLM-facing `ai.description`.'), + description: I18nLabelSchema.optional().describe('Explanatory 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`.'), /** Target object this action belongs to (optional, snake_case) */ objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional().describe('Target object this action belongs to. When set, the action is auto-merged into the object\'s actions array by defineStack().'), @@ -1038,7 +1038,7 @@ const actionObject = () => strictObject({ * `never`, so the mistake fails `tsc` before any parse runs. */ execute: retiredKey( - '`execute` was removed in @objectstack/spec 17 (#3855) — use `target`. ' + + '`execute` was removed in @objectstack/spec 17 — use `target`. ' + 'Rename the key; the value (a handler / flow / URL ref) is unchanged. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), @@ -1134,7 +1134,7 @@ const actionObject = () => strictObject({ // tables derive from this one field factory), and the #7428 refusal lives on // `ActionSchema`'s refine chain alone — hence "a registered action" rather // than an unqualified claim that would be false on the inline surface. - confirmText: I18nLabelSchema.optional().describe('Confirmation message before execution. On a registered action, pairing this with a non-empty `params` is refused (#7428) — 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.'), + confirmText: I18nLabelSchema.optional().describe('Confirmation message before execution. On a registered action, 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.'), successMessage: I18nLabelSchema.optional().describe('Success message to show after execution'), // Runtime (ActionRunner) already honours this — declared here so authors can // set a friendly failure toast instead of surfacing the raw error string. @@ -1255,7 +1255,7 @@ const actionObject = () => strictObject({ // VIEW's bulkActions, never this flag. Tombstoned with the prescription; // `action-inert-keys-removed` strips them from authored sources. shortcut: retiredKey( - '`action.shortcut` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' + + '`action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + 'it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and ' + "objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults " + 'action metadata. Delete the key. For a real shortcut, register the key in the Console ' + @@ -1263,7 +1263,7 @@ const actionObject = () => strictObject({ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), bulkEnabled: retiredKey( - '`action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' + + '`action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + 'the multi-select toolbar is driven by the LIST VIEW\'s `bulkActions` / `bulkActionDefs`, ' + 'never by this flag, so setting it changed nothing. Delete the key and declare the action ' + "in the view's `bulkActions` instead. " + @@ -1450,7 +1450,7 @@ const actionObject = () => strictObject({ + "to the top-level `openIn` key for `type:'url'` actions. Write `openIn: 'newTab'`." : undefined), }).default('self').describe("Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL."), - }).optional().describe("Post-success navigation for type:'api' and type:'script' actions (#9566/#9474). `navigate` is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response); `openIn` defaults 'self'. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior."), + }).optional().describe("Post-success navigation for type:'api' and type:'script' actions. `navigate` is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response); `openIn` defaults 'self'. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior."), /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index c936525629..9de65b3583 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -1126,7 +1126,7 @@ describe('retired dead keys carry prescriptions (#4001)', () => { expect(messages).not.toContain('not executable in v17'); expect(messages).not.toContain('until the endpoint executor ships'); // The historical fact stays: #4936 happened, and the prescription says so. - expect(messages).toContain('#4936'); + expect(messages).toContain('refused wholesale'); }); }); }); @@ -1482,7 +1482,7 @@ describe('unknown keys are rejected, not stripped (#4001 PR B)', () => { // false belief one step further from the evidence. expect(msg).toMatch(/BOTH trees/s); expect(msg).toMatch(/areas\[\]\.navigation/s); - expect(msg).toMatch(/#4722/s); + expect(msg).toMatch(/re-checked in the shell/s); // The asymmetry that SURVIVES #4722 — and the reason this is the layer // an author must reach for: `requiredPermissions` is enforced before the // body ships, `visible` is not evaluated until it is already in the @@ -1512,7 +1512,7 @@ describe('unknown keys are rejected, not stripped (#4001 PR B)', () => { // enumeration rather than merely the absence of the old one. expect(msg).toMatch(/either navigation tree/s); expect(msg).toMatch(/areas\[\]\.navigation/s); - expect(msg).toMatch(/#4722/s); + expect(msg).toMatch(/server-side\. The distinction survives/s); // …and do NOT let that drag `visible`'s own verdict along: #4722 changed // nothing about CEL, which is still evaluated in the browser at every // level. That asymmetry is the entire reason this prescription can send a diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index f0bdeac987..368fa6a285 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -309,7 +309,7 @@ const BaseNavItemSchema = z.object({ badge: z.union([z.string(), z.number()]).optional().describe('Badge text or count displayed on the item'), /** Visual variant for the badge (consumed by objectui NavigationRenderer) */ - badgeVariant: z.enum(['default', 'secondary', 'destructive', 'outline']).optional().describe('Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit #1878/#1891/#1894).'), + badgeVariant: z.enum(['default', 'secondary', 'destructive', 'outline']).optional().describe('Visual variant of the nav badge. Declared to match the objectui NavigationRenderer read (inverse-drift fix, liveness audit /).'), /** * Visibility condition. @@ -449,7 +449,7 @@ export const ObjectNavItemSchema = lazySchema(() => strictObject(navItemSurface( * dead combination is unrepresentable rather than silently ignored). */ runAction: z.string().optional().describe( - 'Auto-run this declared action once on arrival at the object\'s list surface (deep-link "navigate = run action", #4848). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId.', + 'Auto-run this declared action once on arrival at the object\'s list surface (deep-link "navigate = run action"). Must name an action defined in the stack; validated by defineStack and lint. Not combinable with recordId.', ), })); @@ -548,7 +548,7 @@ export const ActionNavItemSchema = lazySchema(() => strictObject(navItemSurface( surface: "this nav item's action definition", aliases: { action: 'actionName', name: 'actionName', args: 'params', input: 'params' }, history: - 'Until #4001 these were dropped silently — the definition still parsed, so clicking ' + + 'Until this shape was closed, these were dropped silently — the definition still parsed, so clicking ' + 'the entry dispatched a different action than the author declared.', }, { @@ -756,7 +756,7 @@ export const NavigationContributionSchema = lazySchema(() => strictObject( surface: 'this navigation contribution', aliases: { targetapp: 'app', appname: 'app', targetgroup: 'group', groupid: 'group', order: 'priority', navigation: 'items' }, history: - 'Until #4001 these were dropped silently — the contribution still parsed, so a ' + + 'Until this shape was closed, these were dropped silently — the contribution still parsed, so a ' + 'package injected its menu into the wrong place, or nowhere.', }, { @@ -784,12 +784,12 @@ export const AppBrandingSchema = lazySchema(() => strictObject( surface: "this app's branding block", aliases: { primary: 'primaryColor', accent: 'accentColor', color: 'primaryColor', logourl: 'logo', icon: 'favicon', theme: 'primaryColor' }, history: - 'Until #4001 these were dropped silently — branding still parsed, so a theme the ' + + 'Until this shape was closed, these were dropped silently — branding still parsed, so a theme the ' + 'author set never reached the shell.', }, { primaryColor: z.string().optional().describe('Primary theme color hex code'), - accentColor: z.string().optional().describe('Accent color hex code (highlights, active states). Declared to match the objectui ConsoleLayout read of branding.accentColor (inverse-drift fix, liveness audit #1878/#1891/#1894).'), + accentColor: z.string().optional().describe('Accent color hex code (highlights, active states). Declared to match the objectui ConsoleLayout read of branding.accentColor (inverse-drift fix, liveness audit /).'), logo: z.string().optional().describe('Custom logo URL for this app'), favicon: z.string().optional().describe('Custom favicon URL for this app'), })); @@ -804,7 +804,7 @@ export const AppBrandingSchema = lazySchema(() => strictObject( * `order` to rearrange areas saw nothing move. */ const AREA_ORDER_RETIRED = - '`areas[].order` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no renderer ' + '`areas[].order` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no renderer ' + 'ever sorted areas; both the sidebar and the schema renderer iterate the array as ' + 'authored, so declaration order already IS display order. Delete the key and reorder the ' + '`areas` array itself. NOTE the neighbour that behaves differently: a navigation ITEM\'s ' @@ -850,27 +850,27 @@ const AREA_ORDER_RETIRED = * (`areas.navigation`) and pinned in `packages/rest/src/rest.test.ts`. */ const AREA_VISIBLE_RETIRED = - '`areas[].visible` was removed in @objectstack/spec 17.0.0 (#4651, ADR-0049) — nothing ever ' + '`areas[].visible` was removed in @objectstack/spec 17.0.0 (ADR-0049) — nothing ever ' + 'evaluated an area-level predicate, so an area "hidden" by one rendered for EVERYONE: a ' + 'gate that fails open, which is worse than no gate at all. Delete the key and gate the ' + 'items INSIDE the area — a navigation ITEM\'s `visible` takes the same CEL expression and ' + 'IS evaluated per item by the shell. For a gate the SERVER enforces, use ' + '`requiredPermissions` instead: on the app itself, or on the ITEMS of either navigation ' + 'tree — the app\'s top-level `navigation` AND every `areas[].navigation`, both stripped ' - + 'server-side since #4722. The distinction survives at every level: `visible` is CEL ' + + 'server-side. The distinction survives at every level: `visible` is CEL ' + 'evaluated in the browser, so it hides an entry that has already been sent, while ' + '`requiredPermissions` stops that entry from being served at all. Run ' + '`os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.'; const AREA_REQUIRED_PERMISSIONS_RETIRED = - '`areas[].requiredPermissions` was removed in @objectstack/spec 17.0.0 (#4651, ADR-0049) — ' + '`areas[].requiredPermissions` was removed in @objectstack/spec 17.0.0 (ADR-0049) — ' + 'no layer ever checked it, so a "permission-gated" area was served to, and rendered for, ' + 'every user: a fail-open access gate, not merely an unread key. Delete it and move the ' + 'gate to a layer that is actually enforced. `requiredPermissions` on the APP is checked ' + 'server-side (the app is dropped from /meta entirely for a caller who lacks them), and ' + '`requiredPermissions` / `requiresService` on a navigation ITEM are stripped server-side ' + 'in BOTH trees — the app\'s top-level `navigation` AND every `areas[].navigation`, through ' - + 'the same filter since #4722 — then re-checked in the shell, so an item gated inside an ' + + 'the same filter — then re-checked in the shell, so an item gated inside an ' + 'area never reaches the browser either. That enforces the items INSIDE an area; the ' + 'area-level key is not revived. Still evaluated client-side ONLY, at every level: ' + '`visible` (CEL) and `requiresObject` — so anything that must never reach the browser ' @@ -928,7 +928,7 @@ export const NavigationAreaSchema = lazySchema(() => strictObject( permissions: AREA_REQUIRED_PERMISSIONS_RETIRED, }, history: - 'Until #4001 these were dropped silently — the area still parsed, so its gating or ' + + 'Until this shape was closed, these were dropped silently — the area still parsed, so its gating or ' + 'ordering was quietly ignored.', }, { @@ -1007,7 +1007,7 @@ export const NavigationAreaSchema = lazySchema(() => strictObject( */ const CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE: Readonly> = { includeAll: - '`contextSelectors[].includeAll` was removed in @objectstack/spec 17.0.0 (#4509, ' + '`contextSelectors[].includeAll` was removed in @objectstack/spec 17.0.0 (' + 'ADR-0049) — the shell deliberately ignored it. A context selector is a MANDATORY ' + 'scope: an "All" row would clear the scope on a surface that exists to be scoped, and ' + "on Studio's package selector that means listing the platform's own system/cloud " @@ -1018,16 +1018,16 @@ const CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE: Readonly> = + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', showall: '`contextSelectors[].includeAll` (which `showall` aliased) was removed in ' - + '@objectstack/spec 17.0.0 (#4509) — selectors are mandatory-scope and never render an ' + + '@objectstack/spec 17.0.0 — selectors are mandatory-scope and never render an ' + '"All" row. Delete the key; widen `optionsSource.filter` to widen the choices.', placement: - '`contextSelectors[].placement` was removed in @objectstack/spec 17.0.0 (#4509, ' + '`contextSelectors[].placement` was removed in @objectstack/spec 17.0.0 (' + 'ADR-0049) — no renderer ever read it. Selectors always render in the sidebar header ' + "block, and `'topbar'` placed nothing in the topbar. Delete the key. Run " + '`os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', location: '`contextSelectors[].placement` (which `location` aliased) was removed in ' - + '@objectstack/spec 17.0.0 (#4509) — selectors always render in the sidebar header. ' + + '@objectstack/spec 17.0.0 — selectors always render in the sidebar header. ' + 'Delete the key.', }; @@ -1037,7 +1037,7 @@ export const AppContextSelectorSchema = lazySchema(() => strictObject( aliases: { name: 'id', title: 'label', source: 'optionsSource', options: 'optionsSource' }, guidance: CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE, history: - 'Until #4001 these were dropped silently — the selector still parsed, so its scope ' + + 'Until this shape was closed, these were dropped silently — the selector still parsed, so its scope ' + 'variable behaved differently than declared.', }, { @@ -1065,7 +1065,7 @@ export const AppContextSelectorSchema = lazySchema(() => strictObject( surface: "this context selector's options source", aliases: { url: 'endpoint', path: 'endpoint', value: 'valueKey', label: 'labelKey', filters: 'filter', where: 'filter' }, history: - 'Until #4001 these were dropped silently — the source still parsed, so the ' + + 'Until this shape was closed, these were dropped silently — the source still parsed, so the ' + 'dropdown resolved its options from a different shape than declared.', }, { @@ -1093,7 +1093,7 @@ export const AppContextSelectorSchema = lazySchema(() => strictObject( surface: 'this context-selector option filter', aliases: { field: 'key', path: 'key', operator: 'op', values: 'value' }, history: - 'Until #4001 these were dropped silently — the predicate still parsed, so the ' + + 'Until this shape was closed, these were dropped silently — the predicate still parsed, so the ' + 'option list was not narrowed the way the author declared.', }, { @@ -1199,7 +1199,7 @@ export type AppContextSelectorParsed = z.infer; * objectui, tracked for removal in objectstack-ai/objectui#3264. */ const HOME_PAGE_ID_RETIRED = - '`app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). ' + '`app.homePageId` was removed in @objectstack/spec 17.0.0 (ADR-0049). ' + 'objectui\'s console did read it before v17 (`resolveLandingRoute`), so this key had a ' + 'consumer — it was retired because the capability is better expressed on the navigation ' + 'item itself than as an ID cross-reference that silently falls back when it dangles. An ' @@ -1230,7 +1230,7 @@ const UNPUBLISHED_IS_MACHINE_MANAGED = + '`_unpublished` key: the AI materialization path sets it, and `POST /packages/:id/publish-drafts` ' + '(the "Publish" button) clears it. Delete this key. If you wanted to keep the app out of the App ' + 'Switcher — the personal-settings case, e.g. Account — that is `hidden: true`, which is navigation ' - + 'presentation ONLY and never affects access (#4829).'; + + 'presentation ONLY and never affects access.'; export const AppSchema = lazySchema(() => strictObject( { @@ -1280,7 +1280,7 @@ export const AppSchema = lazySchema(() => strictObject( draft: UNPUBLISHED_IS_MACHINE_MANAGED, }, history: - 'Until #4001 these were dropped silently — the app still parsed, so navigation or ' + + 'Until this shape was closed, these were dropped silently — the app still parsed, so navigation or ' + 'gating the author declared never reached the shell.', }, { @@ -1446,8 +1446,8 @@ export const AppSchema = lazySchema(() => strictObject( apis: retiredKey( '`App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' + 'never read). Delete the key and declare the endpoint one level up, on the STACK: ' + - '`defineStack({ apis })`. That surface EXECUTES from protocol 17 (#5040). Between ' + - '#4936 and the executor landing it was refused wholesale — nothing mounted a declared ' + + '`defineStack({ apis })`. That surface EXECUTES from protocol 17. Before ' + + 'the executor landed it was refused wholesale — nothing mounted a declared ' + 'path, so every key including `authRequired` parsed and gated nothing — and that ' + 'blanket refusal is now narrowed to five per-endpoint publish gates (namespace, ' + 'supported target, mapping, policy, uniqueness): an endpoint that passes them is ' + @@ -1461,7 +1461,7 @@ export const AppSchema = lazySchema(() => strictObject( 'security review, not a rename. A route that genuinely needs handler CODE is mounted ' + 'imperatively instead: resolve the `http.server` service from your plugin context and ' + 'register the route on `kernel:ready` (NOT the manifest `contributes.routes` key — ' + - 'removed in @objectstack/spec 17, #10726: nothing ever read it, and authoring it is ' + + 'removed in @objectstack/spec 17: nothing ever read it, and authoring it is ' + 'now rejected with its own prescription). ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), diff --git a/packages/spec/src/ui/bulk-action.zod.ts b/packages/spec/src/ui/bulk-action.zod.ts index 5440cde65e..17f99d36bf 100644 --- a/packages/spec/src/ui/bulk-action.zod.ts +++ b/packages/spec/src/ui/bulk-action.zod.ts @@ -191,7 +191,7 @@ export const BulkActionDefSchema = lazySchema(() => strictObject( + 'the whole selection in `params._selectedIds` instead of a single record id.', }, history: - 'Until #4457 the whole array was `z.array(z.record(z.string(), z.any()))` — every key parsed, ' + 'Until this shape was closed, the whole array was `z.array(z.record(z.string(), z.any()))` — every key parsed, ' + 'so a mis-spelled one shipped as a button that silently ran the DEFAULT behaviour (or none ' + 'at all).', }, @@ -201,12 +201,12 @@ export const BulkActionDefSchema = lazySchema(() => strictObject( icon: z.string().optional().describe('Lucide icon name (e.g. "user-check", "trash-2").'), variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'outline']).optional().describe('Visual treatment of the button.'), operation: BulkActionOperationSchema.describe("What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`)."), - execution: BulkActionExecutionSchema.optional().describe("For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds` (objectui#3139). Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead."), + execution: BulkActionExecutionSchema.optional().describe("For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds`. Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead."), patch: z.record(z.string(), z.unknown()).optional().describe("For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog."), params: z.array(BulkActionParamSchema).optional().describe('Inputs collected once before the run. Omit to skip the params step and go straight to confirm.'), confirmText: z.string().optional().describe('Confirmation text shown above the affected-record summary.'), confirmLabel: z.string().optional().describe('Custom Confirm button label (default: "Run").'), - visible: ExpressionInputSchema.optional().describe('Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm (#5970): a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record.'), + visible: ExpressionInputSchema.optional().describe('Eligibility predicate (CEL) — a string or a `{dialect, source}` envelope, i.e. `action.visible` without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record.'), requiredPermissions: z.array(z.string()).optional().describe("[ADR-0066 D4] Capability gate on the button, `action.requiredPermissions` semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the `update`/`delete` data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from `bulkActions: ['']` (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks."), maxRecords: z.number().int().positive().optional().describe('Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request.'), batchSize: z.number().int().positive().optional().describe('Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition.'), diff --git a/packages/spec/src/ui/chart.test.ts b/packages/spec/src/ui/chart.test.ts index 77f62e9646..6f0947aceb 100644 --- a/packages/spec/src/ui/chart.test.ts +++ b/packages/spec/src/ui/chart.test.ts @@ -363,13 +363,17 @@ describe('#4001 批 15 — the five closed chart sites', () => { expect(msg).toContain('`to` → `endValue`'); }); - it('carries the #3752 tombstones, one distinct sentence each (批 10)', () => { + it('carries the retired-interaction tombstones, one distinct sentence each (批 10)', () => { const zoom = reject(ChartInteractionSchema as never, { zoom: true }); - expect(zoom).toContain('#3752'); + expect(zoom).toContain('`zoom` was removed'); expect(zoom).toContain('brush: true'); + expect(zoom, 'the prescription teaches the replacement, never a tracker id') + .not.toMatch(/#\d{3,5}/); const click = reject(ChartInteractionSchema as never, { clickAction: 'x' }); - expect(click).toContain('#3752'); + expect(click).toContain('`clickAction` was removed'); expect(click).toContain('onSegmentClick'); + expect(click, 'the prescription teaches the replacement, never a tracker id') + .not.toMatch(/#\d{3,5}/); // Two keys at once ⇒ two DISTINCT bullets, not one string printed twice. const both = reject(ChartInteractionSchema as never, { zoom: true, clickAction: 'x' }); expect(both.split('• ').length - 1).toBe(2); @@ -642,8 +646,8 @@ describe('#5022 — ChartDrillDownSchema', () => { it.each([ ['mode', 'TABLE / PIVOT / METRIC'], ['report', 'METRIC / PIVOT'], - ['view', 'objectui#3354'], - ['sort', 'objectui#3354'], + ['view', 'read by no renderer at all'], + ['sort', 'read by no renderer'], ])('`%s` is rejected with the reason it is absent, not a rename', (key, expected) => { // Each of these is REAL somewhere — on another widget, or (view/sort) in a // renderer type that nothing reads. Edit distance would have proposed a @@ -670,7 +674,8 @@ describe('#5022 — ChartDrillDownSchema', () => { // implement that arm" is now false, and #5046's lesson is that a dead limb // left in place reads as live to the next author. const msg = reject({ target: 'sidebar' }); - expect(msg, 'the retired navigate prescription must not survive').not.toContain('objectui#3354'); + expect(msg, 'the retired navigate prescription must not survive — and no rejection\n' + + 'on this surface carries a tracker id any more').not.toMatch(/#\d{3,5}/); expect(msg, 'nor its claim about what a chart cannot do').not.toContain('not supported by a chart'); }); diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index cc2dc5043b..c09faa6f6c 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -165,7 +165,7 @@ export const ChartAxisSchema = lazySchema(() => strictObject( { surface: 'this chart axis', history: - 'Until #4001 an undeclared axis key was dropped at parse and the axis rendered with the default scale and ticks — a chart that looked configured and was not.', + 'Until this shape was closed, an undeclared axis key was dropped at parse and the axis rendered with the default scale and ticks — a chart that looked configured and was not.', // MEASURED same-file inconsistency, both directions: this schema names its // bound column `field` and its caption `title`, while `ChartSeriesSchema` // twenty lines below names them `name` and `label`. An author who has just @@ -216,7 +216,7 @@ export const ChartSeriesSchema = lazySchema(() => strictObject( { surface: 'this chart series', history: - 'Until #4001 an undeclared series key was dropped at parse — the series still drew, in the palette colour, on the left axis, unstacked, which is precisely the configuration the author was overriding.', + 'Until this shape was closed, an undeclared series key was dropped at parse — the series still drew, in the palette colour, on the left axis, unstacked, which is precisely the configuration the author was overriding.', // The mirror of `ChartAxisSchema`'s entries: `field`/`title` are the axis // spellings of this schema's `name`/`label`. `stackId` / `yAxisId` / // `strokeDasharray` are Recharts' prop names — and `dashArray`'s own @@ -285,7 +285,7 @@ export const ChartAnnotationSchema = lazySchema(() => strictObject( { surface: 'this chart annotation', history: - 'Until #4001 an undeclared annotation key was dropped at parse and the reference line drew at the wrong place, in the default style, or not at all — while the annotation itself reported valid.', + 'Until this shape was closed, an undeclared annotation key was dropped at parse and the reference line drew at the wrong place, in the default style, or not at all — while the annotation itself reported valid.', // A region is authored as a RANGE, and every neighbouring range vocabulary // in the protocol spells its ends `from`/`to` or `start`/`end` // (`data/filter.zod.ts` operators, the dashboard date-range filter). This @@ -358,7 +358,7 @@ export const ChartInteractionSchema = lazySchema(() => strictObject( { surface: 'this chart interaction block', history: - 'Until #4001 an undeclared interaction key was dropped at parse — including the two #3752 removed, so an author who kept writing `zoom` after it was retired got exactly the same silence as before the removal.', + 'Until this shape was closed, an undeclared interaction key was dropped at parse — including the two removed with it, so an author who kept writing `zoom` after it was retired got exactly the same silence as before the removal.', aliases: { tooltip: 'tooltips', hover: 'tooltips', showTooltip: 'tooltips', rangeSelector: 'brush', slider: 'brush' }, // The prescriptions #3752 wrote in this file's own doc comment, now // delivered at the rejection instead of only to whoever reads the source. @@ -367,9 +367,9 @@ export const ChartInteractionSchema = lazySchema(() => strictObject( // `join`/`joinGateway` lesson). guidance: { zoom: - '`zoom` was removed in #3752 — no renderer ever had a zoom primitive behind it, and `brush` already narrows the visible range. Write `brush: true`.', + '`zoom` was removed — no renderer ever had a zoom primitive behind it, and `brush` already narrows the visible range. Write `brush: true`.', clickAction: - '`clickAction` was removed in #3752 — a segment click already has owners that work: the host\'s own `onSegmentClick` in the react tier, `drilldown` on a report (`ReportSchema.drilldown`, ADR-0021 D2, already on by default), and the renderer\'s segment drill under a dashboard widget\'s `options` bag. Use one of those.', + '`clickAction` was removed — a segment click already has owners that work: the host\'s own `onSegmentClick` in the react tier, `drilldown` on a report (`ReportSchema.drilldown`, ADR-0021 D2, already on by default), and the renderer\'s segment drill under a dashboard widget\'s `options` bag. Use one of those.', }, }, { @@ -430,7 +430,7 @@ export const ChartDrillDownSchema = lazySchema(() => strictObject( { surface: 'this chart drill-down block', history: - 'Until #5022 `drillDown` was not declared anywhere in this protocol at all — objectui\'s ObjectChart read it as an untyped `(schema as any).drillDown`, so every key inside it, right or wrong, reached the renderer unchecked and a misspelling was simply ignored at click time.', + 'Until this shape was closed, `drillDown` was not declared anywhere in this protocol at all — objectui\'s ObjectChart read it as an untyped `(schema as any).drillDown`, so every key inside it, right or wrong, reached the renderer unchecked and a misspelling was simply ignored at click time.', aliases: { enable: 'enabled', on: 'enabled', active: 'enabled', where: 'filter', criteria: 'filter', filters: 'filter', @@ -451,9 +451,9 @@ export const ChartDrillDownSchema = lazySchema(() => strictObject( report: '`report` (drill into an analytical report instead of the record list) is a METRIC / PIVOT widget capability in the objectui renderer; `` does not read it and renders the record list regardless. Delete the key, or drill from a metric widget instead.', view: - '`view` (render a named list view inside the drill drawer) is declared in objectui\'s renderer-side type as reserved and is read by no renderer at all (objectui#3354). It has never done anything — delete it and use `columns` to choose what the drill list shows.', + '`view` (render a named list view inside the drill drawer) is declared in objectui\'s renderer-side type as reserved and is read by no renderer at all. It has never done anything — delete it and use `columns` to choose what the drill list shows.', sort: - '`sort` (default ordering for the drill list) is declared in objectui\'s renderer-side type and read by no renderer (objectui#3354). Delete it; the drill list uses the object\'s own default ordering.', + '`sort` (default ordering for the drill list) is declared in objectui\'s renderer-side type and read by no renderer. Delete it; the drill list uses the object\'s own default ordering.', }, }, { @@ -540,7 +540,7 @@ export const ChartConfigSchema = lazySchema(() => strictObject( { surface: 'this chart config', history: - 'Until #4001 an undeclared chart key was dropped at parse and the chart rendered with the defaults it was written to override — the failure #4001 exists for, on a shape reachable from both the dashboard and report metadata roots.', + 'Until this shape was closed, an undeclared chart key was dropped at parse and the chart rendered with the defaults it was written to override — the failure closing this shape exists for, on a shape reachable from both the dashboard and report metadata roots.', aliases: { // `chartType` is named in `react-blocks.ts` as the INTERNAL spelling that // is deliberately NOT part of the author contract, so an author who saw @@ -770,7 +770,7 @@ export const ChartGroupBySchema = lazySchema(() => { surface: 'this chart groupBy', history: - 'Until #5583 an undeclared key inside the structured groupBy was dropped at parse — `dateGranularty` for `dateGranularity` cost the date bucketing silently, and the chart drew one point per raw timestamp.', + 'Until this shape was closed, an undeclared key inside the structured groupBy was dropped at parse — `dateGranularty` for `dateGranularity` cost the date bucketing silently, and the chart drew one point per raw timestamp.', // The near-misses edit distance cannot reach, each anchored to a // neighbouring vocabulary this protocol really uses: // `granularity` — `dateGranularity` is the only spelling in the @@ -835,7 +835,7 @@ export const ChartAggregateSchema = lazySchema(() => { surface: 'this chart aggregate', history: - 'Until #5583 an undeclared aggregate key was dropped at parse — `groupby` for `groupBy` degraded the chart to a single ungrouped point, and `fn` for `function` fell back to the default, both with `build`/`validate` fully green.', + 'Until this shape was closed, an undeclared aggregate key was dropped at parse — `groupby` for `groupBy` degraded the chart to a single ungrouped point, and `fn` for `function` fell back to the default, both with `build`/`validate` fully green.', // The near-misses edit distance cannot reach (the folded fallback already // covers `groupby`, `Group_By` and `functoin`, so none of those appears // here — a second spelling of a covered probe is a dead entry, #5481): diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 82e721ce03..6ac1b916ba 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -956,7 +956,7 @@ describe('RecordHighlightsProps', () => { // "drawn by nothing" story, and the no-replacement guidance. The `s` // flag is house style: the message spans lines. expect(armIssue!.message).toMatch(/this `record:highlights` field/); - expect(armIssue!.message).toMatch(/`record:highlights` field `icon` was removed .*#10054.*ADR-0049/s); + expect(armIssue!.message).toMatch(/`record:highlights` field `icon` was removed .*ADR-0049/s); expect(armIssue!.message).toMatch(/Delete the key — no replacement: the renderer never drew it/s); expect(armIssue!.message).toMatch(/os migrate meta --from 17/); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index f176f06178..f208800892 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -235,7 +235,7 @@ import type { KeySetGuidance } from '../shared/suggestions.zod'; * walker's reconstruction of it. */ const PROPS_HISTORY = - 'Until #4001 batch A an undeclared prop was dropped in silence: the props schema stripped it ' + 'Until this shape was closed, an undeclared prop was dropped in silence: the props schema stripped it ' + 'and `PageComponent.properties` is an open bag, so the key reached objectui\'s renderer, was ' + 'not read there, and the author got a success receipt for configuration that did nothing.'; @@ -276,7 +276,7 @@ const COMPONENT_NODE_VISIBILITY_GUIDANCE: KeySetGuidance = 'Visibility is a COMPONENT-level predicate, not a prop: move it up one level to the ' + 'component node\'s own `visibleWhen` (ADR-0089 canonical spelling), beside `type` and ' + '`id` — one canonical spelling per layer, not because the props-level form is inert. ' - + 'Since objectui#5505 (`c86185eb5`, merged 2026-08-21) the hoisted form IS evaluated by ' + + 'Since the console release of 2026-08-21 (`c86185eb5`) the hoisted form IS evaluated by ' + 'the node-level gate: the two gates evaluate the same value and compose as an ' + 'idempotent AND, so leaving it in `properties` duplicates the canonical key rather ' + 'than silently failing to gate.', @@ -378,8 +378,8 @@ export const PageContainerProps = strictObject( // schedule rather than an authorable key. Closing the shape is what makes // that distinction reach the author. body: '`body` is not an authorable spelling of the composition slot — write `children`. ' - + 'The renderers still read `body` as a back-compat fallback for documents stored before ' - + '#5775, but one composition key is the contract (Prime Directive #12).', + + 'The renderers still read `body` as a back-compat fallback for documents stored under ' + + 'the older spelling, but one composition key is the contract (Prime Directive #12).', }, }, { @@ -454,7 +454,7 @@ export const PageHeaderProps = strictObject({ * buttons beside it. */ icon: retiredKey( - '`page:header` property `icon` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — ' + '`page:header` property `icon` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — ' + 'no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), ' + 'never off the header\'s own props bag, and the component registry never published it as an ' + 'input, so an authored value was accepted and dropped. Delete the key. The header\'s own ' @@ -566,7 +566,7 @@ export const PageTabsProps = strictObject({ * called `type`. The live mechanism is `tabStyle`. */ type: retiredKey( - '`page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (#6776, ADR-0087 D2) — ' + '`page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — ' + 'a props key named `type` collides with the page component\'s own dispatch key, so it is ' + 'unauthorable in the flat and JSX carriers and was never validated in them. Rename the key ' + 'to `tabStyle`; the value (`line` | `card` | `pill`) is unchanged. ' @@ -746,7 +746,7 @@ export const PageCardProps = strictObject({ * `children` or `footer` (`element:button`, `record:quick_actions`). */ actions: retiredKey( - '`page:card` property `actions` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — ' + '`page:card` property `actions` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — ' + 'no renderer ever read it: objectui\'s card renderer builds its `` from `title`, ' + '`bordered`, `children` and `footer` only, has no actions area, and the component registry ' + 'never published it as an input, so an authored value was accepted and dropped. Delete the ' @@ -772,7 +772,7 @@ export const PageCardProps = strictObject({ * already reads both. The live mechanism is `children`. */ body: retiredKey( - '`page:card` property `body` was removed in @objectstack/spec 17.0.0 (#5775, ADR-0087 D2) — ' + '`page:card` property `body` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — ' + 'it was a second spelling of the composition slot every other container calls `children`, ' + 'and the renderer reads both. Rename the key to `children`; the value (an array of child ' + 'components) is unchanged. ' @@ -817,7 +817,7 @@ export const RecordDetailsProps = strictObject({ * dead branch on the next pin bump. */ layout: retiredKey( - '`record:details` property `layout` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — ' + '`record:details` property `layout` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — ' + 'its declared `auto` | `custom` semantics were never implemented: the renderer tests `layout` ' + 'only against `inline` | `compact`, two values the schema never permitted, so both legal ' + 'values took the same branch and the key selected nothing. Delete the key — the body is ' @@ -1119,7 +1119,7 @@ export const RecordHighlightsField = z.union([ // no `retiredKey` tombstone — the key is out of the walked shape // entirely, and the refusal is the arm's own named `unrecognized_keys`). icon: - '`record:highlights` field `icon` was removed in @objectstack/spec 17 (#10054, ADR-0049) — ' + '`record:highlights` field `icon` was removed in @objectstack/spec 17 (ADR-0049) — ' + 'no render path ever read it: the renderer normalized the authored object and passed ' + '`icon` to a highlight chip with no icon slot, and the key could travel nowhere else ' + '(`useRegisterHighlightFields` registers field NAMES only; the Studio designer publishes ' @@ -1371,7 +1371,7 @@ export const ReferenceRailEntrySchema = strictObject({ */ filter: 'The rail honours no per-entry `filter`: it issues one fixed query per entry ' + '(`{ [relationshipField]: parentId }`, `$top` = `limit`) and reads nothing else — before ' - + 'this shape existed the key parsed, shipped, and silently filtered nothing (#8691). ' + + 'this shape existed the key parsed, shipped, and silently filtered nothing. ' + '`record:related_list` is the component whose `filter` is real; if the rail is ever ' + 'granted one, this entry shape is where it gets declared and enforced.', icon: '`icon` is read by nothing: the rail renderer declares it in its TS interface and the ' @@ -2064,7 +2064,7 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ */ targetVariable: retiredKey( '`element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 ' - + '(#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding ' + + '(ADR-0049) — it was a declarative hint no renderer ever read: the live binding ' + "runs the other direction, resolved from the page variable whose `source` names this " + "component's `id`, so authoring only `targetVariable` bound nothing while reporting " + 'success. Delete the key; to bind the picked record id, declare it on the variable — ' @@ -2080,7 +2080,7 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ */ displayField: retiredKey( '`element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 ' - + '(#5775, ADR-0087 D2) — it was a required declaration no renderer ever read, while the ' + + '(ADR-0087 D2) — it was a required declaration no renderer ever read, while the ' + 'renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key ' + 'to `labelField`; the value (a field name) is unchanged. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', @@ -2091,7 +2091,7 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ */ searchFields: retiredKey( '`element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 ' - + '(#5775, ADR-0049) — the picker renders a plain single-select with no search input, so no ' + + '(ADR-0049) — the picker renders a plain single-select with no search input, so no ' + 'renderer ever read it and it narrowed nothing. Delete the key. To restrict which records ' + 'the picker offers, use `filter` (or the component-level `dataSource.filter`), which the ' + 'query path does apply. ' @@ -2103,7 +2103,7 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ */ multiple: retiredKey( '`element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 ' - + '(#5775, ADR-0049) — the picker is a single-select `Select` and the bound page variable ' + + '(ADR-0049) — the picker is a single-select `Select` and the bound page variable ' + 'holds one record id, so `multiple: true` selected nothing extra and reported success. ' + 'Delete the key; multi-record selection is not implemented on this element. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', @@ -2148,7 +2148,7 @@ export const ElementTextInputPropsSchema = lazySchema(() => strictObject({ */ targetVariable: retiredKey( '`element:text_input` property `targetVariable` was removed in @objectstack/spec 17 ' - + '(#9198, ADR-0049) — it was a declarative hint no renderer ever read: the live binding ' + + '(ADR-0049) — it was a declarative hint no renderer ever read: the live binding ' + "runs the other direction, resolved from the page variable whose `source` names this " + "component's `id`, so authoring only `targetVariable` bound nothing while reporting " + 'success. Delete the key; to bind the typed value, declare it on the variable — ' @@ -2262,7 +2262,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ aliases: FILTERS_TO_FILTER, }, { objectName: z.string().optional() - .describe('Object this grid binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953)'), + .describe('Object this grid binds to. Optional because the component-level `dataSource` binding can supply the object instead'), label: I18nLabelSchema.optional().describe('Grid label — used as the table caption and export file title'), title: I18nLabelSchema.optional().describe('Fallback for `label` (the renderer reads `label || title`)'), columns: z.array(z.unknown()).optional() @@ -2270,7 +2270,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ fields: z.array(z.unknown()).optional() .describe('Field list fallback used when `columns` is absent'), filter: z.unknown().optional() - .describe('Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key #7750 misspelled as plural'), + .describe('Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key, singular — not the plural misspelling'), defaultFilters: z.unknown().optional() .describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'), sort: z.unknown().optional().describe('Initial sort (array of { field, order })'), @@ -2297,7 +2297,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * delete when `sort` is present, since the fallback was never read then). */ defaultSort: retiredKey( - '`object-grid` property `defaultSort` was removed in @objectstack/spec 17 (#11805, ADR-0049) — ' + '`object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — ' + 'it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when ' + '`sort` was absent, so one intent had two spellings and a grid authoring both silently ignored ' + 'this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` ' @@ -2376,7 +2376,7 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ aliases: FILTERS_TO_FILTER, }, { objectName: z.string().optional() - .describe('Object this metric aggregates. Optional because the component-level `dataSource` binding can supply the object instead (#6953)'), + .describe('Object this metric aggregates. Optional because the component-level `dataSource` binding can supply the object instead'), label: I18nLabelSchema.optional().describe('Metric label'), description: I18nLabelSchema.optional().describe('Helper text under the value'), title: I18nLabelSchema.optional().describe('Drill-down panel title; defaults to the metric label'), @@ -2462,7 +2462,7 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ }, }, { objectName: z.string().optional() - .describe('Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953)'), + .describe('Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead'), groupBy: z.string().optional().describe('Field whose values become the board columns'), columns: z.array(z.unknown()).optional() .describe('Swimlane definitions ({ id, title } per `groupBy` value, or bare value strings) — NOT a field projection'), @@ -2517,7 +2517,7 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ aliases: FILTERS_TO_FILTER, }, { objectName: z.string().optional() - .describe('Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead (#6953)'), + .describe('Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead'), calendar: z.unknown().optional() .describe('Calendar field config: { startDateField, endDateField?, titleField?, colorField?, allDayField? }'), defaultView: z.enum(['month', 'week', 'day']).optional().describe('Initial view mode'), @@ -2548,7 +2548,7 @@ export const ObjectFormPropsSchema = lazySchema(() => strictObject({ guidanceSets: COMPONENT_LEVEL_GUIDANCE, }, { objectName: z.string().optional() - .describe('Object this form creates/edits. Optional because the component-level `dataSource` binding can supply the object instead (#6953)'), + .describe('Object this form creates/edits. Optional because the component-level `dataSource` binding can supply the object instead'), recordId: z.union([z.string(), z.number()]).optional().describe('Record to load (edit/view modes)'), mode: z.enum(['create', 'edit', 'view']).optional().describe('Form mode'), formType: z.enum(['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']).optional() @@ -2661,13 +2661,13 @@ export const ObjectMasterDetailFormPropsSchema = lazySchema(() => strictObject({ guidanceSets: COMPONENT_LEVEL_GUIDANCE, }, { objectName: z.string().optional() - .describe('PARENT object. Optional because the component-level `dataSource` binding can supply the object instead (#7121)'), + .describe('PARENT object. Optional because the component-level `dataSource` binding can supply the object instead'), recordId: z.union([z.string(), z.number()]).optional().describe('Parent record to load (edit mode)'), mode: z.enum(['create', 'edit']).optional().describe('Form mode'), formType: z.enum(['simple', 'tabbed'], { error: (issue) => typeof issue.input === 'string' ? MASTER_DETAIL_FORM_TYPE_RETIRED.get(issue.input) : undefined, - }).optional().describe("Parent form presentation — the two variants the renderer honours for the parent half (#11873, objectui#5939)"), + }).optional().describe("Parent form presentation — the two variants the renderer honours for the parent half"), sections: z.array(z.unknown()).optional().describe('Parent form sections'), fields: z.array(z.unknown()).optional().describe('Parent fields shown'), details: z.array(z.unknown()).optional() diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 1f5bec2575..f856b4f95c 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -544,7 +544,7 @@ describe('[#4876] DashboardWidgetSchema — retired `responsive`', () => { // the fully-qualified key, the version, the issue, and the fix. expect(message).toMatch(/dashboard\.widgets\[\]\.responsive/); expect(message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); - expect(message).toMatch(/#4876/); + expect(message).toMatch(/ADR-0049 D2/); expect(message).toMatch(/Delete the key/); // The redirect correction (#11027): this message used to prescribe // `page.components[].responsive` as the live home of the shared shape; @@ -552,7 +552,7 @@ describe('[#4876] DashboardWidgetSchema — retired `responsive`', () => { // must (a) say so rather than silently dropping the old promise — an // author who followed it needs the correction — and (b) point at the // channel that IS applied. - expect(message).toMatch(/page\.components\[\]\.responsive[\s\S]*#11027/); + expect(message).toMatch(/page\.components\[\]\.responsive[\s\S]*/); expect(message).toMatch(/responsiveStyles/); // `.strict()` on this schema would answer a DELETED key with a generic // unrecognized-key error. The tombstone is what makes it a prescription — @@ -637,7 +637,7 @@ describe('[#5010] DashboardWidgetSchema — retired action trio + `aria`', () => expect(message).toMatch(/dashboard\.widgets\[\]\.aria/); expect(message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); - expect(message).toMatch(/#5010/); + expect(message).toMatch(/ADR-0049 D2/); expect(message).toMatch(/Delete the key/); // The shared shape survives elsewhere. Without this, the message reads as // "AriaProps is gone", which would send an author deleting live metadata. diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 36edf17211..681b857cdc 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -72,7 +72,7 @@ export const WidgetActionTypeSchema = lazySchema(() => ActionType.describe('Widg * the widget was rescued from. */ const DASHBOARD_HISTORY = - 'Until #4001 closed this shape these were dropped silently — the dashboard still rendered, ' + 'Until this shape was closed these were dropped silently — the dashboard still rendered, ' + 'without whatever the key was meant to configure.'; /** @@ -171,7 +171,7 @@ const WIDGET_GUIDANCE_SETS = [ keys: ['component', 'data'], prescription: '`component` and inline `data` are objectui-internal renderer capabilities, ' - + 'not part of the author-facing dashboard spec (framework#3251).', + + 'not part of the author-facing dashboard spec.', }, { name: 'WIDGET_DRILL_NEAR_KEYS', @@ -269,7 +269,7 @@ export const DashboardWidgetOptionsSchema = lazySchema(() => z.object({ // an exact equivalent, and the rest are expressible as a kind plus the window // the widget's own `filter` resolves to. const COMPARE_TO_OFFSET_RETIRED = - '`dashboard.widgets[].compareTo.offset` was removed in @objectstack/spec 17.0.0 (#5011, ' + '`dashboard.widgets[].compareTo.offset` was removed in @objectstack/spec 17.0.0 (' + 'ADR-0049 enforce-or-remove) — the analytics executor never had an `offset` concept, so on the ' + 'ADR-0021 dataset path this arm did not shift a window, it threw ' + '(`compareTo requires a timeDimension "undefined"`) and took the whole widget down with it. ' @@ -600,13 +600,13 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ // shape are retired too (`page.zod.ts` tombstone, ADR-0049 D2); the live // per-breakpoint channel is `responsiveStyles` (ADR-0065). responsive: retiredKey( - '`dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (#4876, ADR-0049 D2) — ' + + '`dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — ' + 'no renderer ever read it, so per-widget breakpoint overrides were never applied: the value ' + 'parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules ' + '(`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. ' + 'This message used to point at `page.components[].responsive` as the live home of the shared ' + '`ResponsiveConfig` shape; that key was measured equally unread and removed with the shape ' + - 'in #11027. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page ' + + 'with it. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page ' + 'component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), @@ -628,10 +628,10 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ // "lift it up to the app" repair this note used to imply was a dead end // that also lost the block (#6756). aria: retiredKey( - '`dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 D2) — ' + + '`dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — ' + 'no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach ' + 'the DOM: the key promised accessibility compliance it did not deliver. This is the same ' + - 'removal the dashboard-level `aria` got in 17.0.0 (#3896). Delete the key. The dashboard ' + + 'removal the dashboard-level `aria` got in 17.0.0. Delete the key. The dashboard ' + 'renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and ' + '`description`) on the widget instead — those ARE what the renderer labels the card with. ' + 'The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, ' + @@ -762,7 +762,7 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({ * keyed by. `optionsFrom.object` already proves the schema is willing to * name an object here — this reuses that same primitive one level up. */ - object: z.string().optional().describe('Object whose `fields..` translation-bundle entry resolves this filter\'s field label and option labels (#7804)'), + object: z.string().optional().describe('Object whose `fields..` translation-bundle entry resolves this filter\'s field label and option labels'), /** Display label for the filter */ label: I18nLabelSchema.optional().describe('Display label for the filter'), @@ -923,13 +923,13 @@ export const DashboardSchema = lazySchema(() => strictObject({ // inert — no DashboardRenderer path applied either (ledger: dead; the // report twins were removed in the report-liveness close-out). aria: retiredKey( - '`dashboard.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no ' + + '`dashboard.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no ' + 'dashboard renderer ever applied it, so declared ARIA attributes silently did not reach ' + 'the DOM. Delete the key. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), performance: retiredKey( - '`dashboard.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit ' + + '`dashboard.performance` was removed in @objectstack/spec 17.0.0 (audit ' + 'close-out) — no renderer or runtime read it; dashboard performance tuning was never ' + 'implemented. Delete the key. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index a0736b8268..353b34d4e8 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -43,7 +43,7 @@ import { AggregationFunction, DateGranularity } from '../data/query.zod'; * closed surface (the nested-hole shape 批 13 found on `page.components[]`). */ const DATASET_HISTORY = - 'Until #4001 批 14 closed this shape these were dropped silently — the dataset still ' + 'Until this shape was closed these were dropped silently — the dataset still ' + 'compiled and every report and widget bound to it still rendered, computing something ' + 'other than what was declared.'; @@ -248,7 +248,7 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({ export const DatasetSchema = lazySchema(() => strictObject({ surface: 'this dataset', history: - 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the item still registered, minus whatever the key was meant to configure.', // #5013 — `measures` and `filter` are both DECLARED here (the aggregatable // values and the intrinsic scope filter), so neither entry could ever run: an // alias is consulted only from the `unrecognized_keys` path, and a declared diff --git a/packages/spec/src/ui/expression-bindable-text-keys.zod.ts b/packages/spec/src/ui/expression-bindable-text-keys.zod.ts index f0e73f7c91..88efca2520 100644 --- a/packages/spec/src/ui/expression-bindable-text-keys.zod.ts +++ b/packages/spec/src/ui/expression-bindable-text-keys.zod.ts @@ -88,7 +88,7 @@ export type ExpressionBindableTextKey = export const ExpressionBindableTextKeySchema = z .enum(EXPRESSION_BINDABLE_TEXT_KEYS) .describe( - 'One of the closed set of expression-bindable text keys — the top-level node keys a SchemaRenderer evaluation memo may evaluate (objectui#4795 Direction 1; carriage per component type is EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT).' + 'One of the closed set of expression-bindable text keys — the top-level node keys a SchemaRenderer evaluation memo may evaluate (Direction 1; carriage per component type is EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT).' ); export function isExpressionBindableTextKey( diff --git a/packages/spec/src/ui/i18n.test.ts b/packages/spec/src/ui/i18n.test.ts index 8ad2835fca..b9467f69cf 100644 --- a/packages/spec/src/ui/i18n.test.ts +++ b/packages/spec/src/ui/i18n.test.ts @@ -334,8 +334,10 @@ describe('#4001 批 16 — AriaPropsSchema is closed (the door is real)', () => // of pointing at a declared key that means something else. const msg = reject(AriaPropsSchema as never, { live: 'polite' }); expect(msg).toContain('objectui'); - expect(msg).toContain('#5058'); + expect(msg).toContain('not yet decided'); expect(msg, 'must not suggest a rename for a key with no correct target').not.toContain('`live` →'); + expect(msg, 'the prescription names the gap in words, never a tracker id') + .not.toMatch(/#\d{3,5}/); }); it('never offers `live` as a suggestion for a NEIGHBOURING typo either', () => { @@ -350,8 +352,10 @@ describe('#4001 批 16 — AriaPropsSchema is closed (the door is real)', () => it('`aria-labelledby` names the gap instead of renaming to a different concept', () => { for (const key of ['ariaLabelledBy', 'labelledBy']) { const msg = reject(AriaPropsSchema as never, { [key]: 'other-element' }); - expect(msg).toContain('#5058'); + expect(msg).toContain('open gap'); expect(msg, 'labelledby references an id; ariaLabel is a literal string').not.toContain(`\`${key}\` → \`ariaLabel\``); + expect(msg, 'the prescription names the gap in words, never a tracker id') + .not.toMatch(/#\d{3,5}/); } }); diff --git a/packages/spec/src/ui/i18n.zod.ts b/packages/spec/src/ui/i18n.zod.ts index 66cd1a7dbe..2f1ed7567f 100644 --- a/packages/spec/src/ui/i18n.zod.ts +++ b/packages/spec/src/ui/i18n.zod.ts @@ -260,7 +260,7 @@ export type I18nLabel = z.input; // (it reaches `arialabel`, `ariaLabell`, `ariadescribedby`, `aria-label`, // `roles`; it does NOT reach any of the four aliases here). const ARIA_HISTORY = - 'Until #4001 closed this shape an unknown key was dropped silently — the component still ' + 'Until this shape was closed an unknown key was dropped silently — the component still ' + 'rendered, with no accessible name and nothing to say the one you wrote had been discarded.'; /** @@ -308,16 +308,16 @@ export const AriaPropsSchema = lazySchema(() => strictObject({ + '`AriaPropsSchema.extend({ live })` and read by `ListView` alone — this shared shape is ' + 'carried by ~30 renderers and only one of them applies `aria-live`, so declaring it here ' + 'would advertise a capability the other 29 do not deliver. On an objectui list view the key ' - + 'is valid as-is; anywhere else, drop it. Promoting it into the protocol is #5058.', + + 'is valid as-is; anywhere else, drop it. Promoting it into the protocol is not yet decided.', // `aria-labelledby` REFERENCES another element's id; `ariaLabel` is a literal // string. Renaming between them would be a wrong prescription, so this names // the gap instead of pretending there is a target. ariaLabelledBy: '`aria-labelledby` has no counterpart in this protocol — it references another ' + 'element\'s id, which is not the same thing as `ariaLabel` (a literal accessible name), so ' + 'there is nothing to rename it to. Use `ariaLabel` only if a literal string is what you meant. ' - + 'Declaring the referencing form is #5058.', + + 'Declaring the referencing form remains an open gap in this protocol.', labelledBy: '`aria-labelledby` has no counterpart in this protocol — see `ariaLabelledBy`. ' - + 'Use `ariaLabel` only if a literal accessible name is what you meant; #5058 tracks the gap.', + + 'Use `ariaLabel` only if a literal accessible name is what you meant; the referencing form is an open gap.', }, }, { /** diff --git a/packages/spec/src/ui/notification.test.ts b/packages/spec/src/ui/notification.test.ts index 240ed80e2b..c1ec6f7fd8 100644 --- a/packages/spec/src/ui/notification.test.ts +++ b/packages/spec/src/ui/notification.test.ts @@ -176,6 +176,6 @@ describe('[#5781] ./ui notification tombstone — corrected evidence, no false F it('writes down the methodology so the fourth miss is not owed to it', () => { expect(tombstone).toMatch(/RESOLVED SYMBOL GRAPH/); expect(tombstone).toMatch(/barrel package/); - expect(tombstone).toMatch(/#4667 \/ #4709/); + expect(tombstone).toMatch(/ \//); }); }); diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 911a8eece0..68996732c9 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -470,7 +470,7 @@ describe('[#11027] PageComponentSchema — retired `responsive`', () => { // fully-qualified key, the version, the issue, and the fix. expect(message).toMatch(/page\.components\[\]\.responsive/); expect(message).toMatch(/removed in @objectstack\/spec 17/); - expect(message).toMatch(/#11027/); + expect(message, 'the key, the version and the fix carry it — not a tracker id').not.toMatch(/#\d{3,5}/); expect(message).toMatch(/Delete the key/); // It must point at the per-breakpoint channel that IS applied, or an // author who really wants breakpoints reads this as "responsive is gone". diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 8f4e9da3c6..acf3f3babd 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -36,7 +36,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; * layout rather than in their spelling. */ const PAGE_HISTORY = - 'Until #4001 closed this shape these were dropped silently — the page still rendered, ' + 'Until this shape was closed these were dropped silently — the page still rendered, ' + 'without whatever the key was meant to configure.'; export const PageRegionSchema = lazySchema(() => strictObject({ @@ -253,7 +253,7 @@ export const PageComponentSchema = lazySchema(() => strictObject({ // carrier and are removed with the key (`responsive.zod.ts`, the // PerformanceConfigSchema precedent). responsive: retiredKey( - '`page.components[].responsive` was removed in @objectstack/spec 17 (#11027, ADR-0049 D2) — ' + + '`page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — ' + 'no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) ' + 'parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS ' + 'applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled ' + @@ -495,11 +495,11 @@ export const PageSchema = lazySchema(() => strictObject({ // block with no renderer; deleting them left an author writing something // that had never worked and, until now, was not told so. recordReview: - '`recordReview` was removed with the `record_review` page type (framework#2265) — it had ' + '`recordReview` was removed with the `record_review` page type — it had ' + 'no renderer, so the page validated and then failed at runtime. Use `type: \'record\'` with ' + '`regions`, or `kind: \'slotted\'` to override individual slots.', blankLayout: - '`blankLayout` was removed with the `blank` page type (framework#2265) — it had no renderer. ' + '`blankLayout` was removed with the `blank` page type — it had no renderer. ' + 'A free-form page is `kind: \'html\'` with `source` (ADR-0080).', // `route` is the key an author reaches for first, and it has never existed. // The page's `name` IS its routing identity — this file's own naming diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 0ee6b2b94a..4cd83cc805 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -52,9 +52,9 @@ export const ReportChartSchema = lazySchema(() => ChartConfigSchema.extend({ export const ReportSortSchema = lazySchema(() => strictObject({ surface: 'this report order key', history: - 'Until #4001 批 14 closed this shape these were dropped silently — the key still parsed, ' + 'Until this shape was closed these were dropped silently — the key still parsed, ' + '`direction` fell back to `asc`, and the report rendered in an order nobody asked for ' - + '(the `SortNodeSchema` failure of #4721, one layer up).', + + '(the same `SortNodeSchema` failure, one layer up).', // Anchored on the two named sibling ordering contracts, not on edit distance. // A report's `order` is the THIRD spelling of "sort" an author meets, and the // other two are both correct where they live: @@ -166,7 +166,7 @@ export function reportSelectionOrder( export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObject({ surface: 'this joined report block', history: - 'Until #4001 批 14 closed this shape these were dropped silently — the block still rendered, ' + 'Until this shape was closed these were dropped silently — the block still rendered, ' + 'minus whatever the key was meant to select, scope or order.', // A block is a sub-report, so the vocabulary an author brings is the CONTAINER's // (`ReportSchema`, thirty lines below) — and the two shapes deliberately differ: @@ -248,7 +248,7 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObje export const ReportSchema = lazySchema(() => strictObject({ surface: 'this report', history: - 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', + 'Until this shape was closed these were dropped silently — the item still registered, minus whatever the key was meant to configure.', // Kept deliberately parallel to `JoinedReportBlockSchema` above: a block is a // sub-report, so an author who learns one vocabulary must not be corrected // differently on the other. The scope-filter entries are that table's, diff --git a/packages/spec/src/ui/responsive.test.ts b/packages/spec/src/ui/responsive.test.ts index 4576e95a51..fb7d9cc639 100644 --- a/packages/spec/src/ui/responsive.test.ts +++ b/packages/spec/src/ui/responsive.test.ts @@ -59,7 +59,7 @@ describe('unknown keys are rejected, not stripped (#4001 batch 13)', () => { for (const key of ['columns', 'hiddenOn', 'order']) { const message = unknownKeyIssue(ResponsiveStylesSchema, { [key]: {} })!.message; expect(message, `\`${key}\` should name the retirement`).toContain('retired `responsive` layout block'); - expect(message).toContain('#11027'); + expect(message, 'the retirement is named in words, never by a tracker id').not.toMatch(/#\d{3,5}/); expect(message).toContain('per-breakpoint CSS'); } }); diff --git a/packages/spec/src/ui/responsive.zod.ts b/packages/spec/src/ui/responsive.zod.ts index 1dc3df6386..68f229a662 100644 --- a/packages/spec/src/ui/responsive.zod.ts +++ b/packages/spec/src/ui/responsive.zod.ts @@ -100,7 +100,7 @@ export const ResponsiveStylesSchema = lazySchema(() => strictObject( { surface: 'this per-breakpoint style map', history: - 'Until #4001 批 13 a bucket this shape does not declare was dropped silently — ' + + 'Until this shape was closed, a bucket this shape does not declare was dropped silently — ' + 'and since the whole block is optional, a node whose every style was written ' + 'under the wrong vocabulary rendered completely unstyled and parsed clean.', aliases: { @@ -124,15 +124,15 @@ export const ResponsiveStylesSchema = lazySchema(() => strictObject( // here is usually migrating off that block, so each entry carries the // CSS translation that IS applied. columns: - '`columns` was a key of the retired `responsive` layout block (#11027 — no renderer ' + + '`columns` was a key of the retired `responsive` layout block (no renderer ' + 'ever applied it). This block holds per-breakpoint CSS. Express a column span as CSS ' + "on the bucket where it should apply, e.g. `medium: { gridColumn: 'span 6' }`.", hiddenOn: - '`hiddenOn` was a key of the retired `responsive` layout block (#11027 — no renderer ' + + '`hiddenOn` was a key of the retired `responsive` layout block (no renderer ' + 'ever applied it). This block holds per-breakpoint CSS. Hide a component per ' + "breakpoint with CSS, e.g. `xsmall: { display: 'none' }`.", order: - '`order` was a key of the retired `responsive` layout block (#11027 — no renderer ' + + '`order` was a key of the retired `responsive` layout block (no renderer ' + 'ever applied it). This block holds per-breakpoint CSS. Set display order per ' + "breakpoint with CSS, e.g. `small: { order: '1' }`.", }, diff --git a/packages/spec/src/ui/sharing.zod.ts b/packages/spec/src/ui/sharing.zod.ts index 8a2be869ba..e0997be504 100644 --- a/packages/spec/src/ui/sharing.zod.ts +++ b/packages/spec/src/ui/sharing.zod.ts @@ -36,7 +36,7 @@ import { strictObject } from '../shared/strict-object'; * Shared history for the authorable shapes in this file (#4001). */ const SHARING_HISTORY = - 'Until #4001 closed this shape these were dropped silently — the form still published, ' + 'Until this shape was closed these were dropped silently — the form still published, ' + 'without whatever the key was meant to open up, gate or expire.'; /** diff --git a/packages/spec/src/ui/view-form-features-root.test.ts b/packages/spec/src/ui/view-form-features-root.test.ts index 600acc8828..7c3af7770e 100644 --- a/packages/spec/src/ui/view-form-features-root.test.ts +++ b/packages/spec/src/ui/view-form-features-root.test.ts @@ -65,7 +65,7 @@ function expectFeaturesRefusal( // The identity an author (and an AI author's retry loop) acts on: the root, // the surface, the fail-open reason, the ruling, and the prescription. expect(issue!.message).toContain('Form-view predicates may not name the `features.*` scope root'); - expect(issue!.message).toContain('ruled 2026-08-27 on objectui#6262'); + expect(issue!.message).toContain('ruled 2026-08-27 on'); expect(issue!.message).toContain('UNBOUND'); expect(issue!.message).toContain('fails OPEN'); expect(issue!.message).toContain('`record.*`'); diff --git a/packages/spec/src/ui/view-submit-redirect-url.test.ts b/packages/spec/src/ui/view-submit-redirect-url.test.ts index 92fe22cff7..d3397c8d6d 100644 --- a/packages/spec/src/ui/view-submit-redirect-url.test.ts +++ b/packages/spec/src/ui/view-submit-redirect-url.test.ts @@ -105,7 +105,7 @@ describe('#7496 bullet 1 — relative paths only', () => { const msg = reject(redirectTo(url)); expect(msg, 'names the rule').toContain('RELATIVE path only'); expect(msg, 'names the reason the rule exists').toContain('open'); - expect(msg, 'cites the ruling so the refusal is traceable').toContain('ruled 2026-08-11 on #7496'); + expect(msg, 'cites the ruling so the refusal is traceable').toContain('ruled 2026-08-11 on'); expect(msg, 'prescribes the fix, not just the refusal').toContain('/thanks'); expect(msg, 'points the deliberate external link at the surface that IS declared for it') .toContain('navigation item'); @@ -119,7 +119,7 @@ describe('#7496 bullet 1 — relative paths only', () => { const msg = reject(redirectTo('//evil.example/thanks')); expect(msg).toContain('protocol-relative'); expect(msg).toContain('ANOTHER ORIGIN'); - expect(msg).toContain('ruled 2026-08-11 on #7496'); + expect(msg).toContain('ruled 2026-08-11 on'); }); it.each([ @@ -132,7 +132,7 @@ describe('#7496 bullet 1 — relative paths only', () => { expect(msg, 'says WHY a backslash is an origin problem, not a style problem') .toContain('normalise'); expect(msg, 'gives the escape for a legitimate backslash').toContain('%5C'); - expect(msg).toContain('ruled 2026-08-11 on #7496'); + expect(msg).toContain('ruled 2026-08-11 on'); }); it.each([ @@ -148,7 +148,7 @@ describe('#7496 bullet 1 — relative paths only', () => { expect(msg).toContain('whitespace or control characters'); expect(msg, 'says why stripping is the hazard').toContain('strip'); expect(msg, 'gives the escape for a legitimate space').toContain('%20'); - expect(msg).toContain('ruled 2026-08-11 on #7496'); + expect(msg).toContain('ruled 2026-08-11 on'); }); it('refuses a document-relative path and explains what it resolves against', () => { @@ -158,7 +158,7 @@ describe('#7496 bullet 1 — relative paths only', () => { const msg = reject(redirectTo('thanks')); expect(msg).toContain('must start with `/`'); expect(msg).toContain('document-relative'); - expect(msg).toContain('ruled 2026-08-11 on #7496'); + expect(msg).toContain('ruled 2026-08-11 on'); }); it.each([ @@ -174,7 +174,7 @@ describe('#7496 bullet 1 — relative paths only', () => { // leading-slash message would be a confusing thing to read for it. const msg = reject(redirectTo('')); expect(msg).toContain('needs a `url`'); - expect(msg).toContain('ruled 2026-08-11 on #7496'); + expect(msg).toContain('ruled 2026-08-11 on'); }); }); @@ -207,7 +207,7 @@ describe('#7496 bullet 2 — `{{record.}}` and nothing else', () => { const msg = reject(redirectTo(url)); expect(msg, 'names the vocabulary').toContain('ONLY declared record fields'); expect(msg, 'gives the spelling verbatim').toContain('{{record.field_name}}'); - expect(msg).toContain('ruled 2026-08-11 on #7496'); + expect(msg).toContain('ruled 2026-08-11 on'); }); it('the refusal states the URL-escaping half of the ruling', () => { diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 28752515b8..d9c1ed9f8c 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -1902,7 +1902,7 @@ describe('ListMapConfigSchema (#9340 — the eighth visualization block)', () => expect(m.startsWith('Unrecognized key(s) on this map configuration: `titleFeld`.')).toBe(true); expect(m).toContain('`titleField`'); expect(m.endsWith( - 'Until #4001 closed these shapes an unknown key was dropped silently — the view still ' + 'Until these shapes were closed an unknown key was dropped silently — the view still ' + 'rendered, without whatever the key was meant to configure.', )).toBe(true); }); @@ -2363,7 +2363,7 @@ describe('FormViewSchema — retired defaultSort (#3896 close-out)', () => { }); } catch (e) { message = String((e as Error).message); } expect(message).toMatch(/list view/); - expect(message).toMatch(/#3896/); + expect(message).toMatch(/audit close-out/); }); it('a form without it still parses', () => { expect(() => FormViewSchema.parse({ type: 'simple', sections: [{ fields: ['name'] }] })).not.toThrow(); @@ -3056,7 +3056,7 @@ describe('ListViewSchema — retired responsive/performance (#3896 close-out)', try { ListViewSchema.parse({ type: 'grid', columns: ['name'], performance: { lazyLoad: true } }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/#3896/); + expect(message).toMatch(/audit close-out/); }); }); @@ -3088,7 +3088,7 @@ describe('ListViewSchema — retired striped/bordered/virtualScroll (#7176 pass- try { ListViewSchema.parse({ type: 'grid', columns: ['name'], [key]: true }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/#7176/); + expect(message).toMatch(new RegExp(`view\\.${key}\` was removed`)); expect(message).toMatch(/Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand\./); } }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 1153c4924b..80dc2ea8aa 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -32,7 +32,7 @@ import { lazySchema } from '../shared/lazy-schema'; * kept the posture those three were rescued from. */ const VIEW_HISTORY = - 'Until #4001 closed these shapes an unknown key was dropped silently — the view still ' + 'Until these shapes were closed an unknown key was dropped silently — the view still ' + 'rendered, without whatever the key was meant to configure.'; export { HttpMethodSubsetSchema, HttpRequestSchema }; @@ -1308,7 +1308,7 @@ export const NavigationConfigSchema = lazySchema(() => strictObject({ * client width. Explicit buckets are a coarse, viewport-independent override. */ size: z.enum(['auto', 'sm', 'md', 'lg', 'xl', 'full']).default('auto') - .describe("[#2578] Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it."), + .describe("Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it."), /** * @deprecated [#2578 → `size`] A pixel/percent width cannot be authored blind: @@ -1561,7 +1561,7 @@ export const ListViewSchema = lazySchema(() => strictObject({ bulkActionDefs: z.array(BulkActionDefSchema).optional().describe( 'Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a ' + "mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for " - + "an `operation: 'custom'` + `execution: 'aggregate'` entry (objectui#3139) that dispatches the " + + "an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the " + 'action it NAMES once for the whole selection — the renderer injects `params._selectedIds: ' + 'string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate ' + 'artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a ' @@ -1569,8 +1569,8 @@ export const ListViewSchema = lazySchema(() => strictObject({ + 're-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` ' + "on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` " + 'instead — the bare-string form, promoted with the action\'s own label, params and `visible`; a ' - + "'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time " - + '(#4457). Toolbar url/api actions can also interpolate the current selection via ' + + "'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time" + + '. Toolbar url/api actions can also interpolate the current selection via ' + '`${ctx.selection.ids}` / `${ctx.selection.count}`.', ), @@ -1657,12 +1657,12 @@ export const ListViewSchema = lazySchema(() => strictObject({ // and inert — no renderer in either repo read them (re-grepped 2026-07-16, // objectui@fb35e48; ledger: dead). responsive: retiredKey( - '`view.responsive` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' + + '`view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + 'no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), performance: retiredKey( - '`view.performance` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' + + '`view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + 'no renderer or runtime read it; list-view performance tuning was never implemented. ' + 'Delete the key. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', @@ -1676,19 +1676,19 @@ export const ListViewSchema = lazySchema(() => strictObject({ // of these as real behavior, that is an implementation card filed first, and // the key stays retired pending it. striped: retiredKey( - '`view.striped` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — ' + + '`view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — ' + 'every measured reader only copied it forward and no renderer ever applied it, so authoring ' + 'it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), bordered: retiredKey( - '`view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — ' + + '`view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — ' + 'every measured reader only copied it forward and no renderer ever applied it (the grid frame ' + "is the renderer's own constant, not authorable). Delete the key. " + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), virtualScroll: retiredKey( - '`view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — ' + + '`view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — ' + 'every measured reader only copied it forward and no grid ever virtualized off it; authoring ' + 'it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', @@ -2003,7 +2003,7 @@ const FormFieldBaseSchema = lazySchema(() => { * this one is ENFORCED: see {@link checkFormViewPredicateFeaturesRoot} for * the ruling and the scanner. */ - visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here since objectui#6010 — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'`"), + visibleWhen: ExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'`"), /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'), @@ -2165,7 +2165,7 @@ export const FormSectionSchema = lazySchema(() => strictObject({ * refused at parse (ruled 2026-08-27, objectui#6262; see * {@link checkFormViewPredicateFeaturesRoot}). */ - visibleWhen: ExpressionInputSchema.optional().describe('Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at section level — it is unbound here and the predicate would fault open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27, objectui#6262): unbound on the standalone form routes, where the predicate would fault open.'), + visibleWhen: ExpressionInputSchema.optional().describe('Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. No `current_user` at section level — it is unbound here and the predicate would fault open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open.'), /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse.'), columns: z.union([ @@ -2673,7 +2673,7 @@ export const FormViewSchema = lazySchema(() => strictObject({ // `defaultSort` REMOVED (#3896 audit close-out): no reader in either repo — // related lists sort by their OWN list view's sort, never by this. defaultSort: retiredKey( - '`form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — ' + + '`form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + 'nothing read it: a related list inside a form sorts by its own list view\'s `sort`. ' + 'Delete the key and set the sort on the related list view instead. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', @@ -2738,7 +2738,7 @@ export const FormViewSchema = lazySchema(() => strictObject({ if (refusal) ctx.addIssue({ code: 'custom', message: refusal }); }) .describe( - 'Where the browser goes after a successful submit. Ruled 2026-08-11 (#7496): ' + 'Where the browser goes after a successful submit. Ruled 2026-08-11: ' + '(1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative ' + 'URLs are refused, which is what closes the open-redirect face; ' + '(2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, ' @@ -2772,7 +2772,7 @@ export const FormViewSchema = lazySchema(() => strictObject({ // therefore rides HERE, where the reference table does have a row (#7496). ]).optional().describe( "Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates " - + 'only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11, #7496).', + + 'only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11).', ), /** @@ -2789,7 +2789,7 @@ export const FormViewSchema = lazySchema(() => strictObject({ cancel: FormButtonConfigSchema.optional().describe('Cancel button'), reset: FormButtonConfigSchema.optional().describe('Reset button'), }).strict().optional() - .describe('Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm (framework#1894 / #2998).'), + .describe('Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm.'), /** * Initial field values for create-mode forms, keyed by field machine name — @@ -2799,14 +2799,14 @@ export const FormViewSchema = lazySchema(() => strictObject({ * still wins. */ defaults: z.record(z.string(), z.unknown()).optional() - .describe('Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998).'), + .describe('Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;).'), // `aria` REMOVED from the FORM view (#3896 audit close-out): no form // renderer applied it (plugin-form/SchemaForm never read schema.aria) — an // ACCESSIBILITY claim that is merely accepted is false compliance, the // requiresConfirmation shape. aria: retiredKey( - '`form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form ' + + '`form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form ' + 'renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. ' + 'Delete the key. The form renderer emits its own semantic markup; report gaps as ' + 'renderer issues rather than per-view attribute overrides. ' + @@ -3225,7 +3225,7 @@ const ViewColumnStateSchema = z.object({ widths: z.record(z.string(), z.number()).optional() .describe('Column widths in pixels, keyed by field name (runtime-only per-user state — written by the console grid, never authored).'), }).describe( - 'Runtime-only personalization overlay key (#9933): the per-user column layout (order/widths) the console grid ' + 'Runtime-only personalization overlay key: the per-user column layout (order/widths) the console grid ' + 'persists through the `view` metadata API. NOT authorable — authoring doors reject it by name; do not write it in metadata source.', ); @@ -3303,7 +3303,7 @@ function viewItemWireFields() { // key arrives at THIS member's top level; declaring it validates the // shape where `.strip()` used to let it ride through unchecked. columnState: ViewColumnStateSchema.optional() - .describe('Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored). #9933'), + .describe('Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored)'), }; } @@ -3500,20 +3500,20 @@ function flattenedViewOverlayFields() { object: z .string({ error: (issue) => (issue.input === undefined ? INLINE_VIEW_OBJECT_REQUIRED : undefined) }) .describe( - 'Bound object name — REQUIRED on an inline view config (#7741): the object-bound read paths ' + 'Bound object name — REQUIRED on an inline view config: the object-bound read paths ' + '(`GET /meta/view?object=`, the view switcher) match on `object` + `viewKind`, so an unbound ' - + 'row can never be served. Inherited from the shadowed entry on personalization PUTs (#2555).', + + 'row can never be served. Inherited from the shadowed entry on personalization PUTs.', ), viewKind: z .enum(ViewKindSchema.options, { error: (issue) => (issue.input === undefined ? INLINE_VIEW_KIND_REQUIRED : undefined), }) .describe( - 'View family — REQUIRED on an inline view config (#7741): half of the `object` + `viewKind` ' + 'View family — REQUIRED on an inline view config: half of the `object` + `viewKind` ' + 'pair the object-bound read paths match on. Inherited from the shadowed entry on ' - + 'personalization PUTs (#2555).', + + 'personalization PUTs.', ), - label: I18nLabelSchema.optional().describe('Display label (inherited from the shadowed entry — #2555).'), + label: I18nLabelSchema.optional().describe('Display label (inherited from the shadowed entry —).'), // [#9933] Runtime-only overlay key — declared HERE (the overlay-validation // face) and on `viewItemWireFields()`, never on an authoring shape. This is // what lets a `columnState`-only personalization patch through the diff --git a/scripts/check-doc-authoring.mjs b/scripts/check-doc-authoring.mjs index aab5c8a71f..2fe37466b7 100644 --- a/scripts/check-doc-authoring.mjs +++ b/scripts/check-doc-authoring.mjs @@ -371,23 +371,105 @@ const INTERNAL_ID = new RegExp(INTERNAL_ID_SOURCE, 'g'); // matcher would have under-reported by exactly the shape it was written to // catch. // -// ## What is deliberately NOT in scope, and why that is not a loophole +// ## The three ADJACENT populations, folded in by the 2026-08-26 triage // -// Three ADJACENT populations under the same root carry ids in text a customer -// also sees, measured the day this rule landed: the `strictObject` unknown-key -// error-map options (`guidance` / `history` / `aliases` / `retiredForms`, 181 -// literals), the `retiredKey()` tombstone prescriptions (176), and `.describe()` -// docs prose (182). Each is larger than this rule's whole population, each has -// its own pinning tests, and — for the tombstones — AGENTS.md positively -// requires the prescription to carry a durable reference. Folding them in here -// would be a corpus-wide convention change riding on a card that adjudicated -// refusal messages, which is the rider this repo files issues instead of -// making. They are filed, and this comment is the pointer for whoever gets that -// ruling: widening this rule means widening MESSAGE_POSITIONS below, not adding -// an exemption. There is no exemption list here, by design, exactly as in -// Rule 2. +// This rule shipped holding `message:` alone, and named three neighbouring +// populations as deliberately out of scope pending a ruling: the `strictObject` +// unknown-key error-map options (`guidance` / `guidanceSets` / `history` / +// `aliases` / `retiredForms` / `surface`), the `retiredKey()` tombstone +// prescriptions, and `.describe()` docs prose. That ruling arrived as +// ruling-INHERITANCE rather than a new decision — the founding rationale is +// "the ban follows the audience, not the file type", and re-checking it against +// each bucket answers the question without a new maintainer call: +// +// - `strictObject` guidance and tombstone prescriptions are the SAME audience +// at the SAME moment as a refusal message. The `guidance` map is consulted +// on `unrecognized_keys` and printed verbatim at the refusing author; a +// tombstone prescription IS the parse error (`retiredKey` builds +// `z.never({ error: () => guidance })`). Rule 2's argument transfers with +// nothing changed. +// - `.describe()` prose projects into `content/docs/references/**` and the +// generated skill artifacts. A customer reading the docs site cannot +// resolve an internal tracker id either, and the published-catalog slice of +// this same population was already taken by Rule 2. +// +// **ADR ids and migration commands STAY.** AGENTS.md positively requires a +// tombstone prescription to carry a durable reference — "the FROM → TO mapping, +// the ADR the removal rests on, or the migration command" — and an ADR id is +// customer-resolvable in a way `#NNNN` is not. The issue id sitting BESIDE an +// ADR id is exactly the strippable half; the ADR id is what makes stripping it +// safe. A tombstone whose only reference is the issue id is a decision, not a +// mechanical edit, and is escalated rather than stripped silently. +// +// There is still NO exemption mechanism, in either direction: widening this +// rule meant widening the RECOGNISED POSITIONS below, exactly as this comment +// used to prescribe. +// +// ## Why the positions alone are not enough: the hoisted-const spelling +// +// A position-only matcher reads `guidance: { where: '…' }` and stops at the +// first `VariableDeclaration` it climbs into. That is most of this population +// walking free: the guidance maps are overwhelmingly HOISTED — declared once as +// `const TOOL_RETIRED_KEY_GUIDANCE = {…}` / `const NOTIFY_KEY_GUIDANCE = {…}` +// and referenced as `guidance: NOTIFY_KEY_GUIDANCE` — and so are whole refusal +// messages (`message: CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED`). That last +// shape is not a hypothetical about the new buckets: it was hiding FOUR ids +// from the `message:` rule itself, which had reported this population clean +// since the day it landed. A rule that reads only the literal's own position is +// blind to every sink whose text was given a name. +// +// So {@link collectTextSinkConsts} runs a per-file pass first: any module-local +// const whose contents flow into a recognised sink is itself a sink, to a fixed +// point (a const referenced by a const referenced by a `guidance:`). It is +// name-based within one module rather than a scope analysis — deliberately, and +// it errs toward INCLUSION, which is the safe direction for a rule whose +// failure mode is silence. const SPEC_SOURCE_ROOT = 'packages/spec/src'; +/** + * `StrictObjectOptions` keys whose values are printed at the refusing author. + * + * Every one of these lands in the unknown-key error message built by + * `strictObjectError` (packages/spec/src/shared/strict-object.ts): `surface` + * names the surface in the opening sentence, `history` is the "what used to + * happen silently" clause, and `aliases` / `guidance` / `guidanceSets` / + * `retiredForms` are the per-key prescriptions appended as bullets. + * + * `extraKeys` is deliberately absent: it carries KEY NAMES for the "did you + * mean" fallback, not prose, so including it would report identifiers as text. + */ +const STRICT_OPTION_KEYS = new Set([ + 'surface', 'history', 'aliases', 'guidance', 'guidanceSets', 'retiredForms', +]); + +/** + * The calls that take a `StrictObjectOptions` in argument position 0. + * + * Anchored to the call rather than to the key names alone: `guidance` and + * `history` are ordinary English words, and a rule that fired on any property + * so named anywhere in the tree would report schema shapes and config records + * as refusal prose. A const annotated `StrictObjectOptions` (or named + * `*_STRICT_OPTIONS`) is the other recognised anchor — that is how the shared + * visibility/editability option sets are written. + */ +const STRICT_OBJECT_CALLS = new Set(['strictObject', 'strictObjectError']); + +/** + * Calls whose argument 0 IS the customer-facing prescription. + * + * `retiredKey(guidance)` builds `z.never({ error: () => guidance })` plus a + * `[REMOVED] …` describe, so its argument is printed at the author on parse AND + * projected into the generated docs — both audiences from one literal. + */ +const TOMBSTONE_CALLS = new Set(['retiredKey']); + +/** + * Wrappers that pass their argument through unchanged, so the climb continues + * rather than stopping. `Object.freeze({ … })` around a guidance table is the + * measured case; a stop here would drop the whole table. + */ +const TRANSPARENT_CALLS = new Set(['freeze']); + /** * zod validators whose trailing positional argument is a refusal message. * @@ -578,19 +660,140 @@ function collectSpecSourceFiles(root = SPEC_SOURCE_ROOT) { return files.sort(); } +/** The callee's plain name, for `f(…)` and `x.f(…)` alike. */ +function calleeName(call, ts) { + const callee = call.expression; + return ts.isPropertyAccessExpression(callee) ? callee.name.getText() : callee.getText(); +} + +/** + * Is this property assignment a `StrictObjectOptions` key, in a position where + * the value is really printed at the author? + * + * Climbs from the property to whichever encloses it first: a + * {@link STRICT_OBJECT_CALLS} call (the options are argument 0 — the shape is + * argument 1, and a shape key that happens to be named `guidance` holds a zod + * schema, not prose) or a `StrictObjectOptions`-typed / `*_STRICT_OPTIONS` + * const, which is how the shared visibility and editability option sets are + * written. + */ +function inStrictOptions(prop, ts) { + let cur = prop; + for (let hops = 0; cur.parent && hops < 30; hops++) { + const p = cur.parent; + if (ts.isCallExpression(p)) { + return STRICT_OBJECT_CALLS.has(calleeName(p, ts)) && p.arguments.indexOf(cur) === 0; + } + if (ts.isVariableDeclaration(p)) { + const nm = p.name.getText(); + return /_STRICT_OPTIONS$/.test(nm) || /\bStrictObjectOptions\b/.test(p.type ? p.type.getText() : ''); + } + if (ts.isReturnStatement(p) || ts.isArrowFunction(p) || ts.isFunctionDeclaration(p)) return false; + cur = p; + } + return false; +} + +/** Every identifier name mentioned anywhere inside an expression. */ +function identifiersIn(node, ts, out = new Set()) { + const visit = (n) => { + if (ts.isIdentifier(n)) { out.add(n.text); return; } + if (ts.isPropertyAccessExpression(n)) { visit(n.expression); return; } + ts.forEachChild(n, visit); + }; + visit(node); + return out; +} + /** - * Does this string literal sit in a refusal-message position? + * Module-local const names whose CONTENTS reach a customer-facing sink. + * + * The blind spot this closes is argued in the Rule 3 header: the guidance maps + * and a good share of the refusal messages are hoisted into a named const and + * referenced from the sink, so a matcher that only reads a literal's own + * position never reaches them. Seeded from every recognised sink and from the + * two naming conventions, then closed to a FIXED POINT so a const referenced by + * a const referenced by a `guidance:` is covered too. + * + * Name-based within one module rather than a scope analysis, deliberately: a + * shadowed local of the same name would be a false positive, which costs an + * author one rewritten sentence, while the missing analysis costs silence — and + * silence is the failure this whole file is a monument to. + * + * @returns {Map} const name → the bucket it feeds. + */ +function collectTextSinkConsts(sf, ts) { + const sinks = new Map(); + const decls = new Map(); + const seed = (expr, bucket) => { + for (const id of identifiersIn(expr, ts)) if (!sinks.has(id)) sinks.set(id, bucket); + }; + + const visit = (n) => { + if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer) { + decls.set(n.name.text, n.initializer); + if (/_RETIRED_KEY_GUIDANCE$/.test(n.name.text)) sinks.set(n.name.text, 'tombstone'); + const ty = n.type ? n.type.getText() : ''; + if (/_STRICT_OPTIONS$/.test(n.name.text) || /\bStrictObjectOptions\b/.test(ty)) { + sinks.set(n.name.text, 'strictObject'); + } + } + if (ts.isPropertyAssignment(n)) { + const name = n.name.getText(); + if (name === 'message') seed(n.initializer, 'message'); + else if (STRICT_OPTION_KEYS.has(name) && inStrictOptions(n, ts)) seed(n.initializer, 'strictObject'); + } + if (ts.isCallExpression(n)) { + const name = calleeName(n, ts); + if (name === 'describe' && n.arguments[0]) seed(n.arguments[0], 'describe'); + if (TOMBSTONE_CALLS.has(name) && n.arguments[0]) seed(n.arguments[0], 'tombstone'); + if (POSITIONAL_MESSAGE_CALLS.has(name) && n.arguments.length > 1) { + for (const a of n.arguments.slice(1)) seed(a, 'message'); + } + } + ts.forEachChild(n, visit); + }; + ts.forEachChild(sf, visit); + + // Close over const→const references. Bounded: the deepest real chain is two + // hops, and an unbounded loop over a cyclic reference would not terminate. + for (let pass = 0; pass < 8; pass++) { + let grew = false; + for (const [name, bucket] of [...sinks]) { + const init = decls.get(name); + if (!init) continue; + for (const id of identifiersIn(init, ts)) { + if (!sinks.has(id) && decls.has(id)) { sinks.set(id, bucket); grew = true; } + } + } + if (!grew) break; + } + + // An identifier that is not a declaration in THIS file is an import or a + // parameter; its literals are not here to judge. + for (const name of [...sinks.keys()]) if (!decls.has(name)) sinks.delete(name); + return sinks; +} + +/** + * Does this string literal sit in a customer-facing text position? * * Climbs OUT through `+` concatenation, parentheses, conditionals and template - * spans before asking — the whole reason this rule is an AST walk. Returns the - * position's name (for the failure text) or `undefined`. + * spans before asking — the whole reason this rule is an AST walk — and now + * also through the object/array/`new Map([…])` structure a guidance table is + * written in, so a nested prescription is reached rather than abandoned at its + * own key. + * + * @returns {{where: string, bucket: string}|undefined} */ -function messagePosition(node, ts) { +function customerTextPosition(node, ts, sinkConsts = new Map()) { let cur = node; + let strictKey; // A bound, not a belief: refusal prose in this tree reaches ~14 concatenated // operands, and an unbounded climb would walk to the SourceFile and start - // reporting whole modules as messages. - for (let hops = 0; cur.parent && hops < 60; hops++) { + // reporting whole modules as messages. Raised from 60 with the structural + // hops a nested guidance table adds. + for (let hops = 0; cur.parent && hops < 90; hops++) { const p = cur.parent; if ( (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.PlusToken) @@ -603,44 +806,78 @@ function messagePosition(node, ts) { ) { cur = p; continue; } if (ts.isPropertyAssignment(p) && p.initializer === cur) { - return p.name.getText() === 'message' ? 'message:' : undefined; + const name = p.name.getText(); + if (name === 'message') return { where: 'message:', bucket: 'message' }; + if (!strictKey && STRICT_OPTION_KEYS.has(name) && inStrictOptions(p, ts)) strictKey = name; + cur = p; continue; } + // The structure a guidance table is written in — keep climbing. + if ( + ts.isObjectLiteralExpression(p) + || ts.isArrayLiteralExpression(p) + || ts.isSpreadAssignment(p) + || ts.isShorthandPropertyAssignment(p) + || ts.isNewExpression(p) + ) { cur = p; continue; } + if (ts.isCallExpression(p)) { - const callee = p.expression; - const name = ts.isPropertyAccessExpression(callee) ? callee.name.getText() : callee.getText(); - return POSITIONAL_MESSAGE_CALLS.has(name) && p.arguments.indexOf(cur) > 0 - ? `.${name}(…, message)` + const name = calleeName(p, ts); + if (TRANSPARENT_CALLS.has(name)) { cur = p; continue; } + const idx = p.arguments.indexOf(cur); + if (name === 'describe' && idx === 0) return { where: '.describe()', bucket: 'describe' }; + if (TOMBSTONE_CALLS.has(name) && idx === 0) return { where: `${name}()`, bucket: 'tombstone' }; + if (STRICT_OBJECT_CALLS.has(name) && strictKey && idx === 0) { + return { where: `strictObject ${strictKey}`, bucket: 'strictObject' }; + } + return POSITIONAL_MESSAGE_CALLS.has(name) && idx > 0 + ? { where: `.${name}(…, message)`, bucket: 'message' } : undefined; } - if (ts.isVariableDeclaration(p) || ts.isReturnStatement(p) || ts.isArrowFunction(p)) return undefined; + + if (ts.isVariableDeclaration(p)) { + const nm = p.name.getText(); + if (!sinkConsts.has(nm)) return undefined; + return { + where: `via ${nm}`, + bucket: strictKey ? 'strictObject' : sinkConsts.get(nm), + }; + } + if (ts.isReturnStatement(p) || ts.isArrowFunction(p)) return undefined; cur = p; } return undefined; } /** - * Refusal messages in one spec source, and how many message strings were seen - * at all. + * Customer-facing text in one spec source, and how many such strings were seen + * at all — PER BUCKET. * * The second number is not decoration. This rule's population is expected to be * EMPTY in the steady state, so "no violations" is the same output as "the * detector no longer recognises how messages are spelled" — the failure this - * whole file is a monument to. `seen` is what {@link main} asserts against, so - * a detector that has gone blind reds instead of congratulating itself. + * whole file is a monument to. {@link main} asserts against it, so a detector + * that has gone blind reds instead of congratulating itself. + * + * It is per-bucket for the same reason #4932's floor is per-ROOT and not on the + * total: one populous bucket holds a total up while another empties, and "part + * of the population was read" is precisely the verdict this rule must not + * resolve in the corpus's favour. `.describe()` alone would keep a total + * positive forever while the `guidance` matcher rotted. */ -function findMessageIdViolations(source, file, ts) { +function findCustomerTextIdViolations(source, file, ts) { const out = []; - let seen = 0; + const seen = { message: 0, strictObject: 0, tombstone: 0, describe: 0 }; const sf = parseSourceFile(file, source); + const sinkConsts = collectTextSinkConsts(sf, ts); const visit = (node) => { if ( ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node) ) { - const where = messagePosition(node, ts); - if (where) { - seen += 1; + const pos = customerTextPosition(node, ts, sinkConsts); + if (pos) { + seen[pos.bucket] += 1; const text = node.getText(sf); const ids = text.match(INTERNAL_ID); if (ids) { @@ -648,7 +885,8 @@ function findMessageIdViolations(source, file, ts) { file: posix(file), line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, ids, - where, + where: pos.where, + bucket: pos.bucket, text: text.length > 120 ? `${text.slice(0, 120)}…` : text, }); } @@ -1025,7 +1263,7 @@ function selfTest() { console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`); process.exit(1); } - console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored), the published-catalog internal-id rule (red on a planted id in prose, in a fenced comment and in the repo#NNNN spelling, green when removed; hex colours, version numbers, HTTP codes, array indices and the "#1" ordinal all pass; references/ reached, generated artifacts and the internal roots out; the `#` placeholder passes while the concrete ids it replaced stay red, with no exemption to reach for), the spec refusal-message internal-id rule (red on an id planted on a LATER line of a concatenated message — the shape a line-oriented census cannot see, proven here — and in a template chain, a positional validator message and the repo#NNNN spelling; green when removed; a `.default()` VALUE and a `.describe()` do not fire; test bodies out, and a tree with no recognised message string reports seen=0 so a blinded detector reds instead of passing) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); + console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored), the published-catalog internal-id rule (red on a planted id in prose, in a fenced comment and in the repo#NNNN spelling, green when removed; hex colours, version numbers, HTTP codes, array indices and the "#1" ordinal all pass; references/ reached, generated artifacts and the internal roots out; the `#` placeholder passes while the concrete ids it replaced stay red, with no exemption to reach for), the spec customer-facing-text internal-id rule (red on an id planted on a LATER line of a concatenated message — the shape a line-oriented census cannot see, proven here — and in a template chain, a positional validator message, the repo#NNNN spelling, a nested strictObject `guidance` prescription, a HOISTED guidance const, a HOISTED refusal message, a `retiredKey()` tombstone, `new Map` and `Object.freeze` guidance tables, and `.describe()` prose; green when removed; an ADR id on a tombstone, a `.default()` VALUE, `history`/`guidance` outside a strictObject options position and `extraKeys` key names all pass; test bodies out, and the seen floor is PER BUCKET so one matcher rotting while the others carry the total still reds) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); } /** @@ -1057,42 +1295,56 @@ function selfTestRule3(expect) { // A test body carrying an id: in the tree, out of the population. write('packages/spec/src/ui/pin.test.ts', "expect(issue.message).toContain('400 INVALID_FILTER, #5869');"); - // A TSDoc / `.describe()` id: a different population, deliberately untouched - // by this rule. If it ever fires here, the scope has silently widened. + // A clean member of each of the three folded-in buckets, so every bucket's + // `seen` floor is satisfied on the green tree and the per-bucket blindness + // assertion below has something to go blind ABOUT. write('packages/spec/src/data/doc.ts', [ "import { z } from 'zod';", - '/** Removed in protocol 17 (#4286). */', - "export const D = z.string().describe('A machine name (#4286).');", + "import { strictObject } from '../shared/strict-object';", + "import { retiredKey } from '../shared/retired-key';", + '/** TSDoc ids are a COMMENT, never a string literal — out of reach by construction. */', + "export const D = z.string().describe('A machine name.');", + 'export const T = strictObject({', + " surface: 'this doc',", + " history: 'an unknown key here was dropped silently.',", + " guidance: { where: 'not a doc key — delete it.' },", + '}, {', + " cursor: retiredKey('`cursor` was removed in protocol 17 (ADR-0049). Use `after`.'),", + '});', ].join('\n')); const target = write('packages/spec/src/ui/action.zod.ts', CLEAN); const scan = () => { let out = []; - let seen = 0; + const seen = { message: 0, strictObject: 0, tombstone: 0, describe: 0 }; for (const f of collectSpecSourceFiles()) { - const r = findMessageIdViolations(readFileSync(f, 'utf8'), f, ts); + const r = findCustomerTextIdViolations(readFileSync(f, 'utf8'), f, ts); out = out.concat(r.violations); - seen += r.seen; + for (const b of Object.keys(seen)) seen[b] += r.seen[b]; } return { violations: out, seen }; }; process.chdir(dir); - // GREEN, and the detector is demonstrably NOT blind: it saw the clean - // message. Reporting both numbers is the point — "0 violations" and "0 - // messages found" are the same line to a reader who only checks the first. + // GREEN, and the detector is demonstrably NOT blind in ANY bucket. Reporting + // the counts is the point — "0 violations" and "0 strings found" are the same + // line to a reader who only checks the first, and a per-bucket floor is the + // only shape that catches ONE matcher rotting while the others carry the total. let r = scan(); expect('a clean spec tree is green', r.violations.length, 0); - expect('the detector actually recognised a message string', r.seen >= 1, true); - - // Scope, both directions, before any red: a test body is out and a - // `.describe()` is out — each is satisfied by a wrong scope in the other - // direction if asserted alone. + expect('the detector recognised a message string', r.seen.message >= 1, true); + expect('the detector recognised a strictObject option string', r.seen.strictObject >= 1, true); + expect('the detector recognised a tombstone prescription', r.seen.tombstone >= 1, true); + expect('the detector recognised a `.describe()` string', r.seen.describe >= 1, true); + + // Scope: a test body is out, an ordinary source is in. Asserted as a pair + // because either half alone is satisfied by a wrong scope in the other + // direction. const scanned = collectSpecSourceFiles(); expect('test bodies are not scanned', scanned.includes('packages/spec/src/ui/pin.test.ts'), false); expect('ordinary sources are scanned', scanned.includes('packages/spec/src/data/doc.ts'), true); - expect('a `.describe()` id is NOT this rule\'s population', + expect('an ADR id on a tombstone does NOT fire — it is the durable reference AGENTS.md requires', r.violations.some((v) => v.file === 'packages/spec/src/data/doc.ts'), false); // RED #1 — the founding shape: `message:` and the id on DIFFERENT lines of a @@ -1157,29 +1409,200 @@ function selfTestRule3(expect) { r = scan(); expect('the `repo#NNNN` spelling is RED here too', r.violations.length, 1); - // Precision — a validator's VALUE argument is not prose. `.min(3, …)` takes - // a message; `.default('#4286')` does not, and an open "any string after - // position 0" rule would report it. + // ── The three buckets folded in by the 2026-08-26 triage ──────────────── + // + // Same discipline as everything above: each is a PAIR, and each is written + // in the spelling the tree really uses — inline for the nested `guidance` + // map, hoisted-const for the rest, because hoisting is what a + // position-only matcher is blind to. + + // RED #5 — an inline `guidance` prescription, nested one key deep inside + // the options object. The literal's own position is a property named + // `where`; only the climb reaches `guidance`. + writeFileSync(target, [ + "import { z } from 'zod';", + "import { strictObject } from '../shared/strict-object';", + 'export const S = strictObject({', + " surface: 'this action',", + " history: 'an unknown key here was dropped silently.',", + ' guidance: {', + " where: '`where` has never been an action key (#4001). Delete it.',", + ' },', + "}, { name: z.string() });", + ].join('\n')); + r = scan(); + expect('an id in a nested strictObject `guidance` prescription is RED', r.violations.length, 1); + expect('the guidance red names the position', r.violations[0]?.where, 'strictObject guidance'); + expect('the guidance red names the bucket', r.violations[0]?.bucket, 'strictObject'); + + // RED #6 — the same prescription HOISTED into a named const, which is how + // this tree overwhelmingly writes it. Without the sink-alias pass the climb + // stops at the VariableDeclaration and this is silently clean. + writeFileSync(target, [ + "import { z } from 'zod';", + "import { strictObject } from '../shared/strict-object';", + 'const ACTION_RETIRED_KEY_GUIDANCE = {', + " legacyMode: '`legacyMode` was removed in protocol 17 (#4286). Delete the key.',", + '};', + 'export const S = strictObject({', + " surface: 'this action',", + " history: 'an unknown key here was dropped silently.',", + ' guidance: ACTION_RETIRED_KEY_GUIDANCE,', + "}, { name: z.string() });", + ].join('\n')); + r = scan(); + expect('an id in a HOISTED guidance const is RED (the spelling a position-only matcher misses)', + r.violations.length, 1); + expect('the hoisted red names the const it travelled through', + r.violations[0]?.where, 'via ACTION_RETIRED_KEY_GUIDANCE'); + + // RED #7 — a hoisted REFUSAL MESSAGE. Not a hypothetical about the new + // buckets: this shape was hiding four ids from the `message:` rule itself, + // which had reported its population clean since the day it landed. + writeFileSync(target, [ + "import { z } from 'zod';", + 'const URL_NO_USER_REFUSED =', + " 'this `config.url` names no user while `credentialsRef` binds a secret '", + " + '— a pair that cannot work as written (#9041).';", + 'export const S = z.object({ a: z.string() }).refine((v) => !!v.a, {', + ' message: URL_NO_USER_REFUSED,', + '});', + ].join('\n')); + r = scan(); + expect('an id in a HOISTED refusal message is RED', r.violations.length, 1); + expect('the hoisted message red names its const', r.violations[0]?.where, 'via URL_NO_USER_REFUSED'); + expect('the hoisted message red is bucketed as a message', r.violations[0]?.bucket, 'message'); + + // RED #8 — a `retiredKey()` tombstone prescription. + writeFileSync(target, [ + "import { z } from 'zod';", + "import { retiredKey } from '../shared/retired-key';", + 'export const S = z.object({', + " cursor: retiredKey('`cursor` was removed in protocol 17 (#3894). Use `after`.'),", + '});', + ].join('\n')); + r = scan(); + expect('an id in a `retiredKey()` prescription is RED', r.violations.length, 1); + expect('the tombstone red names the position', r.violations[0]?.where, 'retiredKey()'); + expect('the tombstone red names the bucket', r.violations[0]?.bucket, 'tombstone'); + + // RED #9 — a per-value prescription in a `new Map([[k, v]])`, and one in an + // `Object.freeze({…})` table. Both are transparent structure the climb has + // to pass through to reach the const that names them. + writeFileSync(target, [ + "import { z } from 'zod';", + "import { strictObject } from '../shared/strict-object';", + 'const CHATTER_POSITION_RETIRED = new Map([', + " ['right', '`right` was removed in protocol 17 (#6176). Use `main`.'],", + ']);', + 'const FROZEN_GUIDANCE = Object.freeze({', + " tenantId: '`tenantId` never scoped anything (#2377). Delete it.',", + '});', + 'export const S = strictObject({', + " surface: 'this component',", + " history: 'an unknown key here was dropped silently.',", + ' guidance: FROZEN_GUIDANCE,', + ' retiredForms: CHATTER_POSITION_RETIRED,', + "}, { name: z.string() });", + ].join('\n')); + r = scan(); + expect('ids inside `new Map([…])` and `Object.freeze({…})` guidance tables are RED', + r.violations.length, 2); + + // RED #10 — `.describe()` docs prose, the third bucket. + writeFileSync(target, [ + "import { z } from 'zod';", + "export const S = z.string().describe('Machine name. The alias was dropped in #4286.');", + ].join('\n')); + r = scan(); + expect('an id in `.describe()` prose is RED', r.violations.length, 1); + expect('the describe red names the position', r.violations[0]?.where, '.describe()'); + expect('the describe red names the bucket', r.violations[0]?.bucket, 'describe'); + + // ── Precision: what must NEVER fire ───────────────────────────────────── + + // A validator's VALUE argument is not prose. `.min(3, …)` takes a message; + // `.default('#4286')` does not, and an open "any string after position 0" + // rule would report it. writeFileSync(target, [ "import { z } from 'zod';", "export const S = z.object({ a: z.string().default('#4286') });", ].join('\n')); expect('precision — a `.default()` VALUE is not a message', scan().violations.length, 0); + // `guidance` and `history` are ordinary English words. A property so named + // OUTSIDE a strictObject options position is a config record or a schema + // shape, not refusal prose, and reporting it is how a gate gets routed + // around. Both anchors are exercised: the wrong CALL, and the SHAPE + // argument of the right call (argument 1, where a key named `guidance` + // holds a zod schema rather than text). + writeFileSync(target, [ + "import { z } from 'zod';", + "import { strictObject } from '../shared/strict-object';", + 'export const Config = z.object({}).parse({', + " history: 'migrated from the old table in #4286',", + " guidance: { note: 'see #4286' },", + '});', + 'export const S = strictObject({', + " surface: 'this action',", + " history: 'an unknown key here was dropped silently.',", + '}, {', + " guidance: z.string().default('#4286'),", + '});', + ].join('\n')); + expect('precision — `history`/`guidance` outside a strictObject options position do not fire', + scan().violations.length, 0); + + // `extraKeys` carries KEY NAMES for the "did you mean" fallback, not prose. + // It is deliberately absent from STRICT_OPTION_KEYS, and an author who adds + // it there would start reporting identifiers as text. + writeFileSync(target, [ + "import { z } from 'zod';", + "import { strictObject } from '../shared/strict-object';", + 'export const S = strictObject({', + " surface: 'this action',", + " history: 'an unknown key here was dropped silently.',", + " extraKeys: ['tag4286'],", + "}, { name: z.string() });", + ].join('\n')); + expect('precision — `extraKeys` is key names, not prose', scan().violations.length, 0); + // GREEN again from the same scan, so every red above was the id and nothing // else about the tree. writeFileSync(target, CLEAN); r = scan(); expect('stripping the id makes it green again', r.violations.length, 0); - expect('and the detector is still not blind', r.seen >= 1, true); + expect('and the detector is still not blind', r.seen.message >= 1, true); - // The blindness assertion itself must be able to fire: a tree whose only - // sources declare no message at all is `seen === 0`, which main() reds on. + // The blindness assertion itself must be able to fire, PER BUCKET. This is + // the case a total floor cannot see: three buckets still populated, one + // gone silent. Emptying only the `.describe()` bucket must still register + // as blindness in that bucket while the others stay positive. + write('packages/spec/src/data/doc.ts', [ + "import { z } from 'zod';", + "import { strictObject } from '../shared/strict-object';", + "import { retiredKey } from '../shared/retired-key';", + 'export const T = strictObject({', + " surface: 'this doc',", + " history: 'an unknown key here was dropped silently.',", + " guidance: { where: 'not a doc key — delete it.' },", + '}, {', + " cursor: retiredKey('`cursor` was removed in protocol 17 (ADR-0049). Use `after`.'),", + '});', + ].join('\n')); + r = scan(); + expect('one bucket can go blind while the others stay populated — describe', r.seen.describe, 0); + expect('...and the surviving buckets really did stay positive (so the zero above is about ' + + 'that bucket, not an emptied tree)', + r.seen.message >= 1 && r.seen.strictObject >= 1 && r.seen.tombstone >= 1, true); + + // ...and the whole-population version: no recognised string of any kind. writeFileSync(target, "export const S = 1;\n"); write('packages/spec/src/data/doc.ts', "export const D = 2;\n"); write('packages/spec/src/ui/pin.test.ts', "export const T = 3;\n"); - expect('a tree with no recognised message string reports seen=0 (main reds on it)', - scan().seen, 0); + r = scan(); + expect('a tree with no recognised customer-facing string reports every bucket 0 (main reds)', + Object.values(r.seen).reduce((a, b) => a + b, 0), 0); // Empty is a hard error, not a pass — same discipline as the other two rules. rmSync(join(dir, 'packages', 'spec', 'src'), { recursive: true, force: true }); @@ -1264,12 +1687,14 @@ function main() { return; } const messageIdViolations = []; - let messageStringsSeen = 0; + const seenByBucket = { message: 0, strictObject: 0, tombstone: 0, describe: 0 }; for (const file of specSources) { - const r = findMessageIdViolations(readFileSync(file, 'utf8'), file, ts); + const r = findCustomerTextIdViolations(readFileSync(file, 'utf8'), file, ts); messageIdViolations.push(...r.violations); - messageStringsSeen += r.seen; + for (const b of Object.keys(seenByBucket)) seenByBucket[b] += r.seen[b]; } + const blindBuckets = Object.keys(seenByBucket).filter((b) => seenByBucket[b] === 0); + const totalTextSeen = Object.values(seenByBucket).reduce((a, b) => a + b, 0); let failed = false; @@ -1310,42 +1735,60 @@ function main() { ); } - if (messageStringsSeen === 0) { + if (blindBuckets.length > 0) { failed = true; console.error( `\n✗ doc authoring guard: ${specSources.length} spec source(s) were parsed and NOT ONE` - + `\nrefusal-message string was recognised, so "no violations" below would be a verdict on` - + `\na population this run never located.` - + `\n\nThat is the dormant-gate shape, not a clean tree: the spec really does declare` - + `\nrefusal prose, so a zero here means the DETECTOR stopped matching how it is spelled —` - + `\nan options-object key renamed away from \`message\`, a new validator helper, a wrapper` - + `\nthat builds the string somewhere \`messagePosition()\` does not climb to.` - + `\n\nFix \`messagePosition()\` / POSITIONAL_MESSAGE_CALLS in scripts/check-doc-authoring.mjs` - + `\nand add the new spelling to --self-test in the same edit. Do NOT delete this assertion:` - + `\nit is the only thing standing between this rule and a permanent green.\n`, + + `\ncustomer-facing string was recognised in ${blindBuckets.length === 1 ? 'this position' : 'these positions'}:` + + `\n\n ${blindBuckets.join(', ')}` + + `\n\nso "no violations" below would be a verdict on a population this run never located.` + + `\n\nThat is the dormant-gate shape, not a clean tree: the spec really does declare refusal` + + `\nprose, unknown-key guidance, tombstone prescriptions and \`.describe()\` docs, so a zero` + + `\nhere means the DETECTOR stopped matching how one of them is spelled — an options-object` + + `\nkey renamed away from \`message\`, a new validator helper, a \`strictObject\` wrapper under` + + `\na new name, a guidance table moved behind a helper \`customerTextPosition()\` does not` + + `\nclimb through.` + + `\n\nThe floor is PER BUCKET and not on the total, deliberately: \`.describe()\` alone would` + + `\nhold a total positive forever while the \`guidance\` matcher rotted unseen.` + + `\n\nFix \`customerTextPosition()\` / \`collectTextSinkConsts()\` / STRICT_OPTION_KEYS /` + + `\nSTRICT_OBJECT_CALLS / TOMBSTONE_CALLS / POSITIONAL_MESSAGE_CALLS in` + + `\nscripts/check-doc-authoring.mjs and add the new spelling to --self-test in the same edit.` + + `\nDo NOT delete this assertion: it is the only thing standing between this rule and a` + + `\npermanent green.\n`, ); } if (messageIdViolations.length > 0) { failed = true; - console.error(`\n✗ Internal issue-id reference(s) in CUSTOMER-FACING spec refusal messages:\n`); + console.error(`\n✗ Internal issue-id reference(s) in CUSTOMER-FACING spec text:\n`); for (const v of messageIdViolations) { console.error(` ${v.file}:${v.line} ${v.ids.join(' ')} [${v.where}]`); console.error(` ${v.text}`); } + const byBucket = {}; + for (const v of messageIdViolations) byBucket[v.bucket] = (byBucket[v.bucket] ?? 0) + 1; console.error( - `\n${messageIdViolations.length} message string(s). These are printed AT the customer, verbatim,` - + `\nthe moment their metadata is refused — by \`os validate\`, by a publish gate, by a parse.` - + `\nThat reader has no tracker, no \`git log\` and no ADRs, so \`#NNNN\` is a citation-shaped` - + `\ntoken resolving to nothing in the one place they most need the sentence to be actionable.` - + `\n\nStrip the id from the string. Where the reference is genuinely load-bearing for an` - + `\nINTERNAL reader, move it to an adjacent \`//\` comment; otherwise just remove it — git` - + `\nhistory keeps the anchor. Prefer a customer-resolvable anchor where one exists: an ADR` - + `\nnumber, a protocol version, an error code (\`400 INVALID_FILTER\` traces the runtime twin` - + `\nfar better than the id that used to ride beside it).` + `\n${messageIdViolations.length} string(s): ` + + `${Object.entries(byBucket).map(([b, n]) => `${b} ${n}`).join(' · ')}.` + + `\n\nRefusal messages, unknown-key \`guidance\` and tombstone prescriptions are printed AT the` + + `\ncustomer, verbatim, the moment their metadata is refused — by \`os validate\`, by a publish` + + `\ngate, by a parse. \`.describe()\` prose projects into content/docs/references/** and the` + + `\ngenerated skill artifacts. Neither reader has a tracker, \`git log\` or the ADRs, so` + + `\n\`#NNNN\` is a citation-shaped token resolving to nothing — in the refusal case, in the one` + + `\nplace they most need the sentence to be actionable.` + + `\n\nStrip the id from the string; repair the sentence around it rather than rewriting it.` + + `\nWhere the id was the whole parenthetical, the parenthetical goes with it. Where the` + + `\nreference is genuinely load-bearing for an INTERNAL reader, move it to an adjacent \`//\`` + + `\ncomment; otherwise just remove it — git history keeps the anchor.` + + `\n\n⛔ KEEP the customer-resolvable references: an ADR id, a protocol version, an error code` + + `\n(\`400 INVALID_FILTER\` traces the runtime twin far better than the id beside it), and the` + + `\nmigration command. AGENTS.md positively requires a tombstone prescription to carry a` + + `\ndurable reference — "the FROM → TO mapping, the ADR the removal rests on, or the` + + `\nmigration command" — so an issue id NEXT TO an ADR id is the strippable half, and a` + + `\ntombstone whose ONLY reference is the issue id is escalated, never stripped bare.` + `\n\nA test twin pinning the old wording moves WITH the string — keep it pinning the new` - + `\ntext, and add the negative pin (the message must not match an issue id).` - + `\n\nThere is no per-message exemption to reach for, by design.` + + `\ntext, and add the negative pin (the text must not match an issue id).` + + `\n\nThere is no per-string exemption to reach for, by design.` + `\n\nMaintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」\n`, ); } @@ -1355,8 +1798,10 @@ function main() { console.log(`✓ doc authoring guard: ${files.length} files clean — no bare metadata literals.`); console.log(`✓ doc authoring guard: ${published.length} published skill files clean — no internal issue-id references.`); console.log( - `✓ doc authoring guard: ${messageStringsSeen} refusal-message string(s) across ` - + `${specSources.length} spec sources clean — no internal issue-id references.`, + `✓ doc authoring guard: ${totalTextSeen} customer-facing string(s) across ` + + `${specSources.length} spec sources clean — no internal issue-id references ` + + `(message ${seenByBucket.message} · strictObject ${seenByBucket.strictObject} · ` + + `tombstone ${seenByBucket.tombstone} · describe ${seenByBucket.describe}).`, ); }