diff --git a/.changeset/build-progress-phase-vocabulary.md b/.changeset/build-progress-phase-vocabulary.md
new file mode 100644
index 00000000000..8f98826549d
--- /dev/null
+++ b/.changeset/build-progress-phase-vocabulary.md
@@ -0,0 +1,31 @@
+---
+'@objectstack/spec': minor
+---
+
+**Declare the build-progress PHASE vocabulary on `@objectstack/spec/ai`.**
+
+The `data-build-progress` stream frame has shipped as prose only: `AIToolContext.onProgress`
+documents the channel and its example carries a `phase`, but nothing ever declared which
+phases exist. Consumers filled that gap by guessing, and a guess here is not merely
+unlabelled — the objectui chat panel coerces any value it does not recognise to `structure`,
+which renders a "still building" spinner, so a build turn that has finished and moved on to
+verifying itself keeps claiming to be building.
+
+New exports (additive; nothing removed or renamed):
+
+- `BUILD_PROGRESS_PHASES` / `BuildProgressPhaseSchema` / `BuildProgressPhase` — the CLOSED
+ phase vocabulary: `structure`, `data`, `verify`, `done`, in lifecycle order. An
+ out-of-vocabulary value is refused, and the refusal names the accepted set.
+- `BuildProgressFrameSchema` / `BuildProgressFrame` — the frame's FLOOR: a required `phase`
+ plus an optional `hop` (which post-apply verification hop) and `tool` (the tool that hop is
+ running). Deliberately loose, not strict: the presentation fields the chat panel already
+ reads ride the same frame and belong to it, so a strict schema here would refuse every
+ frame shipping today.
+- `BUILD_PROGRESS_FRAME_TYPE` — `'data-build-progress'`, the one literal both ends select on.
+
+Producers emit these frames from the agent loop rather than from the applying tool: a tool's
+`ctx.onProgress` handle dies when the tool returns, and the verification window opens after
+it does. Consumers should compare phases by value and treat every phase as optional — a turn
+that seeds no sample data never reports `data`.
+
+Clause-②: yes (widening)
diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx
index ef079cef641..1c924a95192 100644
--- a/content/docs/getting-started/quick-reference.mdx
+++ b/content/docs/getting-started/quick-reference.mdx
@@ -108,7 +108,7 @@ Runtime environment, logging, jobs, caching, and observability.
| **[Translation](/docs/references/system/translation)** | `translation.zod.ts` | Translation | i18n support |
| **[Worker](/docs/references/system/worker)** | `worker.zod.ts` | Worker | Background workers |
-## AI Protocol (11 of 11 schemas)
+## AI Protocol (11 of 12 schemas)
AI/ML capabilities - agents, skills, tools, MCP exposure, RAG, and cost tracking.
diff --git a/content/docs/references/ai/build-progress.mdx b/content/docs/references/ai/build-progress.mdx
new file mode 100644
index 00000000000..039020e025b
--- /dev/null
+++ b/content/docs/references/ai/build-progress.mdx
@@ -0,0 +1,83 @@
+---
+title: Build Progress
+description: Build Progress protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+Build-progress PHASE vocabulary for the `data-build-progress` stream frame
+(cloud#2172 ruling A).
+
+## Producer
+
+The cloud AI-studio **agent loop** — deliberately not the tool it just ran.
+A build turn applies its change through `apply_blueprint` / `apply_edit` and
+then keeps working: the loop spends a further POST-APPLY VERIFICATION window
+re-reading and re-seeding what it wrote (measured on cloud#1838: 111 seconds
+and 9 tool calls *after* `apply_blueprint` returned, one of them the
+registered `verify_build` tool — see `PLATFORM_TOOLS_BY_PACKAGE` in
+`../system/constants/platform-tool-names`). A tool's own `ctx.onProgress`
+handle dies when the tool returns, so a frame emitted in that window can only
+come from the loop. WHERE in the loop it is emitted is cloud#2172's decision,
+not this module's; what such a frame may SAY is declared here.
+
+## Consumer
+
+The objectui chat panel — `extractBuildProgress` in
+`packages/plugin-chatbot/src/mapMessages.ts`, which lifts the reconciled part
+onto `ChatBuildProgress` for the build panel in `ChatbotEnhanced.tsx`. That
+reader recognises a fixed set and coerces everything else to `'structure'`,
+which renders a "still building" spinner. So an UNDECLARED phase is not
+merely unlabelled — it reads as the wrong phase, and the user watches a
+build that finished two minutes ago still claim to be building
+(objectui#7388). The panel's per-phase COPY is objectui's to choose; this
+module fixes only the set of values it must be able to tell apart.
+
+## Channel
+
+Unchanged: the `data-`-prefixed custom part described on
+`AIToolContext.onProgress` in `../contracts/ai-service`, reconciled in place
+under a stable part id. This module adds the vocabulary that prose has always
+assumed and never declared; it moves no transport and renames nothing.
+
+
+**Source:** `packages/spec/src/ai/build-progress.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { BuildProgressFrameSchema, BuildProgressPhaseSchema } from '@objectstack/spec/ai';
+import type { BuildProgressFrame, BuildProgressPhase } from '@objectstack/spec/ai';
+
+// Validate data
+const result = BuildProgressFrameSchema.parse(data);
+```
+
+---
+
+## BuildProgressFrame
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **phase** | `Enum<'structure' \| 'data' \| 'verify' \| 'done'>` | ✅ | Build-turn phase this frame reports |
+| **hop** | `integer` | optional | Post-apply verification hop this frame reports |
+| **tool** | `string` | optional | Name of the tool the current hop is running |
+
+
+---
+
+## BuildProgressPhase
+
+### Allowed Values
+
+* `structure`
+* `data`
+* `verify`
+* `done`
+
+
+---
+
diff --git a/content/docs/references/ai/index.mdx b/content/docs/references/ai/index.mdx
index a0d0157560d..8fb0c1b7041 100644
--- a/content/docs/references/ai/index.mdx
+++ b/content/docs/references/ai/index.mdx
@@ -9,6 +9,7 @@ This section contains all protocol schemas for the ai layer of ObjectStack.
+
diff --git a/content/docs/references/ai/meta.json b/content/docs/references/ai/meta.json
index 46db5a19698..25ab80c64e5 100644
--- a/content/docs/references/ai/meta.json
+++ b/content/docs/references/ai/meta.json
@@ -14,6 +14,8 @@
"---Models & Runtime---",
"conversation",
"model-registry",
- "usage"
+ "usage",
+ "---More---",
+ "build-progress"
]
}
\ No newline at end of file
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index b401bd9b17f..e913a27704a 100644
--- a/content/docs/references/index.mdx
+++ b/content/docs/references/index.mdx
@@ -1,6 +1,6 @@
---
title: Protocol Reference
-description: Every schema published by @objectstack/spec — 1525 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1527 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/. */}
@@ -19,7 +19,7 @@ counts are sums of the rows they head. Regenerate with
| Module | Pages | Schemas | Description |
| :--- | ---: | ---: | :--- |
-| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
+| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
| [API Protocol](/docs/references/api) | 31 | 440 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Data Protocol](/docs/references/data) | 29 | 173 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
@@ -33,19 +33,20 @@ 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) | 33 | 272 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **193** | **1525** | 14 protocol modules |
+| **Total** | **194** | **1527** | 14 protocol modules |
---
## AI Protocol
-**Source:** `packages/spec/src/ai/` · **Import:** `@objectstack/spec/ai` · **11 pages, 66 schemas**
+**Source:** `packages/spec/src/ai/` · **Import:** `@objectstack/spec/ai` · **12 pages, 68 schemas**
Agents, tools, skills, RAG and knowledge sources, model registry, conversations.
| File | Schemas |
| :--- | :--- |
| [`agent.zod.ts`](/docs/references/ai/agent) | `AIModelConfig`, `Agent`, `StructuredOutputConfig`, `StructuredOutputFormat`, `TransformPipelineStep` |
+| [`build-progress.zod.ts`](/docs/references/ai/build-progress) | `BuildProgressFrame`, `BuildProgressPhase` |
| [`conversation.zod.ts`](/docs/references/ai/conversation) | `CodeContent`, `ConversationAnalytics`, `ConversationContext`, `ConversationMessage`, `ConversationSession`, `ConversationSummary`, `FileContent`, `FunctionCall`, `ImageContent`, `MessageContent`, `MessageContentType`, `MessagePruningEvent`, `MessageRole`, `TextContent`, `TokenBudgetConfig`, `TokenBudgetStrategy`, `TokenUsageStats`, `ToolCall` |
| [`embedding.zod.ts`](/docs/references/ai/embedding) | `EmbeddingModel`, `VectorStore`, `VectorStoreProvider` |
| [`knowledge-document.zod.ts`](/docs/references/ai/knowledge-document) | `KnowledgeChunk`, `KnowledgeDocument`, `KnowledgeHit` |
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
index 10b21e9c004..f81ac8b9742 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
@@ -256,7 +256,7 @@ directory rather than per file.
| Dir | Sites |
|---|---|
-| `ai/` | 77 |
+| `ai/` | 78 |
| `api/` | 451 |
| `identity/` | 32 |
| `integration/` | 8 |
diff --git a/packages/spec/api-surface/ai.json b/packages/spec/api-surface/ai.json
index 9d7c970b3dc..453b8c8a442 100644
--- a/packages/spec/api-surface/ai.json
+++ b/packages/spec/api-surface/ai.json
@@ -8,6 +8,8 @@
"Agent (type)",
"AgentParsed (type)",
"AgentSchema (const)",
+ "BUILD_PROGRESS_FRAME_TYPE (const)",
+ "BUILD_PROGRESS_PHASES (const)",
"BlueprintApp (type)",
"BlueprintAppParsed (type)",
"BlueprintAppSchema (const)",
@@ -31,6 +33,10 @@
"BlueprintViewSchema (const)",
"BlueprintWidgetCondition (type)",
"BlueprintWidgetConditionSchema (const)",
+ "BuildProgressFrame (type)",
+ "BuildProgressFrameSchema (const)",
+ "BuildProgressPhase (type)",
+ "BuildProgressPhaseSchema (const)",
"CodeContentSchema (const)",
"ConversationAnalytics (type)",
"ConversationAnalyticsParsed (type)",
diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json
index a2f7ca17a4f..35c3ebc34b8 100644
--- a/packages/spec/authorable-surface/ai.json
+++ b/packages/spec/authorable-surface/ai.json
@@ -84,6 +84,9 @@
"ai/BlueprintWidgetCondition:field",
"ai/BlueprintWidgetCondition:op",
"ai/BlueprintWidgetCondition:value",
+ "ai/BuildProgressFrame:hop",
+ "ai/BuildProgressFrame:phase",
+ "ai/BuildProgressFrame:tool",
"ai/CodeContent:language",
"ai/CodeContent:metadata",
"ai/CodeContent:text",
diff --git a/packages/spec/declaration-map/ai.json b/packages/spec/declaration-map/ai.json
index 4f6d260852d..f047617c2f7 100644
--- a/packages/spec/declaration-map/ai.json
+++ b/packages/spec/declaration-map/ai.json
@@ -27,6 +27,10 @@
"BlueprintViewSchema": "ai/BlueprintView",
"BlueprintWidgetCondition": "ai/BlueprintWidgetCondition",
"BlueprintWidgetConditionSchema": "ai/BlueprintWidgetCondition",
+ "BuildProgressFrame": "ai/BuildProgressFrame",
+ "BuildProgressFrameSchema": "ai/BuildProgressFrame",
+ "BuildProgressPhase": "ai/BuildProgressPhase",
+ "BuildProgressPhaseSchema": "ai/BuildProgressPhase",
"CodeContentSchema": "ai/CodeContent",
"ConversationAnalytics": "ai/ConversationAnalytics",
"ConversationAnalyticsSchema": "ai/ConversationAnalytics",
diff --git a/packages/spec/export-origins/ai.json b/packages/spec/export-origins/ai.json
index 1cf29b92e70..13b6fab762a 100644
--- a/packages/spec/export-origins/ai.json
+++ b/packages/spec/export-origins/ai.json
@@ -8,6 +8,8 @@
"Agent": "src/ai/agent.zod.ts#Agent (type)",
"AgentParsed": "src/ai/agent.zod.ts#AgentParsed (type)",
"AgentSchema": "src/ai/agent.zod.ts#AgentSchema (const)",
+ "BUILD_PROGRESS_FRAME_TYPE": "src/ai/build-progress.zod.ts#BUILD_PROGRESS_FRAME_TYPE (const)",
+ "BUILD_PROGRESS_PHASES": "src/ai/build-progress.zod.ts#BUILD_PROGRESS_PHASES (const)",
"BlueprintApp": "src/ai/solution-blueprint.zod.ts#BlueprintApp (type)",
"BlueprintAppParsed": "src/ai/solution-blueprint.zod.ts#BlueprintAppParsed (type)",
"BlueprintAppSchema": "src/ai/solution-blueprint.zod.ts#BlueprintAppSchema (const)",
@@ -31,6 +33,10 @@
"BlueprintViewSchema": "src/ai/solution-blueprint.zod.ts#BlueprintViewSchema (const)",
"BlueprintWidgetCondition": "src/ai/solution-blueprint.zod.ts#BlueprintWidgetCondition (type)",
"BlueprintWidgetConditionSchema": "src/ai/solution-blueprint.zod.ts#BlueprintWidgetConditionSchema (const)",
+ "BuildProgressFrame": "src/ai/build-progress.zod.ts#BuildProgressFrame (type)",
+ "BuildProgressFrameSchema": "src/ai/build-progress.zod.ts#BuildProgressFrameSchema (const)",
+ "BuildProgressPhase": "src/ai/build-progress.zod.ts#BuildProgressPhase (type)",
+ "BuildProgressPhaseSchema": "src/ai/build-progress.zod.ts#BuildProgressPhaseSchema (const)",
"CodeContentSchema": "src/ai/conversation.zod.ts#CodeContentSchema (const)",
"ConversationAnalytics": "src/ai/conversation.zod.ts#ConversationAnalytics (type)",
"ConversationAnalyticsParsed": "src/ai/conversation.zod.ts#ConversationAnalyticsParsed (type)",
diff --git a/packages/spec/json-schema.manifest/ai.json b/packages/spec/json-schema.manifest/ai.json
index 1ab2704cdb4..e76b41a930e 100644
--- a/packages/spec/json-schema.manifest/ai.json
+++ b/packages/spec/json-schema.manifest/ai.json
@@ -15,6 +15,8 @@
"ai/BlueprintSummaryOperations",
"ai/BlueprintView",
"ai/BlueprintWidgetCondition",
+ "ai/BuildProgressFrame",
+ "ai/BuildProgressPhase",
"ai/CodeContent",
"ai/ConversationAnalytics",
"ai/ConversationContext",
diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt
index 141b7166c6a..2318f1e994a 100644
--- a/packages/spec/llms.txt
+++ b/packages/spec/llms.txt
@@ -77,7 +77,7 @@ const query = {
---
-## 3. Schema Inventory by Domain (200 schemas)
+## 3. Schema Inventory by Domain (201 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
@@ -92,7 +92,7 @@ here is verifiable from the installed package.
| ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation |
| automation | 14 | Flow, Approval, BPMN Interop, Control Flow, State Machine, Webhook, Schedule Organization |
| shared | 15 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) |
-| ai | 11 | Agent, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool |
+| ai | 12 | Agent, Build Progress, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool |
| identity | 5 | Identity, Organization, Position, SCIM, Eval User |
| marketplace | 4 | Marketplace, Package, Package Version, Template Manifest |
| security | 4 | Permission, RLS, Sharing, Explain |
diff --git a/packages/spec/src/ai/build-progress.test.ts b/packages/spec/src/ai/build-progress.test.ts
new file mode 100644
index 00000000000..3f48b90477b
--- /dev/null
+++ b/packages/spec/src/ai/build-progress.test.ts
@@ -0,0 +1,182 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Pin for the build-progress PHASE vocabulary (cloud#2172 ruling A).
+ *
+ * What is actually at stake here is not "does zod reject a bad string" — it is
+ * that this vocabulary is CLOSED and that its refusal is LOUD. objectui#7388
+ * measured the cost of the alternative: the consumer's reader coerces any
+ * value it does not recognise to `'structure'`, so a phase nobody declared
+ * renders as a "still building" spinner for the 111 seconds the build spends
+ * being verified after it finished. A refusal that merely throws leaves the
+ * next author guessing; a refusal that names the accepted set tells them.
+ *
+ * So the refusal leg asserts the ENVELOPE, not the throw: zod 4 answers an
+ * out-of-vocabulary enum value with `invalid_value` and carries the full
+ * accepted set on the issue. ⛔ A bare `expect(...).toThrow()` here would stay
+ * green against a schema that had degraded to a bare `z.string()` with a
+ * refinement, which is exactly the degradation worth catching.
+ *
+ * The membership leg is the closed-enum pin. Each member was measured against
+ * a real end of the channel (provenance is recorded per member on
+ * `BUILD_PROGRESS_PHASES` in the source), so this array moving is a claim that
+ * someone re-measured — not an edit to wave through.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import {
+ BUILD_PROGRESS_PHASES,
+ BUILD_PROGRESS_FRAME_TYPE,
+ BuildProgressPhaseSchema,
+ BuildProgressFrameSchema,
+ type BuildProgressPhase,
+} from './build-progress.zod';
+
+/**
+ * The phases objectui's reader discriminates TODAY
+ * (`packages/plugin-chatbot/src/mapMessages.ts` `extractBuildProgress`, and the
+ * `ChatBuildProgress['phase']` union in `ChatbotEnhanced.tsx`). Declaring
+ * `verify` must not disturb the inherited three — this is the control that has
+ * to keep moving, not a restatement of the vocabulary under test.
+ */
+const PHASES_THE_CONSUMER_ALREADY_RENDERS = ['structure', 'data', 'done'] as const;
+
+describe('BUILD_PROGRESS_PHASES (closed membership)', () => {
+ it('is exactly the measured vocabulary, in lifecycle order', () => {
+ expect(BUILD_PROGRESS_PHASES).toEqual(['structure', 'data', 'verify', 'done']);
+ });
+
+ it('still contains every phase the objectui reader already discriminates', () => {
+ for (const phase of PHASES_THE_CONSUMER_ALREADY_RENDERS) {
+ expect(BUILD_PROGRESS_PHASES).toContain(phase);
+ }
+ });
+
+ it('names the post-apply verification phase the card exists to declare', () => {
+ expect(BUILD_PROGRESS_PHASES).toContain('verify');
+ });
+});
+
+describe('BuildProgressPhaseSchema', () => {
+ it('parses every declared phase', () => {
+ for (const phase of BUILD_PROGRESS_PHASES) {
+ const result = BuildProgressPhaseSchema.safeParse(phase);
+ expect(result.success).toBe(true);
+ expect(result.success && result.data).toBe(phase);
+ }
+ });
+
+ it('REFUSES an undeclared phase, and the refusal names the accepted set', () => {
+ const result = BuildProgressPhaseSchema.safeParse('rebuilding');
+
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error('unreachable: an undeclared phase parsed');
+
+ const [issue] = result.error.issues;
+ expect(result.error.issues).toHaveLength(1);
+ expect(issue.code).toBe('invalid_value');
+ // The loud half: the author is told what WAS allowed, not merely that they
+ // were wrong. Compared against the exported vocabulary so the two cannot
+ // drift apart silently.
+ expect((issue as { values?: readonly unknown[] }).values).toEqual([...BUILD_PROGRESS_PHASES]);
+ });
+
+ it('REFUSES the neighbouring vocabulary of the sibling blueprint-progress frame', () => {
+ // `data-blueprint-progress` is a DIFFERENT channel with its own phases
+ // (`designing` / `done`). Folding its vocabulary in here would be the
+ // "one enum for two frames" mistake; `designing` must not parse.
+ expect(BuildProgressPhaseSchema.safeParse('designing').success).toBe(false);
+ });
+
+ it('REFUSES a non-string', () => {
+ expect(BuildProgressPhaseSchema.safeParse(3).success).toBe(false);
+ expect(BuildProgressPhaseSchema.safeParse(undefined).success).toBe(false);
+ });
+});
+
+describe('BUILD_PROGRESS_FRAME_TYPE', () => {
+ it('is the literal both ends select on', () => {
+ // objectui selects parts with `p.type === 'data-build-progress'`; the
+ // `data-` prefix is what makes it a custom data part at all.
+ expect(BUILD_PROGRESS_FRAME_TYPE).toBe('data-build-progress');
+ expect(BUILD_PROGRESS_FRAME_TYPE.startsWith('data-')).toBe(true);
+ });
+});
+
+describe('BuildProgressFrameSchema', () => {
+ it('parses a frame carrying only the required phase', () => {
+ const result = BuildProgressFrameSchema.safeParse({ phase: 'verify' });
+ expect(result.success).toBe(true);
+ expect(result.success && result.data.phase).toBe('verify');
+ expect(result.success && result.data.hop).toBeUndefined();
+ expect(result.success && result.data.tool).toBeUndefined();
+ });
+
+ it('parses a frame carrying both optional fields', () => {
+ const result = BuildProgressFrameSchema.safeParse({
+ phase: 'verify',
+ hop: 4,
+ tool: 'verify_build',
+ });
+ expect(result.success).toBe(true);
+ expect(result.success && result.data).toMatchObject({
+ phase: 'verify',
+ hop: 4,
+ tool: 'verify_build',
+ });
+ });
+
+ it('parses a frame carrying each optional field on its own', () => {
+ expect(BuildProgressFrameSchema.safeParse({ phase: 'data', hop: 0 }).success).toBe(true);
+ expect(BuildProgressFrameSchema.safeParse({ phase: 'data', tool: 'create_seed' }).success).toBe(true);
+ });
+
+ it('is a FLOOR: the panel fields objectui already reads survive the parse', () => {
+ // Regression guard for "tighten it to .strict()". Every key below is read
+ // by `extractBuildProgress` off this same frame today; refusing or
+ // stripping them would break the shipping consumer.
+ const shipping = {
+ phase: 'structure' as const,
+ appLabel: 'CRM',
+ items: [{ type: 'object', name: 'contacts' }],
+ done: 1,
+ total: 4,
+ seq: 12,
+ };
+
+ const result = BuildProgressFrameSchema.safeParse(shipping);
+ expect(result.success).toBe(true);
+ expect(result.success && result.data).toMatchObject(shipping);
+ });
+
+ it('REFUSES an undeclared phase, locating it at `phase`', () => {
+ const result = BuildProgressFrameSchema.safeParse({ phase: 'rebuilding', hop: 1 });
+
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error('unreachable: a frame with an undeclared phase parsed');
+
+ const [issue] = result.error.issues;
+ expect(issue.code).toBe('invalid_value');
+ expect(issue.path).toEqual(['phase']);
+ });
+
+ it('REFUSES a frame with no phase at all', () => {
+ const result = BuildProgressFrameSchema.safeParse({ hop: 2, tool: 'verify_build' });
+ expect(result.success).toBe(false);
+ expect(result.success === false && result.error.issues[0].path).toEqual(['phase']);
+ });
+
+ it('REFUSES a malformed hop and an empty tool name', () => {
+ expect(BuildProgressFrameSchema.safeParse({ phase: 'verify', hop: -1 }).success).toBe(false);
+ expect(BuildProgressFrameSchema.safeParse({ phase: 'verify', hop: 1.5 }).success).toBe(false);
+ expect(BuildProgressFrameSchema.safeParse({ phase: 'verify', tool: '' }).success).toBe(false);
+ });
+});
+
+describe('BuildProgressPhase (type)', () => {
+ it('admits every declared member and is assignable from the array', () => {
+ const everyPhase: BuildProgressPhase[] = [...BUILD_PROGRESS_PHASES];
+ expect(everyPhase).toHaveLength(BUILD_PROGRESS_PHASES.length);
+ });
+});
diff --git a/packages/spec/src/ai/build-progress.zod.ts b/packages/spec/src/ai/build-progress.zod.ts
new file mode 100644
index 00000000000..53034bc672c
--- /dev/null
+++ b/packages/spec/src/ai/build-progress.zod.ts
@@ -0,0 +1,134 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { z } from 'zod';
+import { lazySchema } from '../shared/lazy-schema';
+
+/**
+ * Build-progress PHASE vocabulary for the `data-build-progress` stream frame
+ * (cloud#2172 ruling A).
+ *
+ * ## Producer
+ *
+ * The cloud AI-studio **agent loop** — deliberately not the tool it just ran.
+ * A build turn applies its change through `apply_blueprint` / `apply_edit` and
+ * then keeps working: the loop spends a further POST-APPLY VERIFICATION window
+ * re-reading and re-seeding what it wrote (measured on cloud#1838: 111 seconds
+ * and 9 tool calls *after* `apply_blueprint` returned, one of them the
+ * registered `verify_build` tool — see `PLATFORM_TOOLS_BY_PACKAGE` in
+ * `../system/constants/platform-tool-names`). A tool's own `ctx.onProgress`
+ * handle dies when the tool returns, so a frame emitted in that window can only
+ * come from the loop. WHERE in the loop it is emitted is cloud#2172's decision,
+ * not this module's; what such a frame may SAY is declared here.
+ *
+ * ## Consumer
+ *
+ * The objectui chat panel — `extractBuildProgress` in
+ * `packages/plugin-chatbot/src/mapMessages.ts`, which lifts the reconciled part
+ * onto `ChatBuildProgress` for the build panel in `ChatbotEnhanced.tsx`. That
+ * reader recognises a fixed set and coerces everything else to `'structure'`,
+ * which renders a "still building" spinner. So an UNDECLARED phase is not
+ * merely unlabelled — it reads as the wrong phase, and the user watches a
+ * build that finished two minutes ago still claim to be building
+ * (objectui#7388). The panel's per-phase COPY is objectui's to choose; this
+ * module fixes only the set of values it must be able to tell apart.
+ *
+ * ## Channel
+ *
+ * Unchanged: the `data-`-prefixed custom part described on
+ * `AIToolContext.onProgress` in `../contracts/ai-service`, reconciled in place
+ * under a stable part id. This module adds the vocabulary that prose has always
+ * assumed and never declared; it moves no transport and renames nothing.
+ */
+
+/**
+ * The closed phase vocabulary, in emission-lifecycle order.
+ *
+ * Membership was MEASURED against the two ends of the channel, not designed —
+ * every member below is one a real producer emits or a real consumer already
+ * discriminates:
+ *
+ * - `structure`, `data`, `done` — the consumer's own declared union,
+ * `ChatBuildProgress['phase']` at `packages/plugin-chatbot/src/ChatbotEnhanced.tsx:163`
+ * in objectui, and the set its reader discriminates at
+ * `packages/plugin-chatbot/src/mapMessages.ts:744`
+ * (`d.phase === 'data' || d.phase === 'done' ? d.phase : 'structure'`).
+ * `structure` doubles as that reader's coercion default.
+ * - `verify` — the post-apply verification window objectui#7388 asks the panel
+ * to be able to name, and the reason cloud#2172 ruled this vocabulary into
+ * the spec. Corroborated in this repo by the `verify_build` tool that
+ * `service-ai-studio` actually registers
+ * (`../system/constants/platform-tool-names`).
+ *
+ * Order is the order a build turn passes through these states; it is NOT a
+ * scale. ⛔ Consumers compare phases by VALUE — never by index, and never by
+ * assuming every phase occurs (a turn that applies no seed data never reports
+ * `data`, and `apply_edit` turns need not report `structure`).
+ *
+ * Exported as an array as well as a schema so a consumer can build an
+ * exhaustive per-phase label map without forcing the lazy schema to
+ * construct just to read `.options`.
+ */
+export const BUILD_PROGRESS_PHASES = ['structure', 'data', 'verify', 'done'] as const;
+
+/**
+ * The phase a `data-build-progress` frame is reporting.
+ *
+ * Closed on purpose: an unrecognised value is REFUSED here, loudly, at the
+ * seam that can still say which value was wrong. The alternative is what
+ * objectui#7388 measured — a silent coercion to a neighbouring phase, which
+ * costs the user a correct-looking label describing something that already
+ * finished.
+ */
+export const BuildProgressPhaseSchema = lazySchema(() => z.enum(BUILD_PROGRESS_PHASES));
+
+/**
+ * The stream part name this vocabulary rides, as a Vercel UI-message-stream
+ * custom data-part name. Emitters pass it as `ctx.onProgress({ type, id, data })`
+ * and the objectui reader selects parts by exactly this string, so it is the
+ * one literal both ends must agree on.
+ */
+export const BUILD_PROGRESS_FRAME_TYPE = 'data-build-progress';
+
+/**
+ * The `data` payload of a `data-build-progress` frame — a FLOOR, not a
+ * ceiling.
+ *
+ * Declared minimal on purpose, and deliberately **not** strict. The same frame
+ * already carries the objectui build panel's own presentation fields
+ * (`appLabel`, `items`, `done`, `total`, `seq` — read in `extractBuildProgress`),
+ * which are the consumer's to shape; a strict schema here would refuse every
+ * frame shipping today and would claim ownership of fields this package does
+ * not define. What this schema asserts is the part any consumer may rely on:
+ * a frame says which phase it is in, and may say which verification hop it is
+ * on and which tool that hop is running.
+ */
+export const BuildProgressFrameSchema = lazySchema(() => z.looseObject({
+ /** Which phase of the build turn this frame reports. */
+ phase: BuildProgressPhaseSchema.describe('Build-turn phase this frame reports'),
+ /**
+ * Which hop of the post-apply verification loop this frame is on — the
+ * counter behind the "9 tool calls" cloud#1838 measured, so the panel can
+ * show motion through a window that is otherwise a single flat phase.
+ *
+ * Typed as a non-negative integer rather than pinned to a base: whether the
+ * loop counts its first hop as 0 or 1 is part of the emitter's placement,
+ * which cloud#2172 owns and this card does not decide. ⛔ Consumers render it
+ * as progress, never as an index into anything here.
+ */
+ hop: z.number().int().nonnegative().optional().describe('Post-apply verification hop this frame reports'),
+ /**
+ * Name of the tool the current hop is running, e.g. `verify_build`.
+ *
+ * A free string, not a closed set: the runtime's executable tool set is
+ * registered at boot and legitimately includes plugin-contributed names that
+ * `PLATFORM_PROVIDED_TOOL_NAMES` cannot know about
+ * (`../system/constants/platform-tool-names`). Consumers treat it as a label.
+ */
+ tool: z.string().min(1).optional().describe('Name of the tool the current hop is running'),
+}));
+
+/** A phase from the closed {@link BUILD_PROGRESS_PHASES} vocabulary. */
+export type BuildProgressPhase = z.input;
+
+/** The `data` payload of a `data-build-progress` frame. */
+export type BuildProgressFrame = z.input;
diff --git a/packages/spec/src/ai/index.ts b/packages/spec/src/ai/index.ts
index 55a878bacd2..12f67b2da75 100644
--- a/packages/spec/src/ai/index.ts
+++ b/packages/spec/src/ai/index.ts
@@ -19,6 +19,7 @@
* - Embedding — embedding model + vector store references
* - Usage — token accounting + per-call cost
* - MCP — references and bindings to external MCP servers
+ * - Build Progress — phase vocabulary for the agent loop's `data-build-progress` frames
*/
export * from './agent.zod';
@@ -35,6 +36,7 @@ export * from './mcp.zod';
export * from './knowledge-source.zod';
export * from './knowledge-document.zod';
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
diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts
index b8f95f8022d..8ad36018f81 100644
--- a/packages/spec/src/type-alias-convention.pin.test.ts
+++ b/packages/spec/src/type-alias-convention.pin.test.ts
@@ -271,9 +271,11 @@ import type * as M185 from './shared/epoch.zod.js';
// [#18122] The closed duration vocabulary beside that instant — new module,
// next free index (M186 is `automation/schedule-organization.zod.ts`).
import type * as M187 from './shared/duration.zod.js';
+// [#18451] The build-progress PHASE vocabulary -- new module, next free index.
+import type * as M188 from './ai/build-progress.zod.js';
// ---------------------------------------------------------------------------
-// 783 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
+// 785 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
@@ -1026,6 +1028,18 @@ export type Iso868 = Assert, z.infer< typeof
export type Iso873 = Assert, z.infer< typeof M187.DurationMs > >>;
export type Iso874 = Assert, z.infer< typeof M187.DurationSeconds > >>;
+// ai/build-progress.zod.ts -- the closed build-progress PHASE vocabulary
+// (#18451, cloud#2172 ruling A) and the frame that carries it. The enum has
+// no default and no transform; the frame is a `z.looseObject` whose three
+// members are a bare enum and two plain optionals, so both are the (RISE)
+// case. The frame pin is the load-bearing one: it is deliberately a FLOOR
+// that passes the consumer's own panel fields through untouched, and the day
+// someone gives `phase` or `hop` a `.default()` to 'help' a producer that
+// omits it, author state and parsed state part company -- this is the line
+// that says so by name.
+export type Iso875 = Assert, z.infer< typeof M188.BuildProgressPhaseSchema > >>;
+export type Iso876 = Assert, z.infer< typeof M188.BuildProgressFrameSchema > >>;
+
// shared/value-domain.zod.ts — the ONE standard-domain vocabulary (#14168);
// `SpecifierValueDomainSchema` (Iso758) is an alias of it, so both pins hold
// or fall together. A `z.enum` has no default or transform, the (RISE) case.
@@ -1664,7 +1678,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 783 isomorphic pins', () => {
+ it('still declares all 785 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
@@ -2227,7 +2241,18 @@ describe('ADR-0122 type-alias convention', () => {
// `startTime: durationMs`-style `.default()` or a `.transform()` that
// fills one member from another, this pin is the line that says the alias
// has gained a second shape.
- expect(pins).toHaveLength(783);
+ // 783 -> 785 is #18451's build-progress PHASE vocabulary
+ // (ai/build-progress.zod.ts, new module slot M188): `BuildProgressPhase`
+ // and `BuildProgressFrame`, the (RISE) case twice. The enum is a bare
+ // `z.enum` like the `ConnectorActionEffectSchema` pin that first taught
+ // this count to rise; the frame is a `z.looseObject` of one enum and two
+ // plain optionals. Neither carries a default or a transform, so no
+ // `XParsed` is declared and both come here instead. +2 added.
+ //
+ // Note the number is the same 783 -> 785 the DURATION block above records,
+ // arrived at from the same 783 after #16059's retirement took it back down.
+ // The two entries are different movements that share a pair of endpoints.
+ expect(pins).toHaveLength(785);
// 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