diff --git a/.changeset/21320-agent-lifecycle-retired.md b/.changeset/21320-agent-lifecycle-retired.md new file mode 100644 index 00000000000..5156d116e17 --- /dev/null +++ b/.changeset/21320-agent-lifecycle-retired.md @@ -0,0 +1,77 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': patch +--- + +feat(spec)!: retire `agent.lifecycle`, the agent conversation state machine, and with it the XState `StateMachineSchema` family — a conversation phase is a skill with `triggerConditions`, orchestration is Flow, record transitions are the `state_machine` validation rule (#21320) + +**BREAKING** — `agent.lifecycle` was parsed and never read. No runtime, in this +repository or in the cloud AI runtime that executes agents, moved an agent through a +declared state or refused an undeclared transition, so an authored machine changed +nothing an agent did (ADR-0049 enforce-or-remove). Enforcing it would have meant a +statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. Authoring +now refuses the key by name, with a prescription, and TypeScript rejects it. + +Its value schema had no other authorable door: ADR-0020 had already retired the XState +shape as a record-lifecycle declaration and kept the file only for this key. So the +family leaves the package with it. + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| `agent.lifecycle` — any value | delete the key. | +| a conversation phase in the machine (its own instructions and tools) | a skill with its own `instructions` and `tools`, selected by its `triggerConditions`, listed in the agent's `skills`. | +| a multi-step process in the machine | a Flow. | +| a record's status transitions in the machine | a `state_machine` validation rule in the object's `validations`: `{ type: 'state_machine', field, transitions: { from: [to, …] } }`. | +| `StateMachineSchema`, `StateNodeSchema`, `TransitionSchema`, `ActionRefSchema`, `GuardRefSchema` and the types `StateMachineConfig`, `StateNode`, `StateNodeConfig`, `Transition`, `ActionRef`, `GuardRef` from `@objectstack/spec/automation` | no replacement: declare the shape your code needs itself, or drop it. For record transitions, `StateMachineValidationSchema` in `@objectstack/spec/data` is the enforced shape. | +| `StateNodeConfig` from `@objectstack/spec` or `@objectstack/spec/ai` | removed with the family; nothing in those entries mentions it any more. | + +**The one-line fix: delete `lifecycle`; put phase-scoped instructions and tools in +skills with `triggerConditions`, and orchestration in Flow.** `os migrate meta --from 17` +lists the mechanical edits for existing sources (the `lifecycle` deletion). Where each +deleted machine's intent goes is the author's judgement. + +The refusal is a parse error at `lifecycle` naming the key and the fix, and the key +fails `tsc` (its input type is `never`). + +### The retirement kit + +- **Tombstone.** `lifecycle` is a `retiredKey()` on `AgentSchema` carrying the + prescription; the agent metadata form no longer offers it. +- **D2 conversion `agent-lifecycle-removed`** (step 18, retired from the load path): + it deletes `lifecycle` from every agent, whatever it holds. The delete is lossless, + because no value of it ever changed what an agent did. Stored `sys_metadata` agent + rows and built artifacts replay it; one notice per agent. An object's ADR-0057 + `lifecycle` block shares the name and is not touched. +- **D3 entry `agent-lifecycle-retired`** carries the judgement the conversion cannot + make: which of the three destinations each deleted machine meant. +- **`RETIRED_KEYS_BY_MAJOR[18]`** registers `ai/Agent:lifecycle`, and + **`RETIRED_DEFS_BY_MAJOR[18]`** registers the five published defs + `automation/StateMachine`, `automation/StateNode`, `automation/Transition`, + `automation/ActionRef` and `automation/GuardRef`. Their reference page + (`references/automation/state-machine`) is gone. +- **No deprecation window**, per the project's startup-stage posture. + +### The liveness ledger + +The `agent.lifecycle` row moves `experimental` → `dead` with a REMOVED note +(`verifiedAt` 2026-10-02); the tombstone keeps it in the walked shape. No `agent` row is +`experimental` any more. `os validate` and every other parsing door refuse the key at +parse, before any advisory runs. `os lint` reads the unparsed stack, so it now grades the +key `liveness-dead-property` where it used to say `liveness-experimental-property`. + +### `@objectstack/platform-objects` + +The agent metadata-form catalogs drop the `lifecycle` row's label and help text in all +four locales. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is +published: tenant-authored agents, and code outside this repository importing the +family's exports, were not measured. This repository authors no `agent.lifecycle` +outside `packages/spec` and imports none of the family outside it; the pinned objectui +checkout imports none of the family and reads no `agent.lifecycle`. + +Clause-②: yes (narrowing) + + diff --git a/content/docs/automation/workflows.mdx b/content/docs/automation/workflows.mdx index a8614f6bbc1..ec2c0632365 100644 --- a/content/docs/automation/workflows.mdx +++ b/content/docs/automation/workflows.mdx @@ -8,7 +8,7 @@ ObjectStack no longer has a standalone Salesforce-style Workflow Rule authoring type. Use: - **Flow** for event-triggered or scheduled automation. -- **State machine metadata** for strict lifecycle transitions. +- **A `state_machine` validation rule** for strict lifecycle transitions. - **Approval nodes** inside Flow for human approval pauses. This page keeps the historical route but documents the current split. @@ -60,42 +60,51 @@ registration/runtime. ## State machines for lifecycle constraints -Use `StateMachineSchema` when the core requirement is "this object can only move -through these states by these events." +Use a `state_machine` validation rule when the core requirement is "this record +can only move through these states." It is one of the object's `validations`: a +flat table of each state's allowed next states, enforced by the write path (see +[State Machine](/docs/protocol/objectql/state-machine)). {/* os:check */} ```typescript -import type { StateMachineConfig } from '@objectstack/spec/automation'; - -export const caseLifecycle: StateMachineConfig = { - id: 'case_lifecycle', - initial: 'new', - states: { - new: { - on: { - ASSIGN: { target: 'assigned' }, - }, - }, - assigned: { - on: { - RESOLVE: { target: 'resolved', cond: 'has_resolution' }, - ESCALATE: { target: 'escalated' }, - }, - }, - escalated: { - on: { - RESOLVE: { target: 'resolved', cond: 'has_resolution' }, +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const SupportCase = ObjectSchema.create({ + name: 'support_case', + label: 'Support Case', + sharingModel: 'private', + fields: { + status: Field.select({ + label: 'Status', + required: true, + options: [ + { label: 'New', value: 'new' }, + { label: 'Assigned', value: 'assigned' }, + { label: 'Escalated', value: 'escalated' }, + { label: 'Resolved', value: 'resolved' }, + ], + }), + }, + validations: [ + { + type: 'state_machine', + name: 'case_status_flow', + field: 'status', + events: ['update'], + message: 'Invalid case status transition.', + transitions: { + new: ['assigned'], + assigned: ['resolved', 'escalated'], + escalated: ['resolved'], + resolved: [], }, }, - resolved: { - type: 'final', - }, - }, -}; + ], +}); ``` -State machines describe valid transitions and guards. Use Flow nodes for side -effects around those transitions when you need notifications, record updates, or +The rule declares which transitions are legal. Use Flow nodes for side effects +around those transitions when you need notifications, record updates, or external calls. --- diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index c3f2179f90f..a117f8a072f 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -151,15 +151,14 @@ REST endpoints, real-time subscriptions, and discovery. | **[Metadata](/docs/references/api/metadata)** | `metadata.zod.ts` | Metadata | API metadata endpoints | | **[Storage](/docs/references/api/storage)** | `storage.zod.ts` | Storage | API storage operations | -## Automation Protocol (4 of 14 schemas) +## Automation Protocol (3 of 13 schemas) -Flows, state machines, approvals, and integrations. +Flows, approvals, and integrations. A record's lifecycle transitions are a `state_machine` validation rule on the object (see [State Machine](/docs/protocol/objectql/state-machine)). | Protocol | Source File | Key Schemas | Purpose | |:---------|:-----------|:------------|:--------| | **[Flow](/docs/references/automation/flow)** | `flow.zod.ts` | Flow, FlowNode | Visual workflow builder | | **[Approval](/docs/references/automation/approval)** | `approval.zod.ts` | ApprovalNodeConfig | Flow approval-node config | -| **[State Machine](/docs/references/automation/state-machine)** | `state-machine.zod.ts` | StateMachine | State machine definitions | | **[Webhook](/docs/references/automation/webhook)** | `webhook.zod.ts` | Webhook | Outbound webhooks | ## Security Protocol (3 of 5 schemas) diff --git a/content/docs/references/ai/agent.mdx b/content/docs/references/ai/agent.mdx index 3cf2c258c65..7d260dfcd07 100644 --- a/content/docs/references/ai/agent.mdx +++ b/content/docs/references/ai/agent.mdx @@ -49,7 +49,7 @@ 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. | +| **lifecycle** | `never` | optional | [REMOVED] `agent.lifecycle` was removed in @objectstack/spec 17.7.0 (ADR-0049 enforce-or-remove) — no runtime ever read it: no agent moved through a declared state and no transition was ever refused. Delete the key. A phase of a conversation is a skill with its own `instructions` and `tools`, selected by its `triggerConditions` (ADR-0064); multi-step process orchestration is a Flow (ADR-0019); a record's status transitions are a `state_machine` validation rule on the object (ADR-0020). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **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 — 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. ADR-0064 itself still reads `Proposed` and is cloud-owned — that scopes its RUNTIME half (tool resolution, which lives in cloud `service-ai`), not this rejection: the authoring invariant binds you here, and ADR-0109 (Accepted — implemented) is the in-repo record that carries it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | @@ -80,17 +80,6 @@ const result = AIModelConfigSchema.parse(data); | **maxTokens** | `number` | optional | | | **topP** | `number` | optional | | -### Nested Shape: `Agent.lifecycle` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique Machine ID | -| **description** | `string` | optional | | -| **contextSchema** | `Record` | optional | Zod Schema for the machine context/memory | -| **initial** | `string` | ✅ | Initial State ID | -| **states** | `Record; entry?: (string \| object)[]; exit?: (string \| object)[]; on?: Record; … }>` | ✅ | State Nodes | -| **on** | `Record` | optional | | - ### Nested Shape: `Agent.planning` | Property | Type | Required | Description | diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index e9c873bc3a0..439f2c5b34f 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -1,7 +1,7 @@ --- title: Automation Protocol — schema reference navTitle: Automation Protocol -description: "The ObjectStack Automation Protocol in 14 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack Automation Protocol in 13 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -20,7 +20,6 @@ This section contains all protocol schemas for the automation layer of ObjectSta - diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index b32a7ec940d..65aabf1f154 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -6,7 +6,6 @@ "execution", "flow", "node-executor", - "state-machine", "time-relative-trigger", "---Integration & Data---", "bpmn-interop", diff --git a/content/docs/references/automation/state-machine.mdx b/content/docs/references/automation/state-machine.mdx deleted file mode 100644 index f3941e1fb10..00000000000 --- a/content/docs/references/automation/state-machine.mdx +++ /dev/null @@ -1,249 +0,0 @@ ---- -title: State Machine — Automation Protocol reference -navTitle: State Machine -description: "XState-inspired State Machine Protocol — hierarchical states, guarded transitions, entry/exit actions." ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -XState-inspired State Machine Protocol — hierarchical states, guarded -transitions, entry/exit actions. Used to declare strict business-logic -constraints and lifecycle management, so an AI author cannot "hallucinate" a -transition the machine never declared. - -## Where this is authored — the question #4001 had to answer first - -The ledger carried these shapes as `authorable (p)` — provisional, because -nobody had checked. Checking matters here more than usual, because -[ADR-0020](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0020-state-machine-converge-and-enforce.md) -**retired this shape as a record-lifecycle declaration**: the top-level -`workflow` metadata type and `object.stateMachines` are both gone, and a -record's legal transitions are declared as a `state_machine` **validation -rule** (`data/validation.zod.ts`, a flat `{ from: [to] }` table — closed -since #4001 batch 3b). A schema whose only doors were those two would be -dead surface, and the campaign's own rule is that dead surface gets its -ledger class corrected, not tightened. - -One door survives, and it is an authoring door: **`ai/agent.zod.ts`'s -`lifecycle`** is `StateMachineSchema`, and `agent` is a registered metadata -type — so `defineStack({ agents })`, `POST /api/v1/meta/types/agent` and the -Studio agent form all reach this file through `AgentSchema.parse()`. Verified -by parse, not by reading: before this change, - -```ts -AgentSchema.parse({ …, lifecycle: { - id: 'probe_machine', initial: 'draft', stats: { runs: 3 }, - states: { draft: { onn: { APPROVE: 'done' }, meta: { labell: 'Draft', owner: 'ops' } }, - done: { type: 'final' } }, -} }) -``` - -**succeeded**, returning -`{ id, initial, states: { draft: { type: 'atomic', meta: {} }, done: … } }` — -`stats` gone, `meta`'s two keys gone, and `onn` (one keystroke from `on`) -gone with every transition the author declared. A state machine whose whole -purpose is to *deny* undeclared transitions had silently become one with no -transitions at all, and reported success. - -So: `authorable`, and every shape below is `strictObject`. - -## `meta` is closed, deliberately - -XState treats `meta` as an open bag, so leaving it open was the plausible -call and it was checked rather than assumed (the #4909 precedent: a slot -whose openness is real should say `.passthrough()`, not strip). Three facts -say closed here: the hand-written `StateNodeConfig` type beside this -schema declares exactly four `meta` keys, so `passthrough` would open the -Zod while `tsc` stayed shut — a new declared-≠-enforced split; nothing in -this repo reads any `meta` key (`aiInstructions` has no consumer outside -this file's own test); and the current behaviour is not openness but -*strip* — the probe above shows an author's `meta` arriving as `{}`. There -is no openness here to preserve, only a silence to end. - - -**Source:** `packages/spec/src/automation/state-machine.zod.ts` - - -## TypeScript Usage - -```typescript -import { ActionRefSchema, GuardRefSchema, StateMachineSchema, StateNodeSchema, TransitionSchema } from '@objectstack/spec/automation'; -import type { ActionRef, GuardRef, StateNode, Transition } from '@objectstack/spec/automation'; - -// Validate data -const result = ActionRefSchema.parse(data); -``` - ---- - -## ActionRef - -### Union Options - -This schema accepts one of the following structures: - -#### Option 1 - -Action Name - -Type: `string` - ---- - -#### Option 2 - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `string` | ✅ | | -| **params** | `Record` | optional | | - ---- - - ---- - -## GuardRef - -### Union Options - -This schema accepts one of the following structures: - -#### Option 1 - -Guard Name (e.g., "isManager", "amountGT1000") - -Type: `string` - ---- - -#### Option 2 - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `string` | ✅ | | -| **params** | `Record` | optional | | - ---- - - ---- - -## StateMachine - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique Machine ID | -| **description** | `string` | optional | | -| **contextSchema** | `Record` | optional | Zod Schema for the machine context/memory | -| **initial** | `string` | ✅ | Initial State ID | -| **states** | `Record; entry?: (string \| object)[]; exit?: (string \| object)[]; on?: Record; … }>` | ✅ | State Nodes | -| **on** | `Record` | optional | | - -### Nested Shape: `StateMachine.states[string]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'atomic' \| 'compound' \| 'parallel' \| 'final' \| 'history'>` | optional (default: `"atomic"`) | | -| **entry** | `(string \| { type: string; params?: Record })[]` | optional | Actions to run when entering this state | -| **exit** | `(string \| { type: string; params?: Record })[]` | optional | Actions to run when leaving this state | -| **on** | `Record` | optional | Map of Event Type -> Transition Definition | -| **always** | `{ target?: string; cond?: string \| object; actions?: (string \| object)[]; description?: string }[]` | optional | | -| **initial** | `string` | optional | Initial child state (if compound) | -| **states** | `Record; entry?: (string \| object)[]; exit?: (string \| object)[]; on?: Record; … }>` | optional | | -| **meta** | `{ label?: string; description?: string; color?: string; aiInstructions?: string }` | optional | | - -### Nested Shape: `StateMachine.on[string][option 2]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **target** | `string` | optional | Target State ID | -| **cond** | `string \| { type: string; params?: Record }` | optional | Condition (Guard) required to take this path | -| **actions** | `(string \| { type: string; params?: Record })[]` | optional | Actions to execute during transition | -| **description** | `string` | optional | Human readable description of this rule | - -### Nested Shape: `StateMachine.on[string][option 3][number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **target** | `string` | optional | Target State ID | -| **cond** | `string \| { type: string; params?: Record }` | optional | Condition (Guard) required to take this path | -| **actions** | `(string \| { type: string; params?: Record })[]` | optional | Actions to execute during transition | -| **description** | `string` | optional | Human readable description of this rule | - - ---- - -## StateNode - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'atomic' \| 'compound' \| 'parallel' \| 'final' \| 'history'>` | optional (default: `"atomic"`) | | -| **entry** | `(string \| { type: string; params?: Record })[]` | optional | Actions to run when entering this state | -| **exit** | `(string \| { type: string; params?: Record })[]` | optional | Actions to run when leaving this state | -| **on** | `Record` | optional | Map of Event Type -> Transition Definition | -| **always** | `{ target?: string; cond?: string \| object; actions?: (string \| object)[]; description?: string }[]` | optional | | -| **initial** | `string` | optional | Initial child state (if compound) | -| **states** | `Record` | optional | | -| **meta** | `{ label?: string; description?: string; color?: string; aiInstructions?: string }` | optional | | - -### Nested Shape: `StateNode.on[string][option 2]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **target** | `string` | optional | Target State ID | -| **cond** | `string \| { type: string; params?: Record }` | optional | Condition (Guard) required to take this path | -| **actions** | `(string \| { type: string; params?: Record })[]` | optional | Actions to execute during transition | -| **description** | `string` | optional | Human readable description of this rule | - -### Nested Shape: `StateNode.on[string][option 3][number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **target** | `string` | optional | Target State ID | -| **cond** | `string \| { type: string; params?: Record }` | optional | Condition (Guard) required to take this path | -| **actions** | `(string \| { type: string; params?: Record })[]` | optional | Actions to execute during transition | -| **description** | `string` | optional | Human readable description of this rule | - -### Nested Shape: `StateNode.always[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **target** | `string` | optional | Target State ID | -| **cond** | `string \| { type: string; params?: Record }` | optional | Condition (Guard) required to take this path | -| **actions** | `(string \| { type: string; params?: Record })[]` | optional | Actions to execute during transition | -| **description** | `string` | optional | Human readable description of this rule | - -### Nested Shape: `StateNode.meta` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **label** | `string` | optional | | -| **description** | `string` | optional | | -| **color** | `string` | optional | | -| **aiInstructions** | `string` | optional | Specific instructions for AI when in this state | - - ---- - -## Transition - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **target** | `string` | optional | Target State ID | -| **cond** | `string \| { type: string; params?: Record }` | optional | Condition (Guard) required to take this path | -| **actions** | `(string \| { type: string; params?: Record })[]` | optional | Actions to execute during transition | -| **description** | `string` | optional | Human readable description of this rule | - - ---- - diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 6eba36bff04..f0e035eccf9 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1522 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1517 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -22,7 +22,7 @@ counts are sums of the rows they head. Regenerate with | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 430 | REST contracts, endpoints, routing, realtime, batch, discovery. | -| [Automation Protocol](/docs/references/automation) | 14 | 75 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | +| [Automation Protocol](/docs/references/automation) | 13 | 70 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 30 | 178 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1522** | 14 protocol modules | +| **Total** | **196** | **1517** | 14 protocol modules | --- @@ -106,7 +106,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. ## Automation Protocol -**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 75 schemas** +**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 70 schemas** Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. @@ -123,7 +123,6 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | [`node-executor.zod.ts`](/docs/references/automation/node-executor) | `ActionCategory`, `ActionDescriptor`, `ActionParadigm`, `NodeExecutorDescriptor`, `WaitEventType`, `WaitExecutorConfig`, `WaitResumePayload`, `WaitTimeoutBehavior` | | [`schedule-organization.zod.ts`](/docs/references/automation/schedule-organization) | `ScheduleOrganization` | | [`schemaless-node-config.zod.ts`](/docs/references/automation/schemaless-node-config) | `DecisionCondition`, `DecisionConfig`, `ScriptConfig`, `SubflowConfig` | -| [`state-machine.zod.ts`](/docs/references/automation/state-machine) | `ActionRef`, `GuardRef`, `StateMachine`, `StateNode`, `Transition` | | [`time-relative-trigger.zod.ts`](/docs/references/automation/time-relative-trigger) | `TimeRelativeTrigger` | | [`webhook.zod.ts`](/docs/references/automation/webhook) | `Webhook`, `WebhookTriggerType` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md index d0e4036daaf..b0a0d783611 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `automation/` | 67 | 43 | 0 | 1 | 23 | +| `automation/` | 61 | 37 | 0 | 1 | 23 | ## `automation/` — sites @@ -41,10 +41,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `io-node-config.zod.ts` | 2 | | `node-executor.zod.ts` | 4 | | `schemaless-node-config.zod.ts` | 4 | -| `state-machine.zod.ts` | 6 | | `time-relative-trigger.zod.ts` | 1 | | `webhook.zod.ts` | 1 | -| **total** | **67** | +| **total** | **61** | ## `automation/` — open @@ -52,7 +51,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**23 strip of 67**, in 5 file(s). +**23 strip of 61**, in 5 file(s). | File | Strip | Sites | |---|---|---| @@ -61,7 +60,7 @@ over it is here. | `execution.zod.ts` | 12 | 12 | | `flow.zod.ts` | 1 | 11 | | `node-executor.zod.ts` | 4 | 4 | -| **total** | **23** | **67** | +| **total** | **23** | **61** | | Bucket | Sites | |---|---| diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 11a2ef47fef..574168a6bf1 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -740,7 +740,6 @@ column does not move and the `strip` column falls by the count of what left. |---|---|---| | `flow.zod.ts` | authorable | **strict as of #4001** — the four outer authoring shapes at step 1, and **the six nested blocks at batch 11** (`FlowNode.connectorConfig` / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig`, `Flow.errorHandling`). The gap between those two dates is this campaign's own finding 17 inside its own file: closing the shells left the gate rejecting `nodee:` at node level while `connectorConfig: { connectorId, actionId, params: {…} }` parsed clean and the executor dispatched `input ?? {}` — a successful connector call carrying nothing. Worth recording precisely, because the obvious example is the wrong one: a slip on a REQUIRED key was always loud (it then reads as missing). What `.strip` swallowed here is the OPTIONAL half — the input map, the retry budget, `interrupting: false`, `required: true` — i.e. exactly the keys an author adds to CONSTRAIN behaviour, replaced by a permissive default without a word. `Flow.errorHandling` gained a second chapter at **#4964**: closing it in 批 11 revealed (rather than caused) that its retry keys were a THIRD encoding of the policy #4661 had converged — it spelled the base delay `retryDelayMs` where the shared declaration spells it `backoffMs` and tombstones the old word, so the strictness this row records was, for one release, rejecting an author for having read the newer file. The block now builds from `retryPolicyShape()`. Site count unchanged; only the vocabulary. Two things stay open and are now pinned in code with the reason, so a later sweep stops rather than "finishes" the file: the node `config` slot (ADR-0018 plugin namespace) and `FlowVersionHistorySchema` (the file's only WIRE shape — emitted on publish, never authored; its `definition` is `FlowSchema`, so the authored half inside a history record is gated anyway) | | `execution.zod.ts` | wire | run-state envelopes — never strict. +5 at #4354 (the run-summary family: step metrics / skip reason / per-node / per-gate / the summary itself) — engine-emitted telemetry read by the Console and by operator queries, nobody authors them, so the `wire` verdict covers them unchanged | -| `state-machine.zod.ts` | authorable | **strict as of #4001 批 10** — all six sites (`ActionRef` / `GuardRef` / `Transition` / `StateNode` + `.meta` / `StateMachine`). **The `(p)` was NOT a formality here.** ADR-0020 retired this XState shape as a *record-lifecycle* declaration — the top-level `workflow` metadata type and `object.stateMachines` are both gone, and a record's transitions live on the `state_machine` VALIDATION RULE instead — so had those been the only doors this file would be DEAD surface, and the correct action would have been to fix its class, not close it. One authoring door survives: `ai/agent.zod.ts`'s `lifecycle` is `StateMachineSchema`, and `agent` is a registered type, so `defineStack({ agents })` / meta REST / the Studio agent form all reach here through `AgentSchema.parse()`. Verified by parse: an agent whose lifecycle carried `stats`, a state with `onn` (one keystroke from `on`) and a `meta` with two unknown keys **parsed clean**, returning a machine with NO transitions at all — the declaration whose whole job is to deny undeclared transitions, silently emptied and reported valid. `.meta` was checked for the #4909 open-slot case and is CLOSED: the hand-written `StateNodeConfig` type declares exactly its four keys (passthrough would open the Zod while `tsc` stayed shut), nothing in the repo reads any `meta` key, and the prior behaviour was strip — an author's `meta` arrived as `{}` — so there was no openness to preserve. ⚠️ `ActionRef` / `GuardRef` are UNIONS: a strict branch's message does not reach the top (zod raises one `invalid_union` whose message is the literal `"Invalid input"`, with the real prescription nested in `issue.errors[]`) — that parenthesis still describes the tree today. **The clause that used to follow it does not, and it is corrected rather than kept as written:** 批 10 recorded (2026-08-04) that `formatZodError` then flattened that payload away, and filed it without a number — the filing was **#4971**, and it landed the NEXT DAY (2026-08-05), together with its two siblings on the other doors, **#5014** (the wire mapper) and **#5341** (the CLI). ✅ **The flattening is LIFTED and this clause's consequence is spent.** `formatZodIssue` descends `invalid_union` and ranks the branches through `selectUnionBranches` — the single policy both spec-side walks import, `packages/spec/src/shared/union-branch-policy.ts`, consolidated there at **#8318** — dropping arms that only complain about the wrong kind and rendering the informative one verbatim. Re-measured through this row's OWN door: `defineStack` on an agent whose `lifecycle.states.idle.entry[0]` is `{ type: 'log', parms: {…} }` prints `✗ …entry.0: Invalid input` and, indented beneath it, *"Unrecognized key(s) on this action reference: `parms`. Did you mean `parms` → `params`?"* — the key, the surface and the rename all arrive. The union's cost is one wrapper line, not a lost message. **−1 at #4658**: the orphan `EventSchema` (`{ type, schema }`, an XState-style signal declaration nothing referenced — `StateMachineSchema` names event types as `on:` record keys) was deleted rather than converged with `kernel/events/core.zod.ts`'s envelope `EventSchema`, whose key set it did not intersect (#4535 C6). The remaining 6 sites and their verdict are unchanged | | `control-flow.zod.ts` | authorable | **strict as of #4001 批 10** — all five sites (`FlowRegion` / `Loop` / `ParallelBranch` / `Parallel` / `TryCatch`). The `(p)` resolves to authorable on the executors' own parse seam (`parseNodeConfig`, #4277) plus `validateControlFlow`'s region parse. **`validateControlFlow` is a sibling guard, not a key gate**: it answers single-entry / single-exit / acyclic, which no key check can decide. **The two are no longer disjoint, and since #16134 (landed `21aabbc7b`) that is deliberate** — `FlowSchema`'s `superRefine` refuses a duplicate node id, which is a STRUCTURAL fact decided by the schema, so the schema answers key membership *and* that one structural fact while the analysis answers malformed structure. **They meet at two seams.** **#4001** — the guard `safeParse`s each region slot before analyzing it, so an undeclared region key surfaces there as `: invalid region — `, the guard's framing wrapping the schema's prescription; that seam duplicated nothing and removed nothing, and the guard simply stopped silently repairing its own input before judging it. **#16134** — `FlowSchema`'s `superRefine` holds ONE node-id space across the top-level `nodes[]` and every region body, judged at every depth `collectFlowGraphs` walks; that walk stops at `MAX_REGION_DEPTH` (32), so past the ceiling a region is left raw and `analyzeRegion`'s own `duplicate node id` line, reached through this guard, is the only refusal of a within-region duplicate (a cross-region collision beyond the ceiling is not judged at all). The two guards overlap there by design and hand off at that measured boundary, pinned by `flow.test.ts`'s `the seam at MAX_REGION_DEPTH` case — nesting 32 refused at parse, nesting 33 accepted at parse and refused by `analyzeRegion`. The canonical statement of this division is the `control-flow.zod.ts` docblock as #16835 left it (`c3ce76c210`); this row follows it rather than restating it independently. Two curation entries had to be MEASURED rather than reasoned: the bare edit-distance fallback answers `itemVariable` with **`indexVariable`** — binding the loop INDEX where the author wanted the ITEM — so the alias exists to overrule a confidently wrong suggestion from this campaign's own helper (the `pii` → `min` shape, third instance); and `join`/`joinGateway` needed two DISTINCT prescriptions because `guidance` emits one bullet per key verbatim, so a shared string printed the same paragraph twice. Its test instrument also had to be rebuilt: `region-slots.test.ts` probed every construct with every candidate key at once and depended on `.strip` to discard the mismatches, so it returned "no schema accepts any region" the moment the shapes closed — it failed loudly, which is the only reason this is a footnote and not a fourth finding-3. Structural validation by `validateControlFlow` remains. **−1 at #4661**: `RetryPolicySchema` moved out to `shared/retry-policy.zod.ts` — `./automation` and `./system` published the same name for two different declarations (#4411), so the retry policy converged onto one. The site still exists and is still non-strict and authorable; it is simply no longer in a directory this ledger sections. ⚠️ That is a coverage gap worth knowing about: this audit sections `ui/` / `data/` / `automation/` / `security/` / `studio/` only, so a `shared/` shape is unaudited by construction. The tolerance is deliberate here — the `retryDelayMs` → `backoffMs` rename is tombstoned via `retiredKey()` precisely because a non-strict parent would otherwise swallow the old spelling. **#4964 widened that rename to `flow.errorHandling`**, which spelled the base delay the pre-17 way while the shared policy tombstoned it — so the two automation retry surfaces now teach the same word, and the tombstone's prescription names all four surfaces instead of the two #4661 could see. **+1 at #13681, and it is `wire` by design, not debt: `TryCatchErrorValueSchema`** — the value the engine binds to a `try_catch`'s `errorVariable` (`nodeId` / `message`, plus `iteration` / `item` when the failure happened inside a loop body), declared so the engine, the catch region and the run log share one shape instead of an engine-assembled literal typed nowhere. Nobody authors it, so it is a plain `z.object` and is listed in the remaining-strip-sites map below as out of scope; the five authorable sites above stay strict | | `bpmn-interop.zod.ts` | wire (p) | interop import shapes | | `approval.zod.ts` | authorable | **strict as of #4001 step 3** — all four authoring schemas (node config / approver / escalation / decision-output). The published JSON schema carries `additionalProperties: false` into the Studio form AND `registerFlow()` config validation (#4027/#4040), so an unknown key in an approval node's `config` is rejected at registration too — verified: `z.toJSONSchema` on the strict lazySchema does not throw (#3746 hazard checked) | @@ -752,6 +751,8 @@ column does not move and the `strip` column falls by the count of what left. | `time-relative-trigger.zod.ts` | authorable | **Undeclared until the #4001 re-measurement, and invisible for the worst possible reason**: `TimeRelativeTriggerSchema` is written `z\n .object({`, the old textual counter matched zero sites, and a zero-site file is SKIPPED by the coverage walk as "nothing to classify". So the gate whose whole promise is "no undeclared surface" reported green over an authorable schema — the same shape as `data/driver/`, one layer subtler, because this time the file was not hidden by the walk but by the counter feeding it. Classification is not a guess: the file's own `@example` blocks author it by hand into a flow start node (`config: { timeRelative: { object, dateField, offsetDays, filter } }`), which is the authoring door. A stripped key here means the sweep silently never matches — `offsetDay` for `offsetDays` returns a trigger that never fires, reported as configured. **Strict as of #4001 batch 11**, and closing it turned up one thing the triage did not predict: this schema is `safeParse`d at BIND time by `TimeRelativeTriggerPlugin` (`time-relative-trigger.ts`), not only at authoring — so the descriptor sitting under the deliberately-OPEN node `config` slot (ADR-0018) now has exactly one gate, and it is a runtime one. The behaviour change is the campaign's whole thesis in miniature: `{ …valid, offsetDay: 7 }` used to bind a sweep that ran daily with the author's narrowing discarded; it now refuses to bind and the plugin's warning carries the key and the rename | | `flow-function.zod.ts` | authorable | `FlowFunctionDeclarationSchema` (#4396) — the `{ handler, effect }` form of a `defineStack({ functions })` entry. Authored, but note what an undeclared key here would be: a sibling of a **live function**, not data. `defineStack`'s union already rejects a record whose `handler` is not callable, and the boot-path reader is the hand-written `normalizeFlowFunctionEntry` rather than a `.parse()` (re-validating a live handler every boot buys nothing), so strictness would bind at authoring only. **Strict as of #4001 batch 11**, and the verify-first pass confirmed that reading exactly — stated in the code rather than left implied, because a tightening must not claim reach it does not have. It is still worth having for the reason the reading first made it look pointless: `normalizeFlowFunctionEntry` takes TWO keys and ignores the rest **by construction**, so a misspelled `effect` was dropped at the schema and then not looked for by the reader — and the failure runs the quiet way. The function registers, runs, and its writes are counted as none, which is precisely what keeps #4354's broken-sweep query (`selected > 0 AND acted = 0 AND unmeasured = 0`) silent on the one run that needed it | +`state-machine.zod.ts` had a row here (authorable, **strict as of #4001 批 10** — all six sites: `ActionRef` / `GuardRef` / `Transition` / `StateNode` + `.meta` / `StateMachine`) until **#21320 retired the file whole** (ADR-0049 enforce-or-remove; ruled D, retire, on objectstack-ai/cloud#2569). The row's own finding was that ADR-0020 had already removed every record-lifecycle door to this XState shape and that ONE authoring door survived — `ai/agent.zod.ts`'s `lifecycle` — so the file was authorable surface rather than dead surface, and was closed rather than reclassified. That door was then measured to have no reader in either repository: the agent conversation state machine was parsed and never enforced. `agent.lifecycle` became a `retiredKey()` tombstone (the ADR-0087 conversion `agent-lifecycle-removed` deletes it from stored rows and sources), the file lost its last importer, and its five published defs (`automation/StateMachine`, `StateNode`, `Transition`, `ActionRef`, `GuardRef`) entered `RETIRED_DEFS_BY_MAJOR` with the D3 entry `agent-lifecycle-retired`. A record's legal transitions remain the `state_machine` rule in `data/validation.zod.ts`, which never used this file. + > **⚠️ ERRATUM (2026-08-29) — the `flow-function.zod.ts` row's closing sentence.** > The row is left exactly as written, because this is a dated record of what the > 2026-07 audit concluded. Its last clause has since been measured wrong in two diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index ab5a1768898..aa23421e0ac 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2400,10 +2400,6 @@ export const enMetadataForms: NonNullable = { label: "Memory", helpText: "Long-term memory: distilled notes kept per user, recalled before each conversation and written by a reflection every reflectionInterval delivered interactions. When long-term memory is enabled, maxEntries and reflectionInterval are required. Enforced by the cloud AI runtime." }, - lifecycle: { - label: "Lifecycle", - helpText: "State machine defining conversation flow" - }, structuredOutput: { label: "Structured Output", helpText: "Output contract for the agent's final answer: JSON format, the JSON Schema it is checked against, retries, fallback format and transform steps. Enforced by the cloud AI runtime." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index ab3c1896e93..7ebd1c809f8 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2400,10 +2400,6 @@ export const esESMetadataForms: NonNullable = label: "Memoria", helpText: "Memoria a largo plazo: notas destiladas que se guardan por usuario, se recuperan antes de cada conversación y las escribe una reflexión cada reflectionInterval interacciones entregadas. Cuando la memoria a largo plazo está habilitada, maxEntries y reflectionInterval son obligatorios. Lo aplica el runtime de IA en la nube." }, - lifecycle: { - label: "Ciclo de vida", - helpText: "Máquina de estado que define el flujo de conversación" - }, structuredOutput: { label: "Salida estructurada", helpText: "Contrato de salida para la respuesta final del agente: formato JSON, el JSON Schema con el que se valida, reintentos, formato de respaldo y pasos de transformación. Lo aplica el runtime de IA en la nube." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 6105de7f91a..e831e1dc02b 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2400,10 +2400,6 @@ export const jaJPMetadataForms: NonNullable = label: "メモリ", helpText: "長期メモリ: ユーザーごとに保持される要約ノート。各会話の前に呼び出され、配信済みのやり取り reflectionInterval 回ごとに 1 回のリフレクションで書き込まれます。長期メモリを有効にする場合、maxEntries と reflectionInterval は必須です。クラウド AI ランタイムが適用します。" }, - lifecycle: { - label: "ライフサイクル", - helpText: "会話フローを定義するステートマシン" - }, structuredOutput: { label: "構造化出力", helpText: "エージェントの最終回答に対する出力契約: JSON 形式、回答の検証に使う JSON Schema、リトライ、フォールバック形式、変換ステップ。クラウド AI ランタイムが適用します。" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 8bec79f9bff..5d6c459e1e8 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -239,11 +239,23 @@ const BOTH_TRANSLATE: Readonly> = { 'es-ES': 'translate', }; -/** The `agent` panel's own authored `Lifecycle` — the head noun two rows copy. */ -const AGENT_LIFECYCLE: Copy = { +/** + * The authored `Lifecycle` head noun two rows copy, as a SHARED FRAGMENT. + * + * ⚠️ RE-JUDGED (#21320). These rows were decided against the `agent` panel's + * own `Lifecycle` label (`agent.fields.lifecycle.label`, copied whole, mode + * `in`). That twin left the catalog when `agent.lifecycle` — the agent + * conversation state machine — was retired and its form row deleted, so the + * copy had nothing left to be asserted against. The renderings did not move: + * the same head noun stands authored in the hook form's `Lifecycle events` + * helpText (ライフサイクルイベント / Eventos de ciclo de vida), the twin this + * ledger already copies `例: ` / `p. ej.` from, so the head noun is now asserted + * as a fragment both strings carry. + */ +const LIFECYCLE_HEAD_NOUN: Copy = { catalog: 'metadataForms', - key: 'agent.fields.lifecycle.label', - modes: { 'ja-JP': 'in', 'es-ES': 'in' }, + key: 'hook.fields.events.helpText', + modes: { 'ja-JP': 'ライフサイクル', 'es-ES': 'ciclo de vida' }, }; const DECISIONS: readonly Decision[] = [ @@ -255,9 +267,9 @@ const DECISIONS: readonly Decision[] = [ en: 'Lifecycle', verdict: BOTH_TRANSLATE, verbatim: [], - copies: [AGENT_LIFECYCLE], + copies: [LIFECYCLE_HEAD_NOUN], reason: - 'THE HEAD NOUN IS COPIED FROM AN EXACT AUTHORED TWIN and then QUALIFIED, because the bare word is already spent. agent.fields.lifecycle.label carries the IDENTICAL English string and is 生命周期 / ライフサイクル / Ciclo de vida — but it names the AGENT conversation state machine, a different concept on a different panel, and this block is ADR-0057 DATA lifecycle. Leaving both as the bare word would collide two contracts on one rendering. ⇒ the twin is copied and the catalog word for "data" is prefixed: データ from object.sections.fields.description (データモデル), datos from the same twin (modelo de datos). ⇒ データライフサイクル / Ciclo de vida de los datos. ⭐ CONCEPT settled by zh-CN, which reached the same qualification independently (数据生命周期 against the agent panel plain 生命周期); the WORDS come from each locale own authored twin, and the containment of the twin is asserted below.', + '⚠️ RE-JUDGED: the twin this row was decided against, agent.fields.lifecycle.label, left the catalog with the retired agent.lifecycle key; the head noun is now asserted against hook.fields.events.helpText (see LIFECYCLE_HEAD_NOUN), and the rendering below is unchanged. As decided: THE HEAD NOUN IS COPIED FROM AN EXACT AUTHORED TWIN and then QUALIFIED, because the bare word is already spent. agent.fields.lifecycle.label carried the IDENTICAL English string and was 生命周期 / ライフサイクル / Ciclo de vida — but it named the AGENT conversation state machine, a different concept on a different panel, and this block is ADR-0057 DATA lifecycle. Leaving both as the bare word would collide two contracts on one rendering. ⇒ the twin is copied and the catalog word for "data" is prefixed: データ from object.sections.fields.description (データモデル), datos from the same twin (modelo de datos). ⇒ データライフサイクル / Ciclo de vida de los datos. ⭐ CONCEPT settled by zh-CN, which reached the same qualification independently (数据生命周期 against the agent panel plain 生命周期); the WORDS come from each locale own authored twin, and the containment of the twin is asserted below.', }, { population: 'lifecycle', @@ -281,9 +293,9 @@ const DECISIONS: readonly Decision[] = [ prop: 'label', en: 'Class', verdict: BOTH_TRANSLATE, - copies: [AGENT_LIFECYCLE], + copies: [LIFECYCLE_HEAD_NOUN], reason: - 'THE HUMANIZE IS A BARE WORD AND THE BARE WORD IS AMBIGUOUS IN BOTH LOCALES. objectForm declares no label (asserted), so "Class" is humanizeFieldPath of the path leaf; bare クラス and bare Clase read as a programming class or a CSS class, and the form nests this row under a composite whose own label is now the qualified one. ⇒ qualified with the parent block, copying AGENT_LIFECYCLE the same way the row above does: ライフサイクルクラス / Clase de ciclo de vida. ⭐ CONCEPT from the live LifecycleSchema, ⛔ not from zh-CN: `class` is LifecycleClassSchema, a five-member enum (record | audit | telemetry | transient | event) asserted below, i.e. the persistence contract of the object rows — zh-CN independently reached the same qualification (生命周期类别), which is corroboration rather than the source.', + 'THE HUMANIZE IS A BARE WORD AND THE BARE WORD IS AMBIGUOUS IN BOTH LOCALES. objectForm declares no label (asserted), so "Class" is humanizeFieldPath of the path leaf; bare クラス and bare Clase read as a programming class or a CSS class, and the form nests this row under a composite whose own label is now the qualified one. ⇒ qualified with the parent block, copying the same head noun the row above does (LIFECYCLE_HEAD_NOUN): ライフサイクルクラス / Clase de ciclo de vida. ⭐ CONCEPT from the live LifecycleSchema, ⛔ not from zh-CN: `class` is LifecycleClassSchema, a five-member enum (record | audit | telemetry | transient | event) asserted below, i.e. the persistence contract of the object rows — zh-CN independently reached the same qualification (生命周期类别), which is corroboration rather than the source.', }, { population: 'lifecycle', @@ -1152,7 +1164,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // new row label, authored in all three locales. // 660 since the agent form offers `structuredOutput`: one new row label, // authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(660); + // 659 since #21320: the agent form's `lifecycle` row left with its key + // (the conversation state machine was retired — nothing ever read it), + // taking its label — authored in all three locales — out of the catalog. + expect(translated.length, `${locale} positive control`).toBe(659); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index f038cc2b5c7..fe8d75314d2 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2400,10 +2400,6 @@ export const zhCNMetadataForms: NonNullable = label: "记忆", helpText: "长期记忆:按用户保存的提炼笔记,在每次会话前召回,并每隔 reflectionInterval 次已送达的交互由一次反思写入。启用长期记忆时,maxEntries 与 reflectionInterval 为必填。由云端 AI 运行时强制执行。" }, - lifecycle: { - label: "生命周期", - helpText: "定义会话流程的状态机" - }, structuredOutput: { label: "结构化输出", helpText: "代理最终回答的输出契约:JSON 格式、用于校验回答的 JSON Schema、重试、回退格式与转换步骤。由云端 AI 运行时强制执行。" diff --git a/packages/spec/PROTOCOL_MAP.md b/packages/spec/PROTOCOL_MAP.md index f668ca8f77f..0da7fd319dc 100644 --- a/packages/spec/PROTOCOL_MAP.md +++ b/packages/spec/PROTOCOL_MAP.md @@ -64,7 +64,6 @@ This document serves as the **Grand Map** of the ObjectStack specification. It l | File | Status | Description | | :--- | :--- | :--- | -| [`state-machine.zod.ts`](src/automation/state-machine.zod.ts) | ⭐ | **State Machines**. Strict lifecycle transitions and guards. | | [`flow.zod.ts`](src/automation/flow.zod.ts) | ⭐ | **Visual Flow**. Complex orchestration logic (decisions, loops, CRUD). | | [`approval.zod.ts`](src/automation/approval.zod.ts) | ⭐ | **Approval Node**. Flow node config for human approval pauses. | | [`webhook.zod.ts`](src/automation/webhook.zod.ts) | ⭐ | **Webhooks**. Outbound HTTP notification configuration. | diff --git a/packages/spec/api-surface/ai.json b/packages/spec/api-surface/ai.json index 453b8c8a442..6cf1090d8eb 100644 --- a/packages/spec/api-surface/ai.json +++ b/packages/spec/api-surface/ai.json @@ -147,7 +147,6 @@ "SolutionBlueprintSchema (const)", "SolutionBlueprintStrict (type)", "SolutionBlueprintStrictSchema (const)", - "StateNodeConfig (type)", "StructuredOutputConfig (type)", "StructuredOutputConfigParsed (type)", "StructuredOutputConfigSchema (const)", diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 265d3369946..167e4963355 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -19,8 +19,6 @@ "ActionDescriptorSchema (const)", "ActionParadigm (type)", "ActionParadigmSchema (const)", - "ActionRef (type)", - "ActionRefSchema (const)", "ApprovalDecision (const)", "ApprovalDecision (type)", "ApprovalEscalation (type)", @@ -172,8 +170,6 @@ "GetRecordConfig (type)", "GetRecordConfigParsed (type)", "GetRecordConfigSchema (const)", - "GuardRef (type)", - "GuardRefSchema (const)", "HttpConfig (type)", "HttpConfigParsed (type)", "HttpConfigSchema (const)", @@ -231,11 +227,6 @@ "ScriptConfig (type)", "ScriptConfigParsed (type)", "ScriptConfigSchema (const)", - "StateMachineConfig (type)", - "StateMachineSchema (const)", - "StateNode (type)", - "StateNodeConfig (type)", - "StateNodeSchema (const)", "StructuralConditionRefusal (type)", "StructuralConditionRefusalCode (type)", "StructuralConditionValueKind (type)", @@ -247,8 +238,6 @@ "TRY_CATCH_NODE_TYPE (const)", "TimeRelativeTrigger (type)", "TimeRelativeTriggerSchema (const)", - "Transition (type)", - "TransitionSchema (const)", "TryCatchConfig (type)", "TryCatchConfigParsed (type)", "TryCatchConfigSchema (const)", diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 552a545a403..92ad6fc5085 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -132,7 +132,6 @@ "STACK_RUNTIME_MEMBERS (const)", "Skill (type)", "StackDefinitionKey (type)", - "StateNodeConfig (type)", "StoredConversionOptions (type)", "TemplateExpressionInputSchema (const)", "Tool (type)", diff --git a/packages/spec/authorable-defaults/automation.json b/packages/spec/authorable-defaults/automation.json index cb2065d05e7..b2ca7240c42 100644 --- a/packages/spec/authorable-defaults/automation.json +++ b/packages/spec/authorable-defaults/automation.json @@ -56,7 +56,6 @@ "automation/RetryPolicy:maxRetries = 0", "automation/RetryPolicy:maxRetryDelayMs = 30000", "automation/ScreenConfig:mode = \"create\"", - "automation/StateNode:type = \"atomic\"", "automation/TryCatchConfig:errorVariable = \"$error\"", "automation/WaitExecutorConfig:conditionMaxPolls = 0", "automation/WaitExecutorConfig:conditionPollIntervalMs = 30000", diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json index 35c3ebc34b8..aaf40e662e8 100644 --- a/packages/spec/authorable-surface/ai.json +++ b/packages/spec/authorable-surface/ai.json @@ -26,7 +26,7 @@ "ai/Agent:instructions", "ai/Agent:knowledge [RETIRED]", "ai/Agent:label", - "ai/Agent:lifecycle", + "ai/Agent:lifecycle [RETIRED]", "ai/Agent:memory", "ai/Agent:model", "ai/Agent:name", diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 4216fcd8840..b9ed1d8d28f 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -313,20 +313,6 @@ "automation/ScriptConfig:script [RETIRED]", "automation/ScriptConfig:template [RETIRED]", "automation/ScriptConfig:variables [RETIRED]", - "automation/StateMachine:contextSchema", - "automation/StateMachine:description", - "automation/StateMachine:id", - "automation/StateMachine:initial", - "automation/StateMachine:on", - "automation/StateMachine:states", - "automation/StateNode:always", - "automation/StateNode:entry", - "automation/StateNode:exit", - "automation/StateNode:initial", - "automation/StateNode:meta", - "automation/StateNode:on", - "automation/StateNode:states", - "automation/StateNode:type", "automation/SubflowConfig:flowName", "automation/SubflowConfig:input", "automation/SubflowConfig:outputVariable", @@ -336,10 +322,6 @@ "automation/TimeRelativeTrigger:object", "automation/TimeRelativeTrigger:offsetDays", "automation/TimeRelativeTrigger:withinDays", - "automation/Transition:actions", - "automation/Transition:cond", - "automation/Transition:description", - "automation/Transition:target", "automation/TryCatchConfig:catch", "automation/TryCatchConfig:errorVariable", "automation/TryCatchConfig:retry", diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json index 2e35ecc966a..60a681b2136 100644 --- a/packages/spec/declaration-map/automation.json +++ b/packages/spec/declaration-map/automation.json @@ -8,8 +8,6 @@ "ActionDescriptorSchema": "automation/ActionDescriptor", "ActionParadigm": "automation/ActionParadigm", "ActionParadigmSchema": "automation/ActionParadigm", - "ActionRef": "automation/ActionRef", - "ActionRefSchema": "automation/ActionRef", "ApprovalDecision": "automation/ApprovalDecision", "ApprovalEscalation": "automation/ApprovalEscalation", "ApprovalEscalationSchema": "automation/ApprovalEscalation", @@ -93,8 +91,6 @@ "FlowVersionHistorySchema": "automation/FlowVersionHistory", "GetRecordConfig": "automation/GetRecordConfig", "GetRecordConfigSchema": "automation/GetRecordConfig", - "GuardRef": "automation/GuardRef", - "GuardRefSchema": "automation/GuardRef", "HttpConfig": "automation/HttpConfig", "HttpConfigSchema": "automation/HttpConfig", "LoopConfig": "automation/LoopConfig", @@ -117,15 +113,10 @@ "ScreenFieldConfigSchema": "automation/ScreenFieldConfig", "ScriptConfig": "automation/ScriptConfig", "ScriptConfigSchema": "automation/ScriptConfig", - "StateMachineSchema": "automation/StateMachine", - "StateNode": "automation/StateNode", - "StateNodeSchema": "automation/StateNode", "SubflowConfig": "automation/SubflowConfig", "SubflowConfigSchema": "automation/SubflowConfig", "TimeRelativeTrigger": "automation/TimeRelativeTrigger", "TimeRelativeTriggerSchema": "automation/TimeRelativeTrigger", - "Transition": "automation/Transition", - "TransitionSchema": "automation/Transition", "TryCatchConfig": "automation/TryCatchConfig", "TryCatchConfigSchema": "automation/TryCatchConfig", "TryCatchErrorValue": "automation/TryCatchErrorValue", diff --git a/packages/spec/docs-import-surface.baseline.json b/packages/spec/docs-import-surface.baseline.json index e406fbb1793..6a43cacefc2 100644 --- a/packages/spec/docs-import-surface.baseline.json +++ b/packages/spec/docs-import-surface.baseline.json @@ -10,7 +10,6 @@ "api/MetadataImportRequest — no type export", "api/MetadataQueryRequest — no type export", "automation/FlowVariable — no type export", - "automation/StateMachine — no type export", "data/ComparisonOperator — no type export", "data/DataEngineAggregateRequest — no type export", "data/DataEngineCountRequest — no type export", diff --git a/packages/spec/export-origins/ai.json b/packages/spec/export-origins/ai.json index 13b6fab762a..07631f30889 100644 --- a/packages/spec/export-origins/ai.json +++ b/packages/spec/export-origins/ai.json @@ -147,7 +147,6 @@ "SolutionBlueprintSchema": "src/ai/solution-blueprint.zod.ts#SolutionBlueprintSchema (const)", "SolutionBlueprintStrict": "src/ai/solution-blueprint.zod.ts#SolutionBlueprintStrict (type)", "SolutionBlueprintStrictSchema": "src/ai/solution-blueprint.zod.ts#SolutionBlueprintStrictSchema (const)", - "StateNodeConfig": "src/automation/state-machine.zod.ts#StateNodeConfig (type)", "StructuredOutputConfig": "src/ai/agent.zod.ts#StructuredOutputConfig (type)", "StructuredOutputConfigParsed": "src/ai/agent.zod.ts#StructuredOutputConfigParsed (type)", "StructuredOutputConfigSchema": "src/ai/agent.zod.ts#StructuredOutputConfigSchema (const)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 183af79e6bd..1e4a793040a 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -19,8 +19,6 @@ "ActionDescriptorSchema": "src/automation/node-executor.zod.ts#ActionDescriptorSchema (const)", "ActionParadigm": "src/automation/node-executor.zod.ts#ActionParadigm (type)", "ActionParadigmSchema": "src/automation/node-executor.zod.ts#ActionParadigmSchema (const)", - "ActionRef": "src/automation/state-machine.zod.ts#ActionRef (type)", - "ActionRefSchema": "src/automation/state-machine.zod.ts#ActionRefSchema (const)", "ApprovalDecision": "src/automation/approval.zod.ts#ApprovalDecision (type)", "ApprovalEscalation": "src/automation/approval.zod.ts#ApprovalEscalation (type)", "ApprovalEscalationParsed": "src/automation/approval.zod.ts#ApprovalEscalationParsed (type)", @@ -167,8 +165,6 @@ "GetRecordConfig": "src/automation/builtin-node-config.zod.ts#GetRecordConfig (type)", "GetRecordConfigParsed": "src/automation/builtin-node-config.zod.ts#GetRecordConfigParsed (type)", "GetRecordConfigSchema": "src/automation/builtin-node-config.zod.ts#GetRecordConfigSchema (const)", - "GuardRef": "src/automation/state-machine.zod.ts#GuardRef (type)", - "GuardRefSchema": "src/automation/state-machine.zod.ts#GuardRefSchema (const)", "HttpConfig": "src/automation/io-node-config.zod.ts#HttpConfig (type)", "HttpConfigParsed": "src/automation/io-node-config.zod.ts#HttpConfigParsed (type)", "HttpConfigSchema": "src/automation/io-node-config.zod.ts#HttpConfigSchema (const)", @@ -226,11 +222,6 @@ "ScriptConfig": "src/automation/schemaless-node-config.zod.ts#ScriptConfig (type)", "ScriptConfigParsed": "src/automation/schemaless-node-config.zod.ts#ScriptConfigParsed (type)", "ScriptConfigSchema": "src/automation/schemaless-node-config.zod.ts#ScriptConfigSchema (const)", - "StateMachineConfig": "src/automation/state-machine.zod.ts#StateMachineConfig (type)", - "StateMachineSchema": "src/automation/state-machine.zod.ts#StateMachineSchema (const)", - "StateNode": "src/automation/state-machine.zod.ts#StateNode (type)", - "StateNodeConfig": "src/automation/state-machine.zod.ts#StateNodeConfig (type)", - "StateNodeSchema": "src/automation/state-machine.zod.ts#StateNodeSchema (const)", "StructuralConditionRefusal": "src/automation/flow-node-expression-paths.ts#StructuralConditionRefusal (type)", "StructuralConditionRefusalCode": "src/automation/flow-node-expression-paths.ts#StructuralConditionRefusalCode (type)", "StructuralConditionValueKind": "src/automation/flow-node-expression-paths.ts#StructuralConditionValueKind (type)", @@ -242,8 +233,6 @@ "TRY_CATCH_NODE_TYPE": "src/automation/control-flow.zod.ts#TRY_CATCH_NODE_TYPE (const)", "TimeRelativeTrigger": "src/automation/time-relative-trigger.zod.ts#TimeRelativeTrigger (type)", "TimeRelativeTriggerSchema": "src/automation/time-relative-trigger.zod.ts#TimeRelativeTriggerSchema (const)", - "Transition": "src/automation/state-machine.zod.ts#Transition (type)", - "TransitionSchema": "src/automation/state-machine.zod.ts#TransitionSchema (const)", "TryCatchConfig": "src/automation/control-flow.zod.ts#TryCatchConfig (type)", "TryCatchConfigParsed": "src/automation/control-flow.zod.ts#TryCatchConfigParsed (type)", "TryCatchConfigSchema": "src/automation/control-flow.zod.ts#TryCatchConfigSchema (const)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 211e36b2dd6..09a8011ab81 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -131,7 +131,6 @@ "STACK_RUNTIME_MEMBERS": "src/data/authoring-key-lint.ts#STACK_RUNTIME_MEMBERS (const)", "Skill": "src/ai/skill.zod.ts#Skill (type)", "StackDefinitionKey": "src/stack.zod.ts#StackDefinitionKey (type)", - "StateNodeConfig": "src/automation/state-machine.zod.ts#StateNodeConfig (type)", "StoredConversionOptions": "src/conversions/stored.ts#StoredConversionOptions (type)", "TemplateExpressionInputSchema": "src/shared/expression.zod.ts#TemplateExpressionInputSchema (const)", "Tool": "src/ai/tool.zod.ts#Tool (type)", diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json index 76c8a15aa24..a62746a7d2d 100644 --- a/packages/spec/json-schema.manifest/automation.json +++ b/packages/spec/json-schema.manifest/automation.json @@ -5,7 +5,6 @@ "automation/ActionCategory", "automation/ActionDescriptor", "automation/ActionParadigm", - "automation/ActionRef", "automation/ApprovalDecision", "automation/ApprovalEscalation", "automation/ApprovalNodeApprover", @@ -50,7 +49,6 @@ "automation/FlowVariable", "automation/FlowVersionHistory", "automation/GetRecordConfig", - "automation/GuardRef", "automation/HttpConfig", "automation/LoopConfig", "automation/MapConfig", @@ -63,11 +61,8 @@ "automation/ScreenConfig", "automation/ScreenFieldConfig", "automation/ScriptConfig", - "automation/StateMachine", - "automation/StateNode", "automation/SubflowConfig", "automation/TimeRelativeTrigger", - "automation/Transition", "automation/TryCatchConfig", "automation/TryCatchErrorValue", "automation/UpdateRecordConfig", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index a6649238a42..68bf9bc5122 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -928,7 +928,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | hook | model-healthy; label/description KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove — and **`live` since #20299**: `hook` has no registered preview, but the Studio metadata list's default columns and the metadata quick-find draw both keys for every hook, which for a display key is the whole of the claimed effect (the #7131 ruling above). Still not authorWarn'd | | permission | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection). `objects.allowRestore`/`allowPurge` REMOVED 2026-08-26 (#12497, ADR-0049 — the `restore`/`purge` ops never existed; the 2026-07-30 'live' verdict cited only the evaluator pre-mapping, retired in the same batch; `retiredKey` tombstones, keys return with M2 per the #1883 ruling). `rowLevelSecurity.tags` REMOVED 2026-09-27 (#20321, ADR-0049 — graded RETIRE by the maintainer's criterion: no mainstream platform tags a row-level policy; a `retiredKey` tombstone, so the row stays `dead` beside `priority`'s) | | position | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | -| agent | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release); **#18304** re-classifies `tools` `live` -> `dead` with no key added or removed — the row asserted `live` on a key `agent.zod.ts` had tombstoned in protocol 17 (#3894), and it sat that way from the 2026-06 audit because its citation was EXEMPT from resolution rather than resolved (`packages/services/service-ai/...` matched `FOREIGN_PATH_PREFIXES`; the `cloud` realm marker that replaced it in #13309 is equally unresolvable, so no gate could ever fail on it). The load-bearing evidence is local and re-measurable — the `retiredKey` tombstone plus the `agent-tools-to-skills` strip cover authored and stored input respectively, so nothing can carry a value for any consumer to read; the cloud zero-consumer census (cloud @cb8ee7ff, #13272, 2026-09-15) is attributed, not re-taken. `live-elsewhere` is refused for want of a foreign enforcer, not left undeclared | +| agent | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); the autonomy tier is experimental no longer — `structuredOutput` (#21277) and `memory` (#20274) flipped `live` on the cloud AI runtime's reading, and `lifecycle` REMOVED 2026-10-02 (#21320; ruled D on objectstack-ai/cloud#2569 — the conversation state machine was parsed and never read; phases are skills with `triggerConditions`, orchestration is Flow, record transitions are the `state_machine` validation rule; the XState `StateMachineSchema` family, which only it still reached, left with it), so no `agent` row is experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release); **#18304** re-classifies `tools` `live` -> `dead` with no key added or removed — the row asserted `live` on a key `agent.zod.ts` had tombstoned in protocol 17 (#3894), and it sat that way from the 2026-06 audit because its citation was EXEMPT from resolution rather than resolved (`packages/services/service-ai/...` matched `FOREIGN_PATH_PREFIXES`; the `cloud` realm marker that replaced it in #13309 is equally unresolvable, so no gate could ever fail on it). The load-bearing evidence is local and re-measurable — the `retiredKey` tombstone plus the `agent-tools-to-skills` strip cover authored and stored input respectively, so nothing can carry a value for any consumer to read; the cloud zero-consumer census (cloud @cb8ee7ff, #13272, 2026-09-15) is attributed, not re-taken. `live-elsewhere` is refused for want of a foreign enforcer, not left undeclared | | tool | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources | | skill | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | | dataset | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | diff --git a/packages/spec/liveness/agent.json b/packages/spec/liveness/agent.json index febb7851fcf..d95345e24c9 100644 --- a/packages/spec/liveness/agent.json +++ b/packages/spec/liveness/agent.json @@ -85,9 +85,9 @@ "note": "evaluateAgentAccess() — the caller must hold ALL required entries, matched against the union of user.permissions and user.positions; same two enforcement points as access. ⚠ 2026-09-15 (#13272): the previous wording — \"caller must hold ALL required permissions/roles; enforced at the chat route.\" — is FALSIFIED, measured at cloud @cb8ee7ff. `roles` is the alias protocol 17 removed from the producer's envelope (framework #6011), and what can satisfy a required entry is a permission or a POSITION name, never a role. The chat route is one of the two enforcement points, not the only one." }, "lifecycle": { - "status": "experimental", - "evidence": "no runtime reader (StateMachine)", - "note": "aspirational autonomy." + "status": "dead", + "verifiedAt": "2026-10-02", + "note": "REMOVED 2026-10-02 (#21320; ADR-0049 enforce-or-remove, ruled D on objectstack-ai/cloud#2569) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from stored rows and existing sources by the protocol-18 conversion agent-lifecycle-removed. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). It was experimental with no runtime reader: no runtime in this repository read it (measured in this checkout), and the cloud zero-reader census is the ruling card's own (cloud @3aadd908, attributed, not re-taken here). What it reached for: a conversation phase is a skill with its own instructions and tools, selected by triggerConditions (ADR-0064); multi-step orchestration is a Flow (ADR-0019); a record's status transitions are the state_machine validation rule (ADR-0020). Its value schema, the XState StateMachineSchema family (automation/state-machine.zod.ts), had no other authorable consumer and left with it." }, "memory": { "status": "live", diff --git a/packages/spec/liveness/state-counts/agent.md b/packages/spec/liveness/state-counts/agent.md index 924db90445a..579d29c20e7 100644 --- a/packages/spec/liveness/state-counts/agent.md +++ b/packages/spec/liveness/state-counts/agent.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `agent` | 23 | 1 | 0 | 2 | 0 | 26 | +| `agent` | 23 | 0 | 0 | 3 | 0 | 26 | diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 828742e0018..e3b22769d8a 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (204 schemas) +## 3. Schema Inventory by Domain (203 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -90,7 +90,7 @@ here is verifiable from the installed package. | data | 31 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | | api | 31 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage) | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | -| automation | 14 | Flow, Approval, BPMN Interop, Control Flow, State Machine, Webhook, Schedule Organization | +| automation | 13 | Flow, Approval, BPMN Interop, Control Flow, Webhook, Schedule Organization | | shared | 15 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | | ai | 12 | Agent, Build Progress, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | | identity | 5 | Identity, Organization, Position, SCIM, Eval User | diff --git a/packages/spec/scripts/build-skill-references.ts b/packages/spec/scripts/build-skill-references.ts index f11afe72a29..49634bf86d4 100644 --- a/packages/spec/scripts/build-skill-references.ts +++ b/packages/spec/scripts/build-skill-references.ts @@ -146,10 +146,12 @@ const SKILL_MAP: Record = { // shape AS A RECORD-LIFECYCLE DECLARATION (the top-level `workflow` type // and `object.stateMachines` are both gone), and a record's legal // transitions are now a `state_machine` VALIDATION RULE — `data/validation` - // below, already the correct destination. The file's one surviving door is - // `ai/agent.zod.ts`'s `lifecycle`, an objectstack-ai door, and that index - // reaches it transitively. Advertising it here pointed automation authors - // at a shape the platform deliberately removed from their surface. + // below, already the correct destination. The file's one surviving door + // was `ai/agent.zod.ts`'s `lifecycle`, an objectstack-ai door that index + // reached transitively; advertising it here pointed automation authors at a + // shape the platform deliberately removed from their surface. That door + // was tombstoned at #21320 and the file deleted with it, so neither index + // names it now. 'automation/execution.zod.ts', 'automation/webhook.zod.ts', 'automation/node-executor.zod.ts', diff --git a/packages/spec/scripts/export-origins.test.ts b/packages/spec/scripts/export-origins.test.ts index e430285a544..7f05bbd0052 100644 --- a/packages/spec/scripts/export-origins.test.ts +++ b/packages/spec/scripts/export-origins.test.ts @@ -171,10 +171,10 @@ describe('[#4796] export-origins/ — the baseline the export-surface pins compa // Same shape as the real check, against a namespace missing one runtime // export. If this passed, the guard above would be decoration. const withoutOne = { ...real } as Record; - expect('StateMachineSchema' in withoutOne).toBe(true); - delete withoutOne.StateMachineSchema; + expect('FlowSchema' in withoutOne).toBe(true); + delete withoutOne.FlowSchema; expect(runtimeParityOf('./automation', withoutOne).missingAtRuntime).toEqual([ - 'StateMachineSchema', + 'FlowSchema', ]); // …and against one carrying an export the artifact does not know. @@ -184,7 +184,7 @@ describe('[#4796] export-origins/ — the baseline the export-surface pins compa it('the query primitives answer the three questions the pins ask', () => { // 1. does an entry export the name? — the retirement pins' question. - expect(maybeOriginOf('./automation', 'StateMachineSchema')).toBeDefined(); + expect(maybeOriginOf('./automation', 'FlowSchema')).toBeDefined(); expect(maybeOriginOf('./automation', 'NoSuchExportAnywhere')).toBeUndefined(); expect(holdersOf('NoSuchExportAnywhere')).toEqual([]); diff --git a/packages/spec/scripts/lib/skill-map-guards.ts b/packages/spec/scripts/lib/skill-map-guards.ts index 99c90e3e760..bc5d4a416a7 100644 --- a/packages/spec/scripts/lib/skill-map-guards.ts +++ b/packages/spec/scripts/lib/skill-map-guards.ts @@ -22,12 +22,14 @@ * not assumed: * * - the ADR-0087 registry (`src/migrations/entries/retired-defs/**`) names - * defs removed at a major version. `automation/state-machine.zod.ts` is not - * there and correctly so: the def still exists and still parses, through - * `AgentSchema.lifecycle`; - * - the file's own header carries the ADR-0020 retirement in prose, and that - * same header documents the door that SURVIVES — so a prose grep flags a - * file that is live surface for another package; + * defs removed at a major version. `automation/state-machine.zod.ts` was the + * measured case: for as long as `AgentSchema.lifecycle` parsed through it, + * it was not there, and correctly so. It entered the registry + * (`automation/StateMachine`) only when that door was tombstoned and the + * file deleted (#21320) — a removal, never a per-package verdict; + * - the file's own header carried the ADR-0020 retirement in prose, and that + * same header documented the door that SURVIVED — so a prose grep would + * have flagged a file that was live surface for another package; * - the liveness ledger classifies properties, not files. * * The retirement that mattered was PACKAGE-RELATIVE: dead surface for diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index 7a0744cc9b9..d2a03bc93da 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -437,18 +437,28 @@ describe('check:liveness — the evidence-scan population (#13041)', () => { }); it('FAILS when an `experimental` entry cites a repo-local file that is gone', () => { - // The other half of the widening. `agent.lifecycle` is `experimental` and - // its shipped evidence is a prose absence claim ("no runtime reader"), which - // extracts no path at all — so before this change nothing about it could - // ever fail, and after it, a pointer written there is held to the same - // standard as a `live` one. + // The other half of the widening: a pointer written on an `experimental` + // row is held to the same standard as a `live` one. The row is MADE + // `experimental` in the copy rather than found that way, the `planned` + // case's shape above: this case used to borrow `agent.lifecycle`, whose + // shipped evidence was a prose absence claim, and #21320 retired that key + // (`dead` now) — a borrowed `experimental` row is a claim with a timestamp. + // `tool.outputSchema`'s shipped pointers are all cloud-attributed, which + // this check never resolves, so the pointer written below is the run's + // only cause. const root = path.join(tmp, 'experimental-missing-file'); cpSync(LEDGERS, root, { recursive: true }); - setEvidence(root, 'agent', 'lifecycle', `${ROTTED} (rotted by the self-test)`); + const shipped = String(readRow(root, 'tool', 'outputSchema').status); + setStatus(root, 'tool', 'outputSchema', 'experimental'); + if (shipped !== 'experimental') moveCount(root, 'tool', shipped, 'experimental'); + setEvidence(root, 'tool', 'outputSchema', `${ROTTED} (rotted by the self-test)`); + // The control: the row this run judges IS `experimental` in the copy. + expect(readRow(root, 'tool', 'outputSchema').status).toBe('experimental'); const { status, output } = runGate(root); expect(status, output).toBe(1); - expect(output).toContain(`agent/lifecycle → ${ROTTED}`); + expect(output).toContain(`${SCANNED_LABEL} entr(ies) cite a file that is missing from THIS repo`); + expect(output).toContain(`tool/outputSchema → ${ROTTED}`); }); // THE BOUNDARY, and it is a real one rather than an oversight — which is the diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index 9c1c4808cdc..f14a22f9b58 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -61,7 +61,6 @@ "action/params", "action/resultDialog", "agent/guardrails", - "agent/lifecycle", "agent/memory", "agent/model", "agent/planning", diff --git a/packages/spec/src/ai/agent-lifecycle-retirement.test.ts b/packages/spec/src/ai/agent-lifecycle-retirement.test.ts new file mode 100644 index 00000000000..6306647904c --- /dev/null +++ b/packages/spec/src/ai/agent-lifecycle-retirement.test.ts @@ -0,0 +1,378 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An agent's conversation state machine — `agent.lifecycle` — RETIRED as a + * whole key (ADR-0049 enforce-or-remove, ruled D on objectstack-ai/cloud#2569, + * #21320): it was parsed and never read. No runtime moved an agent through a + * declared state or refused an undeclared transition; a conversation phase is a + * skill selected by its `triggerConditions` (ADR-0064), orchestration is a Flow + * (ADR-0019), and a record's status transitions are the `state_machine` + * validation rule (ADR-0020). Its value schema — the XState `StateMachineSchema` + * family in `automation/state-machine.zod.ts` — had no other authorable door + * and left the package with it. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on the strict `AgentSchema`, so the refusal + * carries the prescription and the key's input type is `never` for `tsc`. + * 2. D2 conversion `agent-lifecycle-removed` (step 18), a lossless delete of + * the key from every `agents[]` entry, retired from the load path: a live + * author is refused, a stored or built agent replays clean. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries `ai/Agent:lifecycle`, + * `RETIRED_DEFS_BY_MAJOR[18]` the family's five published defs, and the D3 + * entry `agent-lifecycle-retired` carries the judgement the conversion + * cannot make (which of the three destinations a machine meant). + * 4. The family's exports are gone from every entry that carried them. + * 5. Tree-scoped absence: nothing inside the declared radius still authors an + * agent machine or imports the retired exports. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` — that envelope belongs to the + * authoring door, `defineStack`, which is pinned with its `code` and `status` + * below. Everywhere else: refusal, the issue `code`, the `path` naming the key, + * and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import * as automationEntry from '../automation'; +import { applyConversions, collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack, ObjectStackDefinitionSchema } from '../stack.zod'; +import { AgentSchema, type Agent } from './agent.zod'; + +const MIGRATE_SENTENCE = + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + +const CONVERSION_ID = 'agent-lifecycle-removed'; + +const BASE_AGENT = { + name: 'intake_agent', + label: 'Intake Agent', + role: 'Assistant', + instructions: 'Greet the user, then triage the request.', +} as const; + +/** The shape the retired schema accepted, as an author wrote it. */ +const MACHINE = { + id: 'intake', + initial: 'greeting', + states: { + greeting: { on: { IDENTIFIED: 'triage' } }, + triage: { on: { RESOLVED: 'done' } }, + done: { type: 'final' }, + }, +}; + +/** + * The tombstone's prescription: why, the fix, and the three destinations. + * Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; + * the key-first house convention is asserted on the issue message itself. + */ +const PRESCRIPTION = + /`agent\.lifecycle` was removed in @objectstack\/spec 17\.7\.0 \(ADR-0049 enforce-or-remove\) — no runtime ever read it.*Delete the key\. A phase of a conversation is a skill .*`triggerConditions`.*a Flow.*`state_machine` validation rule/s; + +/** The one refusal `defineStack` raised, as its ADR-0112 envelope. */ +const stackRefusal = (agent: Record) => { + let thrown: unknown; + try { + defineStack({ + manifest: { id: 'com.example.agent-lifecycle', name: 'agent_lifecycle', version: '1.0.0', type: 'app' }, + agents: [agent], + } as never); + } catch (e) { + thrown = e; + } + return thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; +}; + +describe('agent lifecycle retirement — the tombstone, at every door that carries an agent', () => { + it('refuses EVERY value at its path with the prescription — a full machine, an empty one, and non-objects', () => { + for (const lifecycle of [MACHINE, {}, 'draft', null, 0]) { + const r = AgentSchema.safeParse({ ...BASE_AGENT, lifecycle }); + expect(r.success, `lifecycle: ${JSON.stringify(lifecycle)}`).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('invalid_type'); + expect(issues[0].path).toEqual(['lifecycle']); + expect(issues[0].message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issues[0].message.startsWith('`agent.lifecycle` was removed')).toBe(true); + expect(issues[0].message.endsWith(MIGRATE_SENTENCE)).toBe(true); + } + }); + + it('CONTROL — the same agent without the key parses, so the refusal above is the key and nothing else', () => { + const r = AgentSchema.safeParse(BASE_AGENT); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + expect(r.data).not.toHaveProperty('lifecycle'); + }); + + it('the walked shape keeps `lifecycle` as a key — the tombstone stays reachable for its refusal', () => { + expect(Object.keys(AgentSchema.shape)).toContain('lifecycle'); + expect(Object.keys(AgentSchema.shape), 'CONTROL: a live neighbour').toContain('instructions'); + }); + + it('fails tsc at the authoring site: the input type of `lifecycle` is `never`', () => { + const agent: Agent = { + ...BASE_AGENT, + // @ts-expect-error — `lifecycle` is a retiredKey() tombstone: its input type is `never`. + lifecycle: MACHINE, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => AgentSchema.parse(agent)).toThrow(PRESCRIPTION); + }); + + it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope — never strips it', () => { + const refusal = stackRefusal({ ...BASE_AGENT, lifecycle: MACHINE }); + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues).toHaveLength(1); + expect(refusal.issues?.[0]?.path).toEqual(['agents', 0, 'lifecycle']); + expect(refusal.issues?.[0]?.message).toMatch(PRESCRIPTION); + }); +}); + +describe('agent lifecycle retirement — the D2 conversion', () => { + it('a STORED agent row carrying the key replays clean through the rehydration seam', () => { + const notices: Array<{ conversionId?: string; path?: string; from?: string; to?: string }> = []; + const rehydrated = applyConversionsToStoredItem('agent', { ...BASE_AGENT, lifecycle: MACHINE }, { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string; from?: string; to?: string }), + }) as Record; + expect(notices.map((n) => [n.conversionId, n.path, n.from, n.to])).toEqual([ + [CONVERSION_ID, 'agents[0].lifecycle', 'lifecycle', '(removed)'], + ]); + // CONTROL: every live key on the same row survives byte-for-byte. + expect(rehydrated).toEqual(BASE_AGENT); + // And the rehydrated row is exactly what the write door accepts now. + expect(AgentSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('a persisted artifact is REFUSED at the boot door before the conversion and ACCEPTED after it', () => { + const artifact = () => ({ agents: [{ ...BASE_AGENT, lifecycle: MACHINE }] }); + const before = ObjectStackDefinitionSchema.safeParse(artifact()); + expect(before.success).toBe(false); + expect(JSON.stringify(before.error?.issues ?? [])).toContain('`agent.lifecycle` was removed'); + const after = ObjectStackDefinitionSchema.safeParse(applyConversions(artifact(), { includeRetired: true })); + expect(after.success, JSON.stringify(after.error?.issues ?? [])).toBe(true); + }); + + it('touches only agents: an OBJECT\'s ADR-0057 `lifecycle` block of the same name rides through untouched', () => { + const objectLifecycle = { class: 'telemetry', retention: { maxAge: '30d' } }; + const input = { + objects: [{ name: 'probe_event', lifecycle: objectLifecycle }], + agents: [{ ...BASE_AGENT, lifecycle: MACHINE }], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(1); + expect((stack.objects as Array>)[0]!.lifecycle).toBe(objectLifecycle); + }); + + it('is idempotent by construction, and leaves an agent without the key untouched by reference', () => { + const input = { agents: [{ ...BASE_AGENT, lifecycle: MACHINE }, { ...BASE_AGENT, name: 'plain_agent' }] }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(1); + expect((stack.agents as unknown[])[1]).toBe(input.agents[1]); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const input = { agents: [{ ...BASE_AGENT, lifecycle: MACHINE }] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); + + it('is registered under major 18: the exact key, the five defs, the chain step, and one D3 entry', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ai/Agent:lifecycle'); + for (const def of ['StateMachine', 'StateNode', 'Transition', 'ActionRef', 'GuardRef']) { + expect(RETIRED_DEFS_BY_MAJOR[18], def).toContain(`automation/${def}`); + } + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.surface).toBe('agent.lifecycle'); + const d3 = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === 'agent-lifecycle-retired'); + expect(d3).toHaveLength(1); + expect(d3[0]!.conversionIds).toEqual([CONVERSION_ID]); + expect(d3[0]!.reason).toContain(`\`${CONVERSION_ID}\``); + }); +}); + +describe('agent lifecycle retirement — the StateMachineSchema family left the package', () => { + it('`@objectstack/spec/automation` exports none of the family at runtime', () => { + const names = Object.keys(automationEntry); + for (const retired of ['StateMachineSchema', 'StateNodeSchema', 'TransitionSchema', 'ActionRefSchema', 'GuardRefSchema']) { + expect(names, `./automation must not export ${retired}`).not.toContain(retired); + } + // CONTROL: the entry is the real one, not an empty namespace. + expect(names).toContain('FlowSchema'); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types `lifecycle` `never` on +// `Agent`, and the family's exports are gone, so every TYPED authoring site and +// every typed import fails to compile. The residue is what `tsc` never judges: +// JSON, YAML, MD/MDX code fences, untyped `.js`, and TS literals typed `any` / +// `unknown`. This walk covers that residue across the five repo roots +// `scripts/cross-package-test-inputs.mjs` already declares for +// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' +// own `src/` trees, declared there as `examples/*/src/**/*.ts`. +// +// Two matchers, each judging an AUTHORING SHAPE, never a mention: +// - a `lifecycle` key whose object value holds `initial` or `states` before +// any nested brace (TS / JS / JSON, and YAML block form). `lifecycle` alone +// is far too common a word — an object's ADR-0057 data-lifecycle block +// (`class` / `retention` / `ttl` / `storage`) shares the name and none of +// these keys, which the control below asserts; +// - an `import` / `export … from` of a retired family export from an +// `@objectstack/spec` specifier. Only the distinctive names are judged — +// the bare `Transition` / `ActionRef` / `GuardRef` / `StateNode` type +// names are too generic to own across the tree, and a typed import of +// them already fails `tsc`. +// Inline code is prose and is stripped before judging. The bound, stated: a +// machine assembled by spread or under computed keys, and `docs/**`, +// `.claude/**`, `.github/**` and the repo-root files are outside what this +// walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors an agent machine or imports the family', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const MACHINE_KEYS = '(initial|states)'; + const RETIRED_EXPORTS = + '(StateMachineSchema|StateNodeSchema|TransitionSchema|ActionRefSchema|GuardRefSchema|StateMachineConfig|StateNodeConfig)'; + const AUTHORING = [ + // TS / JS / JSON, block or inline: the key, then an object whose own keys + // (before any nested brace) include a machine key. + new RegExp(`(^|[^\\w.$])["']?lifecycle["']?\\s*:\\s*\\{[^{}]*?(^|[^\\w.$])["']?${MACHINE_KEYS}["']?\\s*:`, 'm'), + // YAML block form: the key on its own line, a machine key indented below + // it among its siblings. + new RegExp(`^[ \\t]*(-[ \\t]+)?lifecycle[ \\t]*:[ \\t]*\\r?\\n(?:[ \\t]+[^\\s][^\\n]*\\r?\\n)*?[ \\t]+${MACHINE_KEYS}[ \\t]*:`, 'm'), + // An import or re-export naming a retired export from the spec package. + new RegExp(`\\b(import|export)\\s+(type\\s+)?\\{[^}]*\\b${RETIRED_EXPORTS}\\b[^}]*\\}\\s*from\\s*['"]@objectstack/spec`, 'm'), + ]; + + /** + * Inline code spans are prose — the house style `check:doc-authoring` enforces + * — so stripping single-backtick spans separates "the retirement kit + * describing what it removed" from "a source still writing it". + * Newline-bounded: a fenced block's content is NOT stripped. + */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => { + const stripped = stripInlineCode(text); + for (const re of AUTHORING) { + const m = re.exec(stripped); + if (m) return m; + } + return null; + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its refusal and its conversion. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement agent on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is the Zod tree. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention and a neighbour (anti-vacuity)', () => { + // Offenders — the retired shapes, in each syntax the walk reads. + expect(judge("defineAgent({ name: 'a', lifecycle: { id: 'm', initial: 'idle', states: {} } })")).not.toBeNull(); + expect(judge(" lifecycle: {\n id: 'bot',\n initial: 'idle',\n states: {\n")).not.toBeNull(); + expect(judge('{ "lifecycle": { "id": "m", "states": { "idle": {} } } }')).not.toBeNull(); + expect(judge('agents:\n - name: a\n lifecycle:\n id: m\n initial: idle\n')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\nimport { StateMachineSchema } from '@objectstack/spec/automation';\n```\n")).not.toBeNull(); + expect(judge("import type { StateNodeConfig } from '@objectstack/spec';")).not.toBeNull(); + expect(judge("export { TransitionSchema } from '@objectstack/spec/automation';")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge("the `lifecycle: { initial: 'idle' }` block was retired")).toBeNull(); + expect(judge("lifecycle: { class: 'telemetry', ttl: { field: 'expires_at', expireAfter: '30d' } }")).toBeNull(); + expect(judge(' lifecycle:\n class: telemetry\n storage:\n strategy: rotation\n')).toBeNull(); + expect(judge("lifecycle: retiredKey(LIFECYCLE_RETIRED),")).toBeNull(); + expect(judge("const s = { lifecycle: 1, nested: { initial: 'x' } };")).toBeNull(); + expect(judge("import { StateMachineValidationSchema } from '@objectstack/spec/data';")).toBeNull(); + expect(judge("import { StateMachineSchema } from './local-machine';")).toBeNull(); + }); + + it('no agent machine authoring or retired import survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree, and the example apps' + // sources were really read. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'an agent machine authoring or a retired import means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ai/agent.form.ts b/packages/spec/src/ai/agent.form.ts index a9eb3a520eb..dc10c33bdb7 100644 --- a/packages/spec/src/ai/agent.form.ts +++ b/packages/spec/src/ai/agent.form.ts @@ -41,7 +41,8 @@ export const agentForm = defineForm({ { field: 'model', type: 'composite', helpText: 'AI model configuration (provider, model name, temperature, etc.)' }, { field: 'planning', type: 'composite', helpText: 'Autonomous reasoning configuration: the maximum number of reasoning iterations before the agent stops (1–100, default 10).' }, { field: 'memory', type: 'composite', helpText: 'Long-term memory: distilled notes kept per user, recalled before each conversation and written by a reflection every reflectionInterval delivered interactions. When long-term memory is enabled, maxEntries and reflectionInterval are required. Enforced by the cloud AI runtime.' }, - { field: 'lifecycle', type: 'composite', helpText: 'State machine defining conversation flow' }, + // `lifecycle` input removed with the key (#21320): the conversation state + // machine was parsed and never read — a form for it was false compliance. { field: 'structuredOutput', type: 'composite', helpText: "Output contract for the agent's final answer: JSON format, the JSON Schema it is checked against, retries, fallback format and transform steps. Enforced by the cloud AI runtime." }, ], }, diff --git a/packages/spec/src/ai/agent.test.ts b/packages/spec/src/ai/agent.test.ts index d2cc2bbd70e..35920b85dc4 100644 --- a/packages/spec/src/ai/agent.test.ts +++ b/packages/spec/src/ai/agent.test.ts @@ -396,26 +396,9 @@ Be precise, data-driven, and clear in your explanations.`, expect(() => AgentSchema.parse(agent)).not.toThrow(); }); - it('should valid agent with lifecycle state machine', () => { - const agentWithLifecycle = { - name: 'approval_bot', - label: 'Approval Bot', - role: 'Approver', - instructions: 'Approve if valid', - lifecycle: { - id: 'bot_lifecycle', - initial: 'idle', - states: { - idle: { on: { TASK: 'working' } }, - working: { on: { DONE: 'idle' } } - } - } - }; - - const result = AgentSchema.parse(agentWithLifecycle); - expect(result.lifecycle).toBeDefined(); - expect(result.lifecycle?.initial).toBe('idle'); - }); + // The `lifecycle` state-machine case that stood here pinned a key nothing + // ever read. It is REFUSED now (#21320) — the tombstone, the conversion and + // the tree-scoped absence are pinned in `agent-lifecycle-retirement.test.ts`. }); describe('Autonomous Reasoning', () => { diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts index 99be77b247a..80b6593b395 100644 --- a/packages/spec/src/ai/agent.zod.ts +++ b/packages/spec/src/ai/agent.zod.ts @@ -4,7 +4,6 @@ import { z } from 'zod'; import { enumWithRetiredValues, retiredKey } from '../shared/retired-key'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { StateMachineSchema } from '../automation/state-machine.zod'; import { lazySchema } from '../shared/lazy-schema'; import { aiJsonSchemaSlot } from '../shared/ai-json-schema-slot'; import { strictObject } from '../shared/strict-object'; @@ -338,7 +337,37 @@ 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.'), + + /** + * [REMOVED — #21320] The agent conversation state machine. ADR-0049 + * enforce-or-remove, ruled D (retire) on objectstack-ai/cloud#2569: it was + * parsed and never read — no runtime in this repository or in cloud moved an + * agent through a declared state or refused an undeclared transition, and + * every enforcement design measured there was a subset statechart + * interpreter beside Flow, the two-engine shape ADR-0020 already rejected. + * What it reached for is served elsewhere: a phase of a conversation is a + * skill with its own `instructions` and `tools`, selected by + * `triggerConditions` (ADR-0064); multi-step process orchestration is a Flow + * (ADR-0019); a record's status transitions are the `state_machine` + * validation rule (ADR-0020). + * + * Tombstoned rather than deleted, for the two channels `retiredKey()` gives + * (`shared/retired-key.ts`): `tsc` refuses the key (its input type is + * `never`), and the parse answers with the prescription rather than a bare + * unrecognized-key error. This was the last authorable door to the XState + * `StateMachineSchema` (`automation/state-machine.zod.ts`), which left with + * it. The ADR-0087 conversion `agent-lifecycle-removed` deletes the key from + * stored rows and existing sources. + */ + lifecycle: retiredKey( + '`agent.lifecycle` was removed in @objectstack/spec 17.7.0 (ADR-0049 enforce-or-remove) — ' + + 'no runtime ever read it: no agent moved through a declared state and no transition was ' + + 'ever refused. Delete the key. A phase of a conversation is a skill with its own ' + + '`instructions` and `tools`, selected by its `triggerConditions` (ADR-0064); multi-step ' + + 'process orchestration is a Flow (ADR-0019); a record\'s status transitions are a ' + + '`state_machine` validation rule on the object (ADR-0020). ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** * ADR-0063 §1 / ADR-0064 — the product surface this agent IS. The kernel diff --git a/packages/spec/src/ai/index.ts b/packages/spec/src/ai/index.ts index 9101b5cfd1a..2e55eb769c1 100644 --- a/packages/spec/src/ai/index.ts +++ b/packages/spec/src/ai/index.ts @@ -39,9 +39,10 @@ export * from './solution-blueprint.zod'; export * from './build-progress.zod'; // [#12414] entry-nameability: these factories' return types expand to mention -// `/data`'s `FilterCondition` and `/automation`'s `StateNodeConfig` — both -// public on their own subpaths but not nameable from `/ai`. Same invariant -// (maintainer ruling recorded in commit ece4dad31), same repair: re-export from the -// declaring module. +// `/data`'s `FilterCondition` — public on its own subpath but not nameable +// from `/ai`. Same invariant (maintainer ruling recorded in commit ece4dad31), +// same repair: re-export from the declaring module. The second name this +// carried, `/automation`'s `StateNodeConfig`, left the package with the +// `StateMachineSchema` family (#21320): `defineAgent`'s return type mentioned +// it only through the tombstoned `agent.lifecycle`. export type { FilterCondition } from '../data/filter.zod'; -export type { StateNodeConfig } from '../automation/state-machine.zod'; diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index a6599d36d23..05fc78c7063 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2611,7 +2611,9 @@ export const GetEffectivePermissionsResponseSchema = lazySchema(() => z.object({ // `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among // routes that never existed). The capability the wrappers promised is live // elsewhere: record state machines are enforced by the `state_machine` -// validation rule (`StateMachineSchema` stays authorable on the object), +// validation rule (a flat `{ from: [to] }` transition table in the object's +// `validations`, `data/validation.zod.ts` — the XState `StateMachineSchema` was +// never authorable on the object after ADR-0020 and left the package in #21320), // approvals are first-class flow nodes on the approvals runtime (ADR-0019 — // decisions via `POST /approvals/requests/:id/{approve,reject}`), and // record-triggered automation is lifecycle hooks + `record_change` flows. diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 5729a6f62ef..3321d51425f 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -66,7 +66,13 @@ export * from './schedule-organization.zod'; // `ui/offline.zod.ts` under ADR-0049. The connector's // `ConnectorConflictResolution` left with `syncConfig`; no domain may re-adopt // the bare name (pinned in `sync-retirement.test.ts`). -export * from './state-machine.zod'; +// `./state-machine.zod` REMOVED (#21320, ADR-0049): the XState-style +// `StateMachineSchema` family (`StateNodeSchema`, `TransitionSchema`, +// `ActionRefSchema`, `GuardRefSchema` and their types) left with its last +// authorable door, the tombstoned `agent.lifecycle`. ADR-0020 had already +// retired it as a record-lifecycle declaration — a record's legal transitions +// are the `state_machine` validation rule (`data/validation.zod.ts`), and +// orchestration is Flow (ADR-0019). export * from './node-executor.zod'; export * from './flow-node-expression-paths'; export * from './flow-node-config-refusals'; diff --git a/packages/spec/src/automation/state-machine.test.ts b/packages/spec/src/automation/state-machine.test.ts deleted file mode 100644 index f174987be30..00000000000 --- a/packages/spec/src/automation/state-machine.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - StateMachineSchema, - StateNodeSchema, - TransitionSchema, - ActionRefSchema, - GuardRefSchema, -} from './state-machine.zod'; -import { AgentSchema } from '../ai/agent.zod'; -import { formatZodError } from '../shared/error-map.zod'; -import { - EXPORT_ENTRY_POINTS, - exportNamesOf, - holdersOf, - maybeOriginOf, - originFileOf, - originOf, - originsOf, - runtimeParityOf, -} from '../../scripts/lib/export-origins-testkit'; - -describe('StateMachineSchema', () => { - it('should validate a simple state machine', () => { - const machine = { - id: 'simple_flow', - initial: 'start', - states: { - start: { - on: { - NEXT: 'end', - }, - }, - end: { - type: 'final', - }, - }, - }; - - const result = StateMachineSchema.parse(machine); - expect(result.id).toBe('simple_flow'); - expect(result.initial).toBe('start'); - }); - - it('should validate complex state machine with guards and actions', () => { - const machine = { - id: 'approval_flow', - initial: 'draft', - states: { - draft: { - on: { - SUBMIT: { - target: 'pending', - cond: 'isComplete', - actions: ['notifyManager'], - }, - }, - }, - pending: { - on: { - APPROVE: 'approved', - REJECT: 'rejected', - }, - meta: { - aiInstructions: 'Review carefully', - }, - }, - approved: { type: 'final' }, - rejected: { type: 'final' }, - }, - }; - - expect(() => StateMachineSchema.parse(machine)).not.toThrow(); - }); - - it('should validate hierarchical states', () => { - const machine = { - id: 'nested_flow', - initial: 'active', - states: { - active: { - initial: 'running', - states: { - running: { - on: { PAUSE: 'paused' }, - }, - paused: { - on: { RESUME: 'running' }, - }, - }, - on: { STOP: 'stopped' }, - }, - stopped: { type: 'final' }, - }, - }; - - expect(() => StateMachineSchema.parse(machine)).not.toThrow(); - }); - - it('should validate parallel states', () => { - const machine = { - id: 'parallel_flow', - initial: 'processing', - states: { - processing: { - type: 'parallel', - states: { - upload: { - initial: 'pending', - states: { - pending: { on: { START: 'uploading' } }, - uploading: { on: { DONE: 'uploaded' } }, - uploaded: { type: 'final' }, - }, - }, - validate: { - initial: 'pending', - states: { - pending: { on: { CHECK: 'checking' } }, - checking: { on: { PASS: 'passed' } }, - passed: { type: 'final' }, - }, - }, - }, - }, - }, - }; - - expect(() => StateMachineSchema.parse(machine)).not.toThrow(); - }); - - it('should reject invalid identifier', () => { - const machine = { - id: 'Invalid Name', - initial: 'start', - states: { - start: {}, - }, - }; - - expect(() => StateMachineSchema.parse(machine)).toThrow(); - }); -}); - -// ─── [#4001 批 10] unknown keys are rejected, not stripped ────────────────── -// -// The ledger carried these six shapes as `authorable (p)` — provisional. The -// `(p)` had to be resolved before tightening (verify-before-tightening), and -// resolving it was not a formality: ADR-0020 RETIRED this XState shape as a -// record-lifecycle declaration, so the top-level `workflow` metadata type and -// `object.stateMachines` are both gone and a record's transitions live on the -// `state_machine` VALIDATION RULE instead. Had those been the only doors, this -// file would be dead surface and the correct action would have been to fix its -// ledger class, not to close it. -// -// The surviving door is `ai/agent.zod.ts`'s `lifecycle` — and `agent` is a -// registered metadata type, so `defineStack({ agents })`, the meta REST write -// and the Studio agent form all `.parse()` through here. The first test below -// IS that verification, kept executable rather than written down. -describe('[#4001] the authoring door — agent.lifecycle', () => { - const agent = (lifecycle: unknown) => ({ - name: 'probe_agent', label: 'Probe', role: 'assistant', instructions: 'do things', lifecycle, - }); - - it('a well-formed lifecycle still parses through AgentSchema', () => { - const parsed = AgentSchema.parse(agent({ - id: 'probe_machine', - initial: 'draft', - states: { - draft: { on: { APPROVE: 'done' }, meta: { aiInstructions: 'Review carefully' } }, - done: { type: 'final' }, - }, - })); - expect((parsed.lifecycle as { states: Record }).states).toHaveProperty('draft'); - }); - - // The measurement that resolved `(p)` to `authorable`. Before this batch the - // parse below SUCCEEDED, returning - // { id, initial, states: { draft: { type: 'atomic', meta: {} }, done: … } } - // — `stats` gone, both `meta` keys gone, and `onn` (one keystroke from `on`) - // gone with every transition the author declared. A machine whose whole job - // is to deny undeclared transitions had become one with NO transitions, and - // reported success. All three depths must now refuse. - it('refuses undeclared keys at all three depths, through the agent door', () => { - const result = AgentSchema.safeParse(agent({ - id: 'probe_machine', - initial: 'draft', - stats: { runs: 3 }, - states: { - draft: { onn: { APPROVE: 'done' }, meta: { labell: 'Draft', owner: 'ops' } }, - done: { type: 'final' }, - }, - })); - expect(result.success).toBe(false); - - const messages = result.error!.issues.map((i) => i.message).join('\n'); - // machine level, state-node level, meta level — and each names its own - // surface, so the author is told WHICH of the three nested shapes refused. - expect(messages).toContain('this state machine'); - expect(messages).toContain('this state node'); - expect(messages).toContain('this state node meta block'); - // Every one of the three carries a usable rename. - expect(messages).toContain('`stats` → `states`'); - expect(messages).toContain('`onn` → `on`'); - expect(messages).toContain('`labell` → `label`'); - }); -}); - -describe('[#4001] state-machine strictness — per shape', () => { - it('StateMachine: `context` gets a wrong-layer prescription, NOT a rename', () => { - const result = StateMachineSchema.safeParse({ - id: 'mm', initial: 's', states: { s: {} }, context: { amount: 0 }, - }); - expect(result.success).toBe(false); - const message = result.error!.issues[0]!.message; - // XState's `context` holds initial VALUES; `contextSchema` declares a - // SHAPE. A rename here would tell the author to write their values where a - // schema goes — so the entry states both halves and offers no rename. - expect(message).toContain('INITIAL VALUES'); - expect(message).toContain('contextSchema'); - expect(message).not.toContain('Did you mean'); - }); - - it('StateNode: `transitions` is pointed at `on`, and at the OTHER declaration', () => { - const result = StateNodeSchema.safeParse({ transitions: { draft: ['done'] } }); - expect(result.success).toBe(false); - const message = result.error!.issues[0]!.message; - expect(message).toContain('`on`'); - // The word `transitions` is not invented — it is the key on the object-level - // `state_machine` validation rule, which is the neighbouring declaration an - // author most plausibly arrives from. Saying so is the whole value. - expect(message).toContain('validations[].transitions'); - }); - - it('Transition: `guard` → `cond` needs the alias — edit distance cannot reach it', () => { - const result = TransitionSchema.safeParse({ target: 'approved', guard: 'isManager' }); - expect(result.success).toBe(false); - expect(result.error!.issues[0]!.message).toContain('`guard` → `cond`'); - }); - - it('Transition: a plain typo still rides the edit-distance fallback', () => { - const result = TransitionSchema.safeParse({ target: 'approved', action: ['notify'] }); - expect(result.success).toBe(false); - expect(result.error!.issues[0]!.message).toContain('`action` → `actions`'); - }); - - // `meta` was the one shape in this file that had to be argued rather than - // measured-and-closed: XState treats `meta` as an open bag, and the #4909 - // precedent says a genuinely-open slot should SAY `.passthrough()`. It is - // closed here because the hand-written `StateNodeConfig` type declares - // exactly these four keys (passthrough would open the Zod while `tsc` stayed - // shut), because nothing in the repo reads any `meta` key, and because the - // pre-existing behaviour was not openness but strip — an author's `meta` - // arrived as `{}`. There was no openness to preserve. - it('StateNode.meta is CLOSED — the four declared keys and no bag', () => { - expect(() => StateNodeSchema.parse({ - meta: { label: 'L', description: 'D', color: '#fff', aiInstructions: 'A' }, - })).not.toThrow(); - - const result = StateNodeSchema.safeParse({ meta: { label: 'L', tooltip: 'T' } }); - expect(result.success).toBe(false); - expect(result.error!.issues[0]!.message).toContain('this state node meta block'); - }); -}); - -// The union branches behave measurably differently from the plain shapes, and -// the difference is zod's, not this file's. Pinned in BOTH directions so the -// next reader does not "fix" the quietness by reopening the branch, and so a -// future improvement to the flattening consumers is noticed here first. -describe('[#4001] ActionRef / GuardRef — strict inside a union', () => { - it.each([ - ['ActionRef', ActionRefSchema, 'this action reference'], - ['GuardRef', GuardRefSchema, 'this guard reference'], - ] as const)('%s: the object branch rejects an unknown key', (_label, schema, surface) => { - // The string branch is untouched — it has no keys to be strict about. - expect(() => schema.parse('isManager')).not.toThrow(); - expect(() => schema.parse({ type: 'log', params: { a: 1 } })).not.toThrow(); - - const result = schema.safeParse({ type: 'log', args: { a: 1 } }); - expect(result.success).toBe(false); - - // The union raises ONE issue, and its own message is the bare zod string. - const issue = result.error!.issues[0] as { code: string; message: string; errors?: unknown[][] }; - expect(issue.code).toBe('invalid_union'); - expect(issue.message).toBe('Invalid input'); - - // …with the real prescription intact one level down. This is the assertion - // that keeps "quieter" from decaying into "silent". - const nested = (issue.errors ?? []).flat() as Array<{ message: string }>; - const prose = nested.map((i) => i.message).join('\n'); - expect(prose).toContain(surface); - expect(prose).toContain('`args`'); - }); - - // Anti-vacuity for the claim above: a PLAIN strictObject in this same file - // surfaces its prose through the same formatter, so anything the union - // renders differently is a property of the union and not of the curation. - // - // ⚠️ THIS PIN WAS FLIPPED BY #4971, exactly as 批 10 predicted it would be. - // It used to assert the second half was flattened away — `formatZodError` - // mapped `error.issues` and never descended into `invalid_union.errors`, so - // the curated rejection stopped at `✗ (root): Invalid input` on the CLI path - // while the REST body and `ZodError.message` carried it fine. The formatter - // now expands the union's most informative branch, and the two halves say - // the same thing again. What is still union-shaped is the extra `Invalid - // input` line above the prescription: zod raises one issue for the union, - // and that line is what says "no branch matched". - it('CONTROL — union and non-union shapes both render their prescription through formatZodError', () => { - const plain = TransitionSchema.safeParse({ target: 't', guard: 'isX' }); - expect(formatZodError(plain.error!)).toContain('`guard` → `cond`'); - - const union = ActionRefSchema.safeParse({ type: 'log', args: { a: 1 } }); - const formatted = formatZodError(union.error!); - expect(formatted).toContain('Invalid input'); - expect(formatted).toContain('this action reference'); - expect(formatted).toContain('`args`'); - // The string branch's "expected string, received object" is not a - // prescription and is not printed — see #4971's branch selection. - expect(formatted).not.toContain('expected string'); - }); -}); - -// ─── [#4658] `EventSchema` is gone from ./automation — dual-source C6 ──────── -// -// `./automation` and `./kernel` both exported an `EventSchema`, for two -// declarations whose key sets did not even intersect: -// -// automation/state-machine.zod.ts (removed) → `{ type, schema }` — an -// XState-style signal DECLARATION ("which events does this machine -// accept"). An orphan: `StateMachineSchema` names event types as the -// record keys of `on:`, and no repo imported it (objectstack / cloud / -// objectui, import-statement-level scan). -// kernel/events/core.zod.ts → `{ id?, name, payload, metadata }` — an -// event-bus ENVELOPE (an emitted event instance). -// -// Converging them would have declared a signal definition to be an envelope — -// a false statement in the contract — so the orphan was deleted instead -// (maintainer ruling on #4658; ledger #4535 C6). The kernel-side analogue of a -// signal *declaration* already exists: `EventTypeDefinitionSchema`, same file. -// -// #4642 established that a compile-time conditional-type pin in this package -// was a no-op until #5286 (tsconfig excluded `**/*.test.ts`; vitest never enables -// `typecheck`), so the load-bearing pin is the compiler-API test below, with -// anti-vacuity guards; sabotage-verified in the PR (re-adding the export -// turns it red). -describe('[#4658] `EventSchema` is not exported from ./automation', () => { - it('resolves the export surface: no entry but ./kernel declares `EventSchema`', () => { - // Anti-vacuity: the baseline must cover the real surface. (This used to - // enumerate package.json's exports map and build its own `ts.createProgram` - // right here; `export-origins/` IS that resolution, computed once at build - // time and checked in — #4796.) - expect(EXPORT_ENTRY_POINTS).toContain('./automation'); - expect(EXPORT_ENTRY_POINTS).toContain('./kernel'); - expect(EXPORT_ENTRY_POINTS.length).toBeGreaterThan(10); - - // 1. The removed side: `./automation` still has a non-trivial surface — - // so the `not.toContain` cannot pass by resolving nothing — and no - // longer names `EventSchema`, while its surviving neighbours stand. - const automationNames = exportNamesOf('./automation'); - expect(automationNames.length, './automation must export a non-trivial surface').toBeGreaterThan(50); - expect(automationNames).not.toContain('EventSchema'); - expect(automationNames).toContain('StateMachineSchema'); - expect(automationNames).toContain('TransitionSchema'); - - // 2. The surviving side: `./kernel` still exports the envelope const and - // its inferred type, declared in kernel/events/core.zod.ts. - expect(maybeOriginOf('./kernel', 'EventSchema'), './kernel must export `EventSchema`').toBeDefined(); - expect(originFileOf('./kernel', 'EventSchema')).toBe('src/kernel/events/core.zod.ts'); - expect(exportNamesOf('./kernel')).toContain('Event'); - - // 3. Uniqueness — the dual-source pin proper: across EVERY public entry, - // an export named `EventSchema` must resolve to that ONE declaration. - expect(originsOf('EventSchema')).toEqual([originOf('./kernel', 'EventSchema')]); - const holders = holdersOf('EventSchema'); - expect(holders).toContain('./kernel'); - expect(holders).not.toContain('./automation'); - }); - - it('keeps the runtime namespaces consistent with the compiler view', async () => { - const automation = await import('./index'); - const kernel = await import('../kernel/index'); - expect('EventSchema' in automation).toBe(false); - expect('EventSchema' in kernel).toBe(true); - - // The compiler-free half of the baseline's freshness guard: a stale or - // hand-edited `export-origins/` that moved or invented a runtime export is - // caught here, in `pnpm test`, without waiting for `check:export-origins`. - expect(runtimeParityOf('./automation', automation)).toEqual({ missingAtRuntime: [], missingFromArtifact: [] }); - expect(runtimeParityOf('./kernel', kernel)).toEqual({ missingAtRuntime: [], missingFromArtifact: [] }); - - // What the name now unambiguously means: an emitted event INSTANCE. - expect(() => - kernel.EventSchema.parse({ - name: 'user.created', - payload: { id: 'u1' }, - metadata: { source: 'test', timestamp: '2026-08-03T00:00:00.000Z' }, - }), - ).not.toThrow(); - - // The removed side's shape — a signal DECLARATION — is not what the - // surviving schema accepts: the two concepts were never converged. - expect(() => kernel.EventSchema.parse({ type: 'APPROVE' })).toThrow(); - }); - - it('still authors state-machine event types as `on:` record keys — the surface the orphan never was', () => { - const machine = { - id: 'c6_pin', - initial: 'draft', - states: { - draft: { on: { APPROVE: 'approved' } }, - approved: { type: 'final' }, - }, - }; - expect(() => StateMachineSchema.parse(machine)).not.toThrow(); - }); -}); diff --git a/packages/spec/src/automation/state-machine.zod.ts b/packages/spec/src/automation/state-machine.zod.ts deleted file mode 100644 index c91f1315db3..00000000000 --- a/packages/spec/src/automation/state-machine.zod.ts +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * @module automation/state-machine - * - * XState-inspired State Machine Protocol — hierarchical states, guarded - * transitions, entry/exit actions. Used to declare strict business-logic - * constraints and lifecycle management, so an AI author cannot "hallucinate" a - * transition the machine never declared. - * - * ## Where this is authored — the question #4001 had to answer first - * - * The ledger carried these shapes as `authorable (p)` — provisional, because - * nobody had checked. Checking matters here more than usual, because - * [ADR-0020](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0020-state-machine-converge-and-enforce.md) - * **retired this shape as a record-lifecycle declaration**: the top-level - * `workflow` metadata type and `object.stateMachines` are both gone, and a - * record's legal transitions are declared as a `state_machine` **validation - * rule** (`data/validation.zod.ts`, a flat `{ from: [to] }` table — closed - * since #4001 batch 3b). A schema whose only doors were those two would be - * dead surface, and the campaign's own rule is that dead surface gets its - * ledger class corrected, not tightened. - * - * One door survives, and it is an authoring door: **`ai/agent.zod.ts`'s - * `lifecycle`** is `StateMachineSchema`, and `agent` is a registered metadata - * type — so `defineStack({ agents })`, `POST /api/v1/meta/types/agent` and the - * Studio agent form all reach this file through `AgentSchema.parse()`. Verified - * by parse, not by reading: before this change, - * - * ```ts - * AgentSchema.parse({ …, lifecycle: { - * id: 'probe_machine', initial: 'draft', stats: { runs: 3 }, - * states: { draft: { onn: { APPROVE: 'done' }, meta: { labell: 'Draft', owner: 'ops' } }, - * done: { type: 'final' } }, - * } }) - * ``` - * - * **succeeded**, returning - * `{ id, initial, states: { draft: { type: 'atomic', meta: {} }, done: … } }` — - * `stats` gone, `meta`'s two keys gone, and `onn` (one keystroke from `on`) - * gone with every transition the author declared. A state machine whose whole - * purpose is to *deny* undeclared transitions had silently become one with no - * transitions at all, and reported success. - * - * So: `authorable`, and every shape below is `strictObject`. - * - * ## `meta` is closed, deliberately - * - * XState treats `meta` as an open bag, so leaving it open was the plausible - * call and it was checked rather than assumed (the #4909 precedent: a slot - * whose openness is real should say `.passthrough()`, not strip). Three facts - * say closed here: the hand-written {@link StateNodeConfig} type beside this - * schema declares exactly four `meta` keys, so `passthrough` would open the - * Zod while `tsc` stayed shut — a new declared-≠-enforced split; nothing in - * this repo reads any `meta` key (`aiInstructions` has no consumer outside - * this file's own test); and the current behaviour is not openness but - * *strip* — the probe above shows an author's `meta` arriving as `{}`. There - * is no openness here to preserve, only a silence to end. - */ - -import { z } from 'zod'; - -import { lazySchema } from '../shared/lazy-schema'; -import { strictObject } from '../shared/strict-object'; -import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; - -/** - * Shared history sentence for every shape in this file — one silence, one - * description of it, so the rejections cannot drift apart. - */ -const STATE_MACHINE_STRIP_HISTORY = - '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 --- - -/** - * References a named action (side effect) - * Can be a script, a webhook, or a field update. - * - * A union: the string form names a registered action, the object form - * parameterises one. Only the OBJECT branch has keys to be strict about. - * - * ⚠️ **The rejection is shaped differently here than on a plain shape, and - * that is a zod property, not a curation gap.** A failing branch does not - * raise its own issue to the top: the union raises ONE `invalid_union` issue - * whose `message` is the literal string `"Invalid input"`, with each branch's - * real issues nested one level down in `issue.errors[]`. That payload survives - * everywhere the issues are carried structurally (`ZodError.message`, the REST - * error body); until #4971 the flatten-to-one-line consumers dropped it, and - * `formatZodError` — what `defineStack` throws through — was one, so this - * schema rendered a bare `✗ (root): Invalid input` where `TransitionSchema` (a plain - * `strictObject`) rendered its full prescription. `formatZodError` now expands - * the union's most informative branch, so both render the prescription; what - * remains union-shaped is the extra `Invalid input` line above it. - * - * Strictness earned its place even before that fix: the alternative was never - * a better message, it is `params` misspelled as `args` **accepted in - * silence**, with the action running unparameterised. Rejection beat that even - * at "Invalid input". Both facts are pinned in `state-machine.test.ts` so - * neither the union's shape nor the underlying prose can regress unnoticed. - */ -export const ActionRefSchema = lazySchema(() => z.union([ - z.string().describe('Action Name'), - strictObject( - { - surface: 'this action reference', - history: STATE_MACHINE_STRIP_HISTORY, - }, - { - type: z.string(), // e.g., 'xstate.assign', 'log', 'email' - params: z.record(z.string(), z.unknown()).optional(), - }, - ), -])); - -/** - * References a named condition (guard) - * Must evaluate to true for the transition to occur. - */ -export const GuardRefSchema = lazySchema(() => z.union([ - z.string().describe('Guard Name (e.g., "isManager", "amountGT1000")'), - strictObject( - { - surface: 'this guard reference', - history: STATE_MACHINE_STRIP_HISTORY, - }, - { - type: z.string(), - params: z.record(z.string(), z.unknown()).optional(), - }, - ), -])); -export type GuardRef = z.input; - -// --- Core Structure --- - -/** - * State Transition Definition - * "When EVENT happens, if GUARD is true, go to TARGET and run ACTIONS" - */ -export const TransitionSchema = lazySchema(() => strictObject( - { - surface: 'this state transition', - history: STATE_MACHINE_STRIP_HISTORY, - // `guard → cond` is the alias category's textbook case, and the evidence - // is inside this file rather than in XState release notes: the key is - // `cond`, its value is a `GuardRefSchema`, and its own `.describe()` calls - // it "Condition (Guard) required to take this path". An author who reads - // the schema — or arrives with XState v5 priors, where `cond` WAS renamed - // to `guard` — writes the word the prose uses. Edit distance never - // connects `guard` to `cond`, so only a named entry can. - aliases: { guard: 'cond' }, - }, - { - target: z.string().optional().describe('Target State ID'), - cond: GuardRefSchema.optional().describe('Condition (Guard) required to take this path'), - actions: z.array(ActionRefSchema).optional().describe('Actions to execute during transition'), - description: z.string().optional().describe('Human readable description of this rule'), - }, -)); - -// `EventSchema` (XState-style signal declaration `{ type, schema }`) was removed -// here in #4658 (dual-source ledger #4535 C6): nothing in this file — or any -// repo — ever referenced it. Event types on a state machine are the RECORD KEYS -// of `on:` (plain strings), not declared signal objects. The platform's one -// `EventSchema` is the event-bus envelope in `kernel/events/core.zod.ts`; the -// kernel-side analogue of a signal *declaration* is `EventTypeDefinitionSchema` -// in the same file. - -export type ActionRef = z.input; -export type Transition = z.input; - -export type StateNodeConfig = { - type?: 'atomic' | 'compound' | 'parallel' | 'final' | 'history'; - entry?: ActionRef[]; - exit?: ActionRef[]; - on?: Record; - always?: Transition[]; - initial?: string; - states?: Record; - meta?: { - label?: string; - description?: string; - color?: string; - aiInstructions?: string; - }; -}; - -/** - * State Node Definition - * - * Both type arguments are given (#4195) so `z.input` is not `unknown` — a state - * machine is hand-authored, and an `unknown` input type means nothing checks - * what an author writes into `states`. - * - * Caveat worth knowing before trusting it: {@link StateNodeConfig} is written by - * hand in the AUTHORING shape (`type?` is optional), while `type` is - * `.default('atomic')` and so always present once parsed. Using it for both - * arguments is therefore exact on the input side and slightly loose on the - * output side — which is what this schema already claimed before, so nothing - * regressed. Making the output exact means re-deriving `StateNodeConfig` from - * the schema rather than maintaining it beside one; that is a separate change. - * - * Note the annotation also ERASES strictness from the static type: `tsc` judges - * an author's literal against {@link StateNodeConfig}, which is a plain object - * type, so an excess key is caught by the parse rather than the compiler. That - * is exactly why the parse had to stop stripping. - */ -export const StateNodeSchema: z.ZodType = z.lazy(() => strictObject( - { - surface: 'this state node', - history: STATE_MACHINE_STRIP_HISTORY, - guidance: { - // A wrong-LAYER pointer, not a rename: `on` is a record keyed by EVENT - // TYPE, so there is no `transitions` key to send the author to. Named - // because `transitions` is the word the surviving record-lifecycle shape - // uses (`data/validation.zod.ts`'s `state_machine` rule), which is the - // neighbouring declaration an author is most likely to be coming from. - transitions: 'A state node declares its transitions as `on`, a record keyed by EVENT TYPE (`on: { APPROVE: "approved" }`). `transitions` is the key on the object-level `state_machine` VALIDATION RULE (`validations[].transitions`, a flat `{ from: [to] }` table) — a different declaration, for a record\'s lifecycle rather than an agent\'s.', - }, - }, - { - /** Type of state */ - type: z.enum(['atomic', 'compound', 'parallel', 'final', 'history']).default('atomic'), - - /** Entry/Exit Actions */ - entry: z.array(ActionRefSchema).optional().describe('Actions to run when entering this state'), - exit: z.array(ActionRefSchema).optional().describe('Actions to run when leaving this state'), - - /** Transitions (Events) */ - on: z.record(z.string(), z.union([ - z.string(), // Shorthand target - TransitionSchema, - z.array(TransitionSchema), - ])).optional().describe('Map of Event Type -> Transition Definition'), - - /** Always Transitions (Eventless) */ - always: z.array(TransitionSchema).optional(), - - /** Nesting (Hierarchical States) */ - initial: z.string().optional().describe('Initial child state (if compound)'), - states: z.record(z.string(), StateNodeSchema).optional(), - - /** Metadata for UI/AI — closed, see the module note on `meta`. */ - meta: strictObject( - { - surface: 'this state node meta block', - history: STATE_MACHINE_STRIP_HISTORY, - }, - { - label: z.string().optional(), - description: z.string().optional(), - color: z.string().optional(), // For UI diagrams - // Instructions for AI Agent when in this state - aiInstructions: z.string().optional().describe('Specific instructions for AI when in this state'), - }, - ).optional(), - }, -)); -export type StateNode = z.input; - -/** - * Top-Level State Machine Definition - */ -export const StateMachineSchema = lazySchema(() => strictObject( - { - surface: 'this state machine', - history: STATE_MACHINE_STRIP_HISTORY, - guidance: { - // XState's `context` holds initial VALUES; this protocol's - // `contextSchema` declares a SHAPE. Renaming one to the other would tell - // an author to write their initial values where a schema goes — a - // confidently wrong prescription of exactly the kind the campaign's - // fourth finding is about. So: a pointer that states both halves. - context: '`context` in XState holds the machine\'s INITIAL VALUES. This protocol declares only the context SHAPE, as `contextSchema` — there is no key here for seeding values, so the two are not a rename of each other.', - }, - }, - { - id: SnakeCaseIdentifierSchema.describe('Unique Machine ID'), - description: z.string().optional(), - - /** Context (Memory) Schema */ - contextSchema: z.record(z.string(), z.unknown()).optional().describe('Zod Schema for the machine context/memory'), - - /** Initial State */ - initial: z.string().describe('Initial State ID'), - - /** State Definitions */ - states: z.record(z.string(), StateNodeSchema).describe('State Nodes'), - - /** Global Listeners */ - on: z.record(z.string(), z.union([z.string(), TransitionSchema, z.array(TransitionSchema)])).optional(), - }, -)); - -export type StateMachineConfig = z.input; diff --git a/packages/spec/src/automation/sync-retirement.test.ts b/packages/spec/src/automation/sync-retirement.test.ts index 9d1bc3f2fe6..980f537f753 100644 --- a/packages/spec/src/automation/sync-retirement.test.ts +++ b/packages/spec/src/automation/sync-retirement.test.ts @@ -101,10 +101,13 @@ describe('[#4738] sync/conflict dual-source retirement', () => { // keeping it would have asserted the survival of a layer this repo // deliberately removed. Re-pointing it at another `automation/` export // would have preserved the line and lost the meaning. What survives as the - // "did not over-reach" witness is `StateMachineSchema` plus the >50 export + // "did not over-reach" witness is `FlowSchema` plus the >50 export // floor above — and, one layer out, the surviving sync surfaces are - // asserted by name in section 4 below. - expect(automationNames).toContain('StateMachineSchema'); + // asserted by name in section 4 below. (The witness was `StateMachineSchema` + // until #21320 retired that family with `agent.lifecycle`; `FlowSchema` is + // the `/automation` export least likely to ever leave, which is the only + // property a non-over-reach witness needs.) + expect(automationNames).toContain('FlowSchema'); for (const alsoRetired of [ 'ETLPipeline', 'ETLPipelineSchema', 'ETLPipelineRun', 'ETLPipelineRunSchema', 'ETLSource', 'ETLSourceSchema', 'ETLDestination', 'ETLDestinationSchema', diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 9d37792a272..60700957c37 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10396,6 +10396,86 @@ const actionBlockEndpointToTarget: MetadataConversion = { }, }; +/** + * An agent's conversation state machine — `agent.lifecycle` — leaves the spec + * (protocol 18, #21320; ADR-0049 enforce-or-remove, ruled D (retire) on + * objectstack-ai/cloud#2569). + * + * It was parsed and never read: no runtime, in this repository or in cloud's + * AI service (the one runtime that executes agents), moved an agent through a + * declared state or refused an undeclared transition. So no authored value + * ever changed what an agent did, and the delete is LOSSLESS. Authoring now + * refuses the key by name (`retiredKey`, ai/agent.zod.ts). Its value schema, + * the XState `StateMachineSchema` family, had no other authorable door and + * left the package with it. + * + * One edit: the key is deleted from each `agents[]` entry, whatever it holds. + * Every other key of the agent stays. One notice per agent that carried it. + * + * ⛔ What it does NOT do: rewrite the machine into a skill, a Flow or a + * `state_machine` validation rule. Which of the three an author meant — a + * conversation phase, a multi-step process, or a record's status transitions + * — is a judgement no mechanical rewrite can make; the D3 entry + * `agent-lifecycle-retired` carries it. + * + * Idempotent by construction: `stripKeys` skips an absent key and hands the + * input back by reference. Retired from the load path: an author is refused at + * parse with the prescription; data at rest (`applyConversionsToStoredItem`), + * built artifacts and `os migrate meta` replay it. + */ +const agentLifecycleRemoved: MetadataConversion = { + id: 'agent-lifecycle-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.6.0', + surface: 'agent.lifecycle', + summary: + "agent key 'lifecycle' removed: the conversation state machine was parsed and never read — no runtime " + + 'moved an agent through a declared state. The key is deleted; a conversation phase is a skill with ' + + 'triggerConditions, orchestration is a Flow, record transitions are a state_machine validation rule', + apply(stack, emit) { + return mapCollection(stack, 'agents', (agent, path) => stripKeys(agent, ['lifecycle'], emit, path)); + }, + fixture: { + before: { + agents: [ + { + // A full machine, the shape the retired schema accepted: deleted + // whole — no state of it ever ran. + name: 'intake_agent', + label: 'Intake', + lifecycle: { + id: 'intake', + initial: 'greeting', + states: { + greeting: { on: { IDENTIFIED: 'triage' } }, + triage: { on: { RESOLVED: 'done' } }, + done: { type: 'final' }, + }, + }, + }, + // A minimal machine. + { name: 'review_agent', label: 'Review', lifecycle: { id: 'review', initial: 'open', states: { open: {} } } }, + // A non-object value a hand-edited stored row could carry: the key + // goes whatever it holds. + { name: 'stray_agent', label: 'Stray', lifecycle: 'draft' }, + // No machine at all: rides through untouched. + { name: 'plain_agent', label: 'Plain' }, + ], + }, + after: { + agents: [ + { name: 'intake_agent', label: 'Intake' }, + { name: 'review_agent', label: 'Review' }, + { name: 'stray_agent', label: 'Stray' }, + { name: 'plain_agent', label: 'Plain' }, + ], + }, + // Three: one per agent that carried the key. + expectedNotices: 3, + }, +}; + /** * An agent's long-term memory store — `agent.memory.longTerm.store` — leaves * the spec (protocol 18, #20274; ADR-0049 enforce-or-remove, ruling record @@ -14063,6 +14143,7 @@ function inApplicationOrder(entries: readonly OrderedConversion[]): readonly Met const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: actionAriaRemoved, order: 43 }, { conversion: actionBlockEndpointToTarget, order: 52 }, + { conversion: agentLifecycleRemoved, order: 57 }, { conversion: agentMemoryLongTermStoreRemoved, order: 56 }, { conversion: agentStructuredOutputRefusedMembersRemoved, order: 55 }, { conversion: apiEndpointCacheTtlToCacheTtlSeconds, order: 23 }, diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index b66caeac33d..56da7636d5c 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -134,21 +134,23 @@ export { defineSkill } from './ai/skill.zod'; // ObjectStackDefinitionSchema>` — a generic instantiation the declaration // emitter does not preserve as an alias — so an un-annotated // `export default defineStack(...)` is emitted as the STRUCTURAL expansion, -// and that expansion mentions these three types. Without root re-exports, tsc +// and that expansion mentions these types. Without root re-exports, tsc // can only name them through the hash-named internal dist chunk that declares // them (unaddressable through the package's `exports` map → TS2883 in every -// consumer inferring through a root-entry function). All three are already -// public on their domain subpaths (`/ui`, `/automation`); this block makes the +// consumer inferring through a root-entry function). Both are already +// public on their domain subpath (`/ui`); this block makes the // root entry self-consistent. Invariant (maintainer ruling 2026-08-23, // recorded in commit ece4dad31): a type that appears structurally in an entry's public -// declarations must be nameable from that same entry. +// declarations must be nameable from that same entry. The third name this +// block carried, `/automation`'s `StateNodeConfig`, left the package with the +// `StateMachineSchema` family (#21320): its one structural mention was the +// tombstoned `agent.lifecycle`. export type { FormFieldInput } from './ui/view.zod'; export type { NavigationItemInput } from './ui/app.zod'; -export type { StateNodeConfig } from './automation/state-machine.zod'; // [#11709] Commit ece4dad31's recorded premise delta, ruled the same way (maintainer // decision 2026-08-25, recorded on #11709): the MINIMAL one-file consumer — // no `/data` subpath import anywhere in its program — leaks two more -// structural mentions of `defineStack`'s return type that the three lines +// structural mentions of `defineStack`'s return type that the lines // above do not cover. Same invariant, same fix shape: re-export from the // declaring module (both already public on `/data`). export type { BaseValidationRuleShape } from './data/validation.zod'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.automation__ActionRef.ts b/packages/spec/src/migrations/entries/retired-defs/18.automation__ActionRef.ts new file mode 100644 index 00000000000..89b2c49a29e --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.automation__ActionRef.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #21320 — `automation/ActionRef` (a named side effect, by name or parameterised) left with `automation/StateMachine`: +// every consumer it had was inside the retired state-machine family (the +// #3950 rule — an exported value schema with no consumer reads as a +// capability). See `18.automation__StateMachine.ts` for the retirement +// record and the ruling. +export const entry = 'automation/ActionRef'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.automation__GuardRef.ts b/packages/spec/src/migrations/entries/retired-defs/18.automation__GuardRef.ts new file mode 100644 index 00000000000..41bd0d0bebe --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.automation__GuardRef.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #21320 — `automation/GuardRef` (a named transition condition) left with `automation/StateMachine`: +// every consumer it had was inside the retired state-machine family (the +// #3950 rule — an exported value schema with no consumer reads as a +// capability). See `18.automation__StateMachine.ts` for the retirement +// record and the ruling. +export const entry = 'automation/GuardRef'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.automation__StateMachine.ts b/packages/spec/src/migrations/entries/retired-defs/18.automation__StateMachine.ts new file mode 100644 index 00000000000..1ca51f9c199 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.automation__StateMachine.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #21320 — `automation/state-machine.zod.ts` `StateMachineSchema`, the +// XState-style machine (hierarchical and parallel states, guarded transitions, +// entry/exit actions), retired whole with its last authorable door, the +// tombstoned `agent.lifecycle` (ADR-0049 enforce-or-remove, ruled D on +// objectstack-ai/cloud#2569). ADR-0020 had already retired it as a +// record-lifecycle declaration — the `workflow` type and `object.stateMachines` +// went, and a record's legal transitions are the `state_machine` validation +// rule — and kept the file only because the agent door still imported it +// (ADR-0020 implementation note 1). The rest of the family — `StateNode`, +// `Transition`, `ActionRef`, `GuardRef` — left with it, each registered in its +// own entry file beside this one. Upgraders get the D3 semantic entry +// `agent-lifecycle-retired`. Registered under 18 for the launch-window reason +// its neighbours state. +export const entry = 'automation/StateMachine'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.automation__StateNode.ts b/packages/spec/src/migrations/entries/retired-defs/18.automation__StateNode.ts new file mode 100644 index 00000000000..6b630e588ff --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.automation__StateNode.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #21320 — `automation/StateNode` (one state of a machine, recursive) left with `automation/StateMachine`: +// every consumer it had was inside the retired state-machine family (the +// #3950 rule — an exported value schema with no consumer reads as a +// capability). See `18.automation__StateMachine.ts` for the retirement +// record and the ruling. +export const entry = 'automation/StateNode'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.automation__Transition.ts b/packages/spec/src/migrations/entries/retired-defs/18.automation__Transition.ts new file mode 100644 index 00000000000..8984934b317 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.automation__Transition.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #21320 — `automation/Transition` (a guarded transition between states) left with `automation/StateMachine`: +// every consumer it had was inside the retired state-machine family (the +// #3950 rule — an exported value schema with no consumer reads as a +// capability). See `18.automation__StateMachine.ts` for the retirement +// record and the ruling. +export const entry = 'automation/Transition'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ai__Agent__lifecycle.ts b/packages/spec/src/migrations/entries/retired-keys/18.ai__Agent__lifecycle.ts new file mode 100644 index 00000000000..a15d88b8b2b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ai__Agent__lifecycle.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #21320 — ADR-0049 enforce-or-remove, ruled D (retire) on +// objectstack-ai/cloud#2569: `agent.lifecycle`, the agent conversation state +// machine, was parsed and never read — no runtime in this repository or in +// cloud moved an agent through a declared state, and every enforcement design +// measured there was a subset statechart interpreter beside Flow (the +// two-engine shape ADR-0020 rejected). Tombstoned with `retiredKey()` on the +// strict `AgentSchema`; D2 conversion `agent-lifecycle-removed` (lossless +// delete, retired from the load path); D3 semantic entry +// `agent-lifecycle-retired`. Its value schema, `automation/StateMachine`, left +// with it (RETIRED_DEFS_BY_MAJOR). Registered under 18 for the launch-window +// reason its neighbours state. +export const entry = 'ai/Agent:lifecycle'; diff --git a/packages/spec/src/migrations/entries/semantic/18.agent-lifecycle-retired.ts b/packages/spec/src/migrations/entries/semantic/18.agent-lifecycle-retired.ts new file mode 100644 index 00000000000..1ec535fbeb5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.agent-lifecycle-retired.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21320 — ADR-0049 enforce-or-remove (ruled D, retire, on +// objectstack-ai/cloud#2569) — the D3 entry of the `agent.lifecycle` +// retirement, one entry for the one family: the key and the XState +// `StateMachineSchema` exports that only it still reached leave for the same +// reason. The key's deletion is mechanical (the D2 conversion +// `agent-lifecycle-removed`); where the intent behind a deleted machine goes — +// a skill, a Flow, or a `state_machine` validation rule — is not, and that +// judgement is what this entry carries. +export const entry: SemanticMigration = { + id: 'agent-lifecycle-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. + surface: + 'agent.lifecycle — the agent conversation state machine left the shape; with it the XState ' + + 'StateMachineSchema family left @objectstack/spec/automation (StateMachineSchema, StateNodeSchema, ' + + 'TransitionSchema, ActionRefSchema, GuardRefSchema and their types), and StateNodeConfig left the ' + + 'root and /ai entries', + replacement: + 'no key: delete `lifecycle` from every agent. Put what the machine meant where the platform enforces ' + + 'it — a phase of a conversation is a skill with its own `instructions` and `tools`, selected by its ' + + '`triggerConditions` and attached through the agent\'s `skills`; a multi-step process is a Flow; a ' + + 'record\'s status transitions are a `state_machine` validation rule on the object (a flat table of ' + + 'each state\'s allowed next states). Code that imported the state machine exports declares the shape ' + + 'it needs itself, or drops it', + reason: + 'ADR-0049 enforce-or-remove: `agent.lifecycle` was parsed and never read. No runtime — not this ' + + 'repository, not the cloud AI runtime that executes agents — moved an agent through a declared ' + + 'state or refused an undeclared transition, so an authored machine changed nothing an agent did. ' + + 'Enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 ' + + 'rejected, and what it reached for is already served: conversation phases by skills (ADR-0064), ' + + 'orchestration by Flow (ADR-0019), record transitions by the `state_machine` validation rule ' + + '(ADR-0020). Authoring now refuses the key with that prescription, and TypeScript rejects it. The ' + + 'D2 conversion `agent-lifecycle-removed` deletes it from existing sources and stored agent rows, ' + + 'losslessly. `StateMachineSchema` had kept its file only for this door (ADR-0020 implementation ' + + 'note 1), so the family left with it — which of the three destinations each deleted machine meant ' + + 'is the author\'s judgement, not a mechanical rewrite', + acceptanceCriteria: + 'No agent declares `lifecycle`; it is refused at parse with its prescription, and TypeScript rejects ' + + 'it. Every conversation phase a deleted machine described is a skill the agent lists in `skills`, ' + + 'with its own `instructions`, `tools` and `triggerConditions`; every multi-step process it described ' + + 'is a Flow; every record status transition it described is a `state_machine` validation rule on that ' + + 'object. No source imports StateMachineSchema, StateNodeSchema, TransitionSchema, ActionRefSchema, ' + + 'GuardRefSchema or their types from @objectstack/spec. Every agent parses under the new schema.', + conversionIds: ['agent-lifecycle-removed'], +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a49da25ca28..405e41bc5a9 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5074,6 +5074,20 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'to be already precise. Unlike everything else in this step it changes no schema, so ' + 'nothing refuses at publish: the upgrade signal is behavioural and belongs here.', }, + { + id: 'agent-lifecycle-retired', + order: 62, + text: + 'It also retires an agent\'s conversation state machine, `agent.lifecycle` (ADR-0049 ' + + 'enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared ' + + 'state or refused an undeclared transition, and enforcing it would have meant a statechart ' + + 'interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served ' + + 'elsewhere — a conversation phase is a skill selected by its `triggerConditions`, a multi-step ' + + 'process is a Flow, a record\'s status transitions are the `state_machine` validation rule — so ' + + 'authoring refuses the key with that prescription, and the D2 conversion `agent-lifecycle-removed` ' + + 'deletes it, losslessly, retired from the load path. The XState `StateMachineSchema` family, ' + + 'kept by ADR-0020 only for this door, left the package with it.', + }, { id: 'agent-memory-store-retired-and-limits-required', order: 61, @@ -6585,6 +6599,50 @@ const step18: MigrationStep = { + 'tests green. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ' + 'ever read the container, so removing it removes no behaviour.', }, + // #21320 — ADR-0049 enforce-or-remove (ruled D, retire, on + // objectstack-ai/cloud#2569) — the D3 entry of the `agent.lifecycle` + // retirement, one entry for the one family: the key and the XState + // `StateMachineSchema` exports that only it still reached leave for the same + // reason. The key's deletion is mechanical (the D2 conversion + // `agent-lifecycle-removed`); where the intent behind a deleted machine goes — + // a skill, a Flow, or a `state_machine` validation rule — is not, and that + // judgement is what this entry carries. + { + id: 'agent-lifecycle-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. + surface: + 'agent.lifecycle — the agent conversation state machine left the shape; with it the XState ' + + 'StateMachineSchema family left @objectstack/spec/automation (StateMachineSchema, StateNodeSchema, ' + + 'TransitionSchema, ActionRefSchema, GuardRefSchema and their types), and StateNodeConfig left the ' + + 'root and /ai entries', + replacement: + 'no key: delete `lifecycle` from every agent. Put what the machine meant where the platform enforces ' + + 'it — a phase of a conversation is a skill with its own `instructions` and `tools`, selected by its ' + + '`triggerConditions` and attached through the agent\'s `skills`; a multi-step process is a Flow; a ' + + 'record\'s status transitions are a `state_machine` validation rule on the object (a flat table of ' + + 'each state\'s allowed next states). Code that imported the state machine exports declares the shape ' + + 'it needs itself, or drops it', + reason: + 'ADR-0049 enforce-or-remove: `agent.lifecycle` was parsed and never read. No runtime — not this ' + + 'repository, not the cloud AI runtime that executes agents — moved an agent through a declared ' + + 'state or refused an undeclared transition, so an authored machine changed nothing an agent did. ' + + 'Enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 ' + + 'rejected, and what it reached for is already served: conversation phases by skills (ADR-0064), ' + + 'orchestration by Flow (ADR-0019), record transitions by the `state_machine` validation rule ' + + '(ADR-0020). Authoring now refuses the key with that prescription, and TypeScript rejects it. The ' + + 'D2 conversion `agent-lifecycle-removed` deletes it from existing sources and stored agent rows, ' + + 'losslessly. `StateMachineSchema` had kept its file only for this door (ADR-0020 implementation ' + + 'note 1), so the family left with it — which of the three destinations each deleted machine meant ' + + 'is the author\'s judgement, not a mechanical rewrite', + acceptanceCriteria: + 'No agent declares `lifecycle`; it is refused at parse with its prescription, and TypeScript rejects ' + + 'it. Every conversation phase a deleted machine described is a skill the agent lists in `skills`, ' + + 'with its own `instructions`, `tools` and `triggerConditions`; every multi-step process it described ' + + 'is a Flow; every record status transition it described is a `state_machine` validation rule on that ' + + 'object. No source imports StateMachineSchema, StateNodeSchema, TransitionSchema, ActionRefSchema, ' + + 'GuardRefSchema or their types from @objectstack/spec. Every agent parses under the new schema.', + conversionIds: ['agent-lifecycle-removed'], + }, // #20274 — ADR-0049 enforce-or-remove (ruling record 5950198150, letter A′) — // the D3 entry of the `agent.memory` contract: one entry for the one decision, // because its two halves leave an upgrading author ONE job between them. The @@ -20461,6 +20519,18 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + // #21320 — ADR-0049 enforce-or-remove, ruled D (retire) on + // objectstack-ai/cloud#2569: `agent.lifecycle`, the agent conversation state + // machine, was parsed and never read — no runtime in this repository or in + // cloud moved an agent through a declared state, and every enforcement design + // measured there was a subset statechart interpreter beside Flow (the + // two-engine shape ADR-0020 rejected). Tombstoned with `retiredKey()` on the + // strict `AgentSchema`; D2 conversion `agent-lifecycle-removed` (lossless + // delete, retired from the load path); D3 semantic entry + // `agent-lifecycle-retired`. Its value schema, `automation/StateMachine`, left + // with it (RETIRED_DEFS_BY_MAJOR). Registered under 18 for the launch-window + // reason its neighbours state. + 'ai/Agent:lifecycle', // #20274 — ADR-0049 enforce-or-remove, ruling record 5950198150 (letter A′, // maintainer 「同意」): the `agent.memory` contract states exactly what the // runtime honours, and the memory store is platform infrastructure, not agent @@ -24871,6 +24941,18 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // conversion — this table plus the D3 semantic entry // `export-job-family-retired` are the declaration. 'api/ScheduledExport', + // #21320 — `automation/ActionRef` (a named side effect, by name or parameterised) left with `automation/StateMachine`: + // every consumer it had was inside the retired state-machine family (the + // #3950 rule — an exported value schema with no consumer reads as a + // capability). See `18.automation__StateMachine.ts` for the retirement + // record and the ruling. + 'automation/ActionRef', + // #21320 — `automation/GuardRef` (a named transition condition) left with `automation/StateMachine`: + // every consumer it had was inside the retired state-machine family (the + // #3950 rule — an exported value schema with no consumer reads as a + // capability). See `18.automation__StateMachine.ts` for the retirement + // record and the ruling. + 'automation/GuardRef', // #17158 — `automation/ScheduleState`, retired whole with the export-job API family // (ADR-0049 enforce-or-remove; maintainer ruling A, landing route A — objectui // retired its side first in objectui#10247). It declared @@ -24884,6 +24966,32 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // conversion — this table plus the D3 semantic entry // `export-job-family-retired` are the declaration. 'automation/ScheduleState', + // #21320 — `automation/state-machine.zod.ts` `StateMachineSchema`, the + // XState-style machine (hierarchical and parallel states, guarded transitions, + // entry/exit actions), retired whole with its last authorable door, the + // tombstoned `agent.lifecycle` (ADR-0049 enforce-or-remove, ruled D on + // objectstack-ai/cloud#2569). ADR-0020 had already retired it as a + // record-lifecycle declaration — the `workflow` type and `object.stateMachines` + // went, and a record's legal transitions are the `state_machine` validation + // rule — and kept the file only because the agent door still imported it + // (ADR-0020 implementation note 1). The rest of the family — `StateNode`, + // `Transition`, `ActionRef`, `GuardRef` — left with it, each registered in its + // own entry file beside this one. Upgraders get the D3 semantic entry + // `agent-lifecycle-retired`. Registered under 18 for the launch-window reason + // its neighbours state. + 'automation/StateMachine', + // #21320 — `automation/StateNode` (one state of a machine, recursive) left with `automation/StateMachine`: + // every consumer it had was inside the retired state-machine family (the + // #3950 rule — an exported value schema with no consumer reads as a + // capability). See `18.automation__StateMachine.ts` for the retirement + // record and the ruling. + 'automation/StateNode', + // #21320 — `automation/Transition` (a guarded transition between states) left with `automation/StateMachine`: + // every consumer it had was inside the retired state-machine family (the + // #3950 rule — an exported value schema with no consumer reads as a + // capability). See `18.automation__StateMachine.ts` for the retirement + // record and the ruling. + 'automation/Transition', // #16325 — `cloud/developer-portal.zod.ts` left `@objectstack/spec` with the `./cloud` subpath // (maintainer ruling, option B "cut by owner": the cloud control plane's contracts are // the cloud repo's own declarations, not an open-source protocol). Prescription: the diff --git a/packages/spec/src/recursive-schema-input-assertions.ts b/packages/spec/src/recursive-schema-input-assertions.ts index dafba36560e..607a30f3e9f 100644 --- a/packages/spec/src/recursive-schema-input-assertions.ts +++ b/packages/spec/src/recursive-schema-input-assertions.ts @@ -51,7 +51,6 @@ import type { z } from 'zod'; import type { FormFieldInput, FormFieldSchema } from './ui/view.zod'; -import type { StateNodeConfig, StateNodeSchema } from './automation/state-machine.zod'; import type { BaseValidationRuleShape, ValidationRuleSchema } from './data/validation.zod'; import type { FilterCondition, @@ -155,23 +154,11 @@ export const normalizedNotAString: NormalizedFilter = 'nope'; // @ts-expect-error — only `$and` / `$or` / `$not` are declared at this level export const normalizedBadKey: NormalizedFilter = { $nand: [] }; -/* ── automation/state-machine.zod.ts ───────────────────────────────────────── */ - -/** Every key is optional; `states` recurses. */ -export const stateInput: StateNodeConfig = { - type: 'compound', - initial: 'draft', - states: { - draft: { type: 'atomic', on: { SUBMIT: 'review' } }, - review: { type: 'final' }, - }, -}; - -// @ts-expect-error — a state node is not a string -export const stateNotAString: StateNodeConfig = 'draft'; - -// @ts-expect-error — `type` must be one of the five declared state kinds -export const stateBadType: StateNodeConfig = { type: 'pending' }; +/* ── automation/state-machine.zod.ts — REMOVED (#21320) ────────────────────── + * The recursive `StateNodeSchema` left the package with the `StateMachineSchema` + * family when its last authorable door, `agent.lifecycle`, was tombstoned. Its + * probes went with it; there is no schema left for them to pin. + * ──────────────────────────────────────────────────────────────────────────── */ /* ── data/validation.zod.ts ────────────────────────────────────────────────── */ @@ -235,9 +222,6 @@ export const wiredFilter: z.input = 42; // @ts-expect-error — NormalizedFilterSchema's input is checked export const wiredNormalized: z.input = 42; -// @ts-expect-error — StateNodeSchema's input is checked -export const wiredState: z.input = 42; - // @ts-expect-error — ValidationRuleSchema's input is checked export const wiredValidation: z.input = 42; diff --git a/packages/spec/src/shared/union-author-message-pins.test.ts b/packages/spec/src/shared/union-author-message-pins.test.ts index d78fd346439..738a1c11350 100644 --- a/packages/spec/src/shared/union-author-message-pins.test.ts +++ b/packages/spec/src/shared/union-author-message-pins.test.ts @@ -68,6 +68,15 @@ * (`kernel/manifest-unknown-keys.test.ts`), `ActionRef` by the CONTROL case * in `automation/state-machine.test.ts`. * + * ⚠️ **Four of the 14 have since left the tree, and the table with them + * (#21320).** `ActionRef`, `GuardRef`, `StateNode.on` and `StateMachine.on` + * were the four string-or-object unions of `automation/state-machine.zod.ts`, + * which was deleted whole when its last authorable door, `agent.lifecycle`, + * was tombstoned (ADR-0049). The class is 10 today: 9 in the table, + * `devPlugins` in its own file. A deleted site is not a re-derivation — the + * other 28 coordinates were not re-scanned for it, and the counts in this + * header above this note are the original derivation's. + * * ⚠️ Curation and closure are INDEPENDENT, which is the trap. Thirteen of the * 14 spell both at once with `strictObject()`. `ui/view.zod.ts:2895` splits * them: `FormFieldBaseSchema` takes a `strictObjectError({ surface: 'this @@ -153,7 +162,6 @@ import { describe, expect, it } from 'vitest'; import { ApprovalNodeConfigSchema } from '../automation/approval.zod'; import { FlowFunctionEntrySchema } from '../automation/flow-function.zod'; -import { GuardRefSchema, StateMachineSchema, StateNodeSchema } from '../automation/state-machine.zod'; import { FieldSchema } from '../data/field.zod'; import { ObjectSchema } from '../data/object.zod'; import { GroupByNodeSchema } from '../data/query.zod'; @@ -226,8 +234,9 @@ interface UnionMessageSite { } /** - * The class-A and class-B population minus the two sites already covered - * (`devPlugins` by #14975, `ActionRef` by `state-machine.test.ts`). + * The class-A and class-B population minus the one site already covered + * (`devPlugins` by #14975). `ActionRef`'s pin left with + * `state-machine.test.ts`, its site with `state-machine.zod.ts` (#21320). * * ⛔ Rows are not invented: each `site` is a coordinate the scan produced. * @@ -239,45 +248,6 @@ interface UnionMessageSite { * own card, not asserted here as though it existed. */ const SITES: ReadonlyArray = [ - ['GuardRef — the object arm of a guard reference', { - site: 'automation/state-machine.zod.ts:120', - door: GuardRefSchema, - reject: { parms: { a: 1 } }, - selectedBranchIssues: 2, - key: 'parms', - keyInMessage: '`parms`', - surface: 'this guard reference', - renameInMessage: '`parms` → `params`', - acceptString: 'isManager', - acceptObject: { type: 'log', params: { a: 1 } }, - }], - ['StateNode.on — a transition written inline on a state', { - site: 'automation/state-machine.zod.ts:231', - door: StateNodeSchema, - reject: { on: { GO: { guard: 'isX', actions: 'not-an-array' } } }, - selectedBranchIssues: 2, - key: 'guard', - keyInMessage: '`guard`', - surface: 'this state transition', - renameInMessage: '`guard` → `cond`', - acceptString: { on: { GO: 'next' } }, - acceptObject: { on: { GO: { target: 'next' } } }, - }], - ['StateMachine.on — the machine-level listener map', { - site: 'automation/state-machine.zod.ts:292', - door: StateMachineSchema, - reject: { id: 'machine', initial: 'idle', states: { idle: { initial: 'a', states: {} } }, - on: { GO: { guard: 'isX', actions: 'not-an-array' } } }, - selectedBranchIssues: 2, - key: 'guard', - keyInMessage: '`guard`', - surface: 'this state transition', - renameInMessage: '`guard` → `cond`', - acceptString: { id: 'machine', initial: 'idle', states: { idle: { initial: 'a', states: {} } }, - on: { GO: 'idle' } }, - acceptObject: { id: 'machine', initial: 'idle', states: { idle: { initial: 'a', states: {} } }, - on: { GO: { target: 'idle' } } }, - }], ['Approval.decisionOutputs — a declared decision output', { site: 'automation/approval.zod.ts:791', door: ApprovalNodeConfigSchema, @@ -547,11 +517,14 @@ describe('[#15423] the AUTHOR-VISIBLE message at a string-or-object union site', it('covers every site the scan found outside the two already pinned elsewhere', () => { // 14 class-A sites + 1 class-B = 15 with a curated-or-bare unknown-key // refusal to lose; `devPlugins` (#14975) and `ActionRef` - // (`state-machine.test.ts`) are pinned in their own files, so 13 belong - // here. ⚠️ This number certifies the population COMPLETE, which is why - // the first pass getting it wrong mattered: at 12 it asserted, forever - // and greenly, that `ui/view.zod.ts:2895` was not a member. - expect(SITES).toHaveLength(13); + // (`state-machine.test.ts`) were pinned in their own files, so 13 belonged + // here. #21320 deleted `automation/state-machine.zod.ts` and its four + // class-A sites (`ActionRef`, `GuardRef`, `StateNode.on`, + // `StateMachine.on`), three of them rows of this table: 10 now. ⚠️ This + // number certifies the population COMPLETE, which is why the first pass + // getting it wrong mattered: at 12 it asserted, forever and greenly, that + // `ui/view.zod.ts:2895` was not a member. + expect(SITES).toHaveLength(10); // Each row names a distinct `z.union` coordinate. expect(new Set(SITES.map(([, s]) => s.site)).size).toBe(SITES.length); diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 29949044035..67a1ce41525 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -116,7 +116,6 @@ import type * as M38 from './automation/bpmn-interop.zod.js'; import type * as M39 from './automation/execution.zod.js'; import type * as M40 from './automation/flow-function.zod.js'; import type * as M41 from './automation/node-executor.zod.js'; -import type * as M42 from './automation/state-machine.zod.js'; import type * as M43 from './automation/time-relative-trigger.zod.js'; import type * as M44 from './automation/webhook.zod.js'; import type * as M50 from './marketplace/marketplace.zod.js'; @@ -275,7 +274,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 778 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 773 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -750,13 +749,6 @@ export type Iso_automation_scheduleOrganization__ScheduleOrganizationSchema = As // automation/schemaless-node-config.zod.ts export type Iso_automation_schemalessNodeConfig__DecisionConditionSchema = Assert, z.infer< typeof M173.DecisionConditionSchema > >>; -// automation/state-machine.zod.ts -export type Iso_automation_stateMachine__ActionRefSchema = Assert, z.infer< typeof M42.ActionRefSchema > >>; -export type Iso_automation_stateMachine__GuardRefSchema = Assert, z.infer< typeof M42.GuardRefSchema > >>; -export type Iso_automation_stateMachine__StateMachineSchema = Assert, z.infer< typeof M42.StateMachineSchema > >>; -export type Iso_automation_stateMachine__StateNodeSchema = Assert, z.infer< typeof M42.StateNodeSchema > >>; -export type Iso_automation_stateMachine__TransitionSchema = Assert, z.infer< typeof M42.TransitionSchema > >>; - // automation/time-relative-trigger.zod.ts export type Iso_automation_timeRelativeTrigger__TimeRelativeTriggerSchema = Assert, z.infer< typeof M43.TimeRelativeTriggerSchema > >>; @@ -1673,7 +1665,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 778 isomorphic pins', () => { + it('still declares all 773 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2413,7 +2405,13 @@ describe('ADR-0122 type-alias convention', () => { // whose own input ≠ infer, so `ObjectMasterDetailFormPropsSchema` left the // isomorphic family for an `ObjectMasterDetailFormPropsParsed` alias, the // route the object-* family note above prescribes. -1 removed. - expect(pins).toHaveLength(778); + // + // 778 -> 773 is #21320: `automation/state-machine.zod.ts` was deleted whole + // when its last authorable door, `agent.lifecycle`, was tombstoned + // (ADR-0049). Its five pins (`ActionRefSchema`, `GuardRefSchema`, + // `StateMachineSchema`, `StateNodeSchema`, `TransitionSchema`) went with the + // module, and so did its `M42` import. -5 removed. + expect(pins).toHaveLength(773); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index 1f35256fa2f..3c319ca893b 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -51,7 +51,9 @@ import { strictObject } from '../shared/strict-object'; // (`TouchTargetConfigSchema`, `GestureConfigSchema`) did not. (Those two // negative controls are gone as of #4988, which retired the five no-door // interaction modules outright; a re-run needs a fresh negative control — -// an inline `z.object({ a: z.string() })` is the cheapest one.) +// an inline `z.object({ a: z.string() })` is the cheapest one. Likewise +// the `StateMachineSchema` positive control left the package at #21320, +// retired with `agent.lifecycle`; the other four still stand.) // 3. PARSE — `getMetadataTypeSchema('dashboard' | 'report')` is what // `MetadataManager.validate`, `GET /api/v1/meta` and the Studio form all // go through, so a chart key is judged on the stored-metadata path. diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 40a5d0f92eb..6beb44d9979 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -23,6 +23,7 @@ "scripts/step18-rationale-merge.test.ts", "scripts/strictness-ledger-doc.test.ts", "scripts/strictness-ledger.test.ts", + "src/ai/agent-lifecycle-retirement.test.ts", "src/ai/agent-memory-store-retirement.test.ts", "src/ai/tool-confirmation-prescription-tense.pin.test.ts", "src/api/error-catalog-docs.test.ts", diff --git a/skills/objectstack-ai/references/_index.md b/skills/objectstack-ai/references/_index.md index aaecef91b11..26a4e30c9b2 100644 --- a/skills/objectstack-ai/references/_index.md +++ b/skills/objectstack-ai/references/_index.md @@ -19,7 +19,6 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies - `node_modules/@objectstack/spec/src/ai/embedding.zod.ts` — Embedding & Vector Store Primitives -- `node_modules/@objectstack/spec/src/automation/state-machine.zod.ts` — XState-inspired State Machine Protocol — hierarchical states, guarded - `node_modules/@objectstack/spec/src/data/field-value.zod.ts` — Field runtime VALUE-shape contract (ADR-0104 D1). - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Exports: FieldType, SelectOptionSchema, LocationCoordinatesSchema, CurrencyConfigSchema, CurrencyValueSchema - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification