spec(ai): declare the closed build-progress PHASE vocabulary the agent loop emits during post-apply verification - #18528
Conversation
…ent loop emits during post-apply verification The `data-build-progress` frame has shipped as prose only: `AIToolContext.onProgress` documents the channel and its example carries a `phase`, but no declaration ever said which phases exist. The consumer filled that gap by guessing — objectui's `extractBuildProgress` coerces any value it does not recognise to `structure`, which renders a "still building" spinner, so the 111 seconds a build turn spends being verified after it finished read as the wrong phase rather than an unknown one. Declare the vocabulary where both ends can read it: - `BUILD_PROGRESS_PHASES` / `BuildProgressPhaseSchema` — a CLOSED enum whose members were measured, not designed. `structure`/`data`/`done` are the consumer's own declared union; `verify` is the post-apply window, corroborated here by the `verify_build` tool `service-ai-studio` actually registers. Per-member provenance is recorded in the source. - `BuildProgressFrameSchema` — the frame's floor: a required `phase` plus an optional verification hop counter and tool name. Deliberately loose, because the panel fields the consumer already reads ride the same frame and are its to shape. - `BUILD_PROGRESS_FRAME_TYPE` — the one literal both ends select on. The docblock names producer (the cloud agent loop — not the tool, whose `onProgress` handle dies when it returns) and consumer (the objectui chat panel). Where in the loop the frames are emitted, and what copy each phase gets, stay with their owners. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…move, and declare the changeset `check:generated` proved exactly five stale: api-surface, export-origins, declaration-map, the rendered reference pages, and the unknown-key strictness ledger. Regenerated only those. api-surface reads 6 added / 0 removed on `./ai` — a purely additive published surface, which is what the `minor` and the `Clause-②: yes (widening)` declaration record. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…and correct the counts a new schema moves Three gate-named corrections, all mechanical consequences of the new export: - ADR-0122 (`check:spec-parsed-alias`): the bare alias is the AUTHOR state, so `BuildProgressPhase` becomes `z.input`. Both schemas are isomorphic — no default, no transform anywhere in either tree — so neither gets a redundant `XParsed` synonym; they are pinned in `type-alias-convention.pin.test.ts` instead, which is the route the ADR prescribes and the gate reads as its exemption registry. tsc is what proves the two new pins, and the pin count moves 783 -> 785 in all three places that state it. - `check:llms-txt`: the hand-kept inventory ships to AI consumers inside the tarball; `ai` 11 -> 12 and the total 200 -> 201, with the domain's subject list naming the new module rather than only its count. - `check:quick-reference-counts`: the AI section was complete at 11 of 11, so it stays complete at 12 of 12 with a row for the new reference page. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0a1d46a3790e737a33d47eb11c6a6f823108b0e5 && git checkout 0a1d46a3790e737a33d47eb11c6a6f823108b0e5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 24964153e2843303fb591debb2c060934153c809 3233eea6733eb621d76ed7386c558e290c987497 && git checkout -B drift-repro 24964153e2843303fb591debb2c060934153c809 && git merge --no-ff 3233eea6733eb621d76ed7386c558e290c987497
node scripts/docs-audit/affected-docs.mjs --json 24964153e2843303fb591debb2c060934153c809 |
…nce table `check:quick-reference-counts` reds because a new reference page moved M, the number of pages the tree publishes. Correcting M is the whole requirement; listing the new page in the section table is discretionary polish and is not this card's. The section is now "11 of 12", the same shape the API section already carries at "17 of 31". Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsAccept set
Public surface
② Semver levelChangeset ③ Boundary flagsThe dev report declares
Implemented-by: VERDICT: PASS Generated by Claude Code |
Clause-② carriers cleared — provenanceBoth carriers stripped in one stroke by the dispatching seat,
How this verdict was produced. This seat's measured served model is Independence pair: a Boundary item carried to #18552, filed before this PR lands so a closing keyword cannot lose it. The review named it as a required correction to shipped prose; ⛔ the seat did not let it ride unnamed, and ⛔ did not fix it in-branch, because that would move the head and void an otherwise complete review record. Pre-landing checks: ① review PASS on record ✅ · ② Generated by Claude Code |
Fixes #18451
Clause-②: yes (widening)
Declares the build-progress PHASE vocabulary on
@objectstack/spec/ai, executing ruling A onobjectstack-ai/cloud#2172. Thedata-build-progressframe has shipped as prose only —AIToolContext.onProgressdocuments the channel (contracts/ai-service.ts:639) and its examplecarries a
phase(:644) — while nothing ever declared which phases exist.Membership was measured, not designed
The ruling names a CLOSED enum, so each member is sourced. Provenance is recorded in the module
itself, per member:
structure,data,doneChatBuildProgress['phase']atpackages/plugin-chatbot/src/ChatbotEnhanced.tsx:163in objectui, and the set its reader discriminates atpackages/plugin-chatbot/src/mapMessages.ts:744— `d.phase === 'data'verifyverify_buildtoolservice-ai-studioactually registers (packages/spec/src/system/constants/platform-tool-names.ts).What I could not source, and therefore did not add: the producer's own emission list. The
ruling lives on
objectstack-ai/cloud#2172and thecloudrepository is not reachable from thissession — the ruling text here is taken from the director seat's filing on the card, verbatim, and
the cloud issue is NOT reported as checked. No member was rounded out to fill that gap.
designingwas considered and REJECTED: it belongs to the siblingdata-blueprint-progressframe, a different channel with its own reader, and a test asserts it does not parse here.
What this adds
BUILD_PROGRESS_PHASES/BuildProgressPhaseSchema/BuildProgressPhase— the closedvocabulary in lifecycle order. An out-of-vocabulary value is refused and the refusal names the
accepted set.
BuildProgressFrameSchema/BuildProgressFrame— the frame's FLOOR: a requiredphaseplusan optional
hopandtool. Deliberatelyz.looseObject, not strict: the panel fields theconsumer already reads (
appLabel,items,done,total,seq) ride the same frame and areobjectui's to shape, so a strict schema here would refuse every frame shipping today.
BUILD_PROGRESS_FRAME_TYPE— the one literal both ends select on.hopis typedz.number().int().nonnegative()rather than pinned to a base on purpose: whetherthe loop counts its first hop as 0 or 1 is part of the emitter's placement, which cloud#2172 owns
and this card explicitly does not decide.
toolis a free string, not a closed set, because theexecutable tool set is registered at boot and legitimately includes plugin-contributed names
PLATFORM_PROVIDED_TOOL_NAMEScannot know about.api-surfacereads 6 added / 0 removed on./ai— purely additive, which is what theminorchangeset and the
Clause-②: yes (widening)declaration record.Tests
Everything below was run on final head
3233eea673, afterpnpm --filter @objectstack/spec build.packages/spec/src/ai/build-progress.test.ts— 16 tests, exit 0.pnpm --filter @objectstack/spec test—Test Files 484 passed (484),Tests 13789 passed (13789),VERDICT command-exit 0.pnpm --filter @objectstack/spec typecheck— exit 0. Note the maintsc --noEmitprogram does NOT include
**/*.test.ts(verified with--listFiles: 0 hits for both testfiles, 1 for the source); the test layer is covered by the
tsconfig.test.jsonleg, where agrepfor my files over the raw error output returns zero hits with a lit control finding 5elsewhere. That is what proves the two new ADR-0122 assertions.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 108 families; all 108 were run on this head and reconciled with exit codes via
--ran: "108 derived, 108 run, 0 NOT-MEASURED, 0 UNRUN". 107 exit 0.pnpm lint(eslint . --no-inline-config) — exit 0, run in full, not narrowed.The one non-zero, and why it is not this diff
pnpm check:cross-package-test-inputsexits 1, namingpackages/cli/test/init-created-files-summary.e2e.test.tsdescendingpackages/spec/dist/.This diff touches neither that test nor
packages/cli. Two legs on a throwaway worktree atpristine
origin/main(97233b90ca), with zero of my source changes:packages/spec/dist/present: exit 0packages/spec/dist/copied in: exit 1, byte-identical findingdist/removed again: exit 0So the trigger is the presence of a gitignored build output — which AGENTS.md requires before the
dist-reading gates — not this change. Filed as an out-of-scope finding in the report.
Ablation — the pin is not vacuous
Run from the committed state, each leg proving the mutation reached disk (occurrence counts of
both the removed and the injected text, plus a blob hash differing from the HEAD blob) before any
verdict was read, and restoring by observed state (
git checkout HEAD -- path, blob hash back to53034bc672c8de6dbe800cd923bba8b7f237c66d,git diff HEADempty) rather than by exit code:z.enum(BUILD_PROGRESS_PHASES)becomesz.string()— the vocabulary stops being closedz.looseObjectbecomesz.strictObject— the floor becomes a ceilingAcceptance notes
File-surface accounting — all 17 files, in three buckets
The claim declared
packages/spec/src/ai/**, thecontracts/ai-service.tsdocblock, the generatedapi-surface/export-origins/ reference-page artifacts, and.changeset/. Nothing here is ahand edit I chose — bucket 3 is empty.
Bucket 1 — generated (10 files). Proven, not asserted: every one was reverted to the merge base
fb6b2c369eand re-produced by re-running the generators on that tree(
check:generated --fixwrotegen:schema,gen:api-surface,gen:export-origins,gen:docs,gen:strictness-ledger;gen:declaration-mapwas run explicitly because the aggregate does notflag it when its companion is reverted in the same stroke).
git diff HEADover all ten came backempty — byte-identical reproduction — and the tree was then restored with
git restore --source=HEAD --staged --worktree, index and tree both clean.packages/spec/api-surface/ai.jsongen:api-surfacepackages/spec/export-origins/ai.jsongen:export-originspackages/spec/declaration-map/ai.jsongen:declaration-mappackages/spec/authorable-surface/ai.jsongen:schema(viacheck:authorable-surface)packages/spec/json-schema.manifest/ai.jsongen:schemadocs/audits/2026-07-unknown-key-strictness-ledger.counts.mdgen:strictness-ledgercontent/docs/references/ai/build-progress.mdx(new page)gen:docscontent/docs/references/ai/index.mdxgen:docscontent/docs/references/ai/meta.jsongen:docscontent/docs/references/index.mdxgen:docsBucket 2 — required by a gate, with the gate's own red quoted (3 files).
packages/spec/src/type-alias-convention.pin.test.ts—pnpm check:spec-parsed-aliasred:"
BuildProgressFrameis the AUTHOR state ofBuildProgressFrameSchemaand nothing names itsPARSED state. Declare
export type BuildProgressFrameParsed = …next to it … or, ifz.inputand
z.inferofBuildProgressFrameSchemaare the same type, pin it inpackages/spec/src/type-alias-convention.pin.test.tsinstead." Both schemas are isomorphic, andthat file's own header states why the pin beats a synonym: "a permanent synonym is a name an
author can only pick wrongly." So this is the ADR-prescribed route, not a preference — the file
is also the gate's machine-readable exemption registry, which is why the registration must live
there and nowhere else.
packages/spec/llms.txt—pnpm check:llms-txtred:"
[count]domainaideclares 11 schemas;src/ai/holds 12" and "heading declares 200schemas;
packages/spec/src/holds 201". Hand-kept by design — the gate says "there isdeliberately no
gen:llms-txt" — and it ships to AI consumers inside the npm tarball.content/docs/getting-started/quick-reference.mdx—pnpm check:quick-reference-countsred:"section "AI Protocol" declares "of 11 schemas" but
content/docs/references/ai/publishes12 page(s)". Reduced to the gate-required minimum: a one-line
11 of 11to11 of 12. Anearlier revision of this branch also added a table row for the new page; that was discretionary
polish, not needed for correctness, and it was dropped — the section now carries the same
partial shape the API section already has at
17 of 31.Bucket 3 — my own judgement calls: none.
If every path above reads as bucket 1 or 2, the claim's surface wording was narrower than what one
new exported schema mechanically forces in this tree.
contracts/ai-service.tswas NOT edited. The claim allowed its docblock "if the frameexample should name the enum". The new module's own docblock points at that file, and leaving
the contract file untouched keeps the diff off a path a sibling PR is in (
email-service.ts,PR docs(spec): scope the email-template locale-floor claims to a call that names a locale #18482, is the only
contracts/file in flight). Naming the enum from theonProgressdocblock is a good follow-up, not a requirement of this card.
XParsedaliases were added. Both schemas are isomorphic, and ADR-0122's pin file statesthe reason a permanent synonym is worse than none: it is a name an author can only pick wrongly.
The pins are the prescribed route and tsc proves them.
main. The branch is based onfb6b2c369e. Independence was verified forthese paths, and the three hottest spec artifacts are sharded per domain precisely so parallel
spec PRs stay textually disjoint. The merge queue rebuilds on the merged generation. Declared
rather than assumed.
extractBlueprintProgressreader in objectui collapses anunknown
data-blueprint-progressphase to'designing'by the same pattern. It is out of scopehere and was already ruled on in objectui#7388 (its docblock makes "only
doneisauthoritative" a contract, so the posture is deliberate). Carrier: whoever picks up the
data-blueprint-progressvocabulary, if that is ever ruled.Scope this card does not decide
Per the card: not the UI copy for each phase (objectui#7388's), and not the emitter's placement in
the cloud agent loop (cloud#2172's).
Authored by the
domain:specexecution seat, sessionsession_01KB5PFtxuy1x3dcR5gxudx6.Generated by Claude Code