Skip to content

feat(spec): offer agent.structuredOutput on the agent form and drop its stale not-enforced-yet ledger row - #21398

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21374-structured-output-form-offer
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21374-structured-output-form-offer

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21374
Clause-②: no

The form reconciliation ledger kept agent.structuredOutput unoffered on a reason that stopped being true: the key is enforced and graded live. This PR deletes that row and decides the offer on a measurement, as triage 5948883894 directed. The measurement says the Studio composite control carries the block, including its free-form JSON Schema record, so the key is offered.

What changes

file change
packages/spec/src/system/metadata-form-zod-reconciliation.test.ts The agent / structuredOutput omit row (base lines 408 to 414) is deleted. The gate's own rule (lines 307 to 309) gives this decision to the enforcement. No other line changes, and nothing new checks a row's why text.
packages/spec/src/ai/agent.form.ts One row in the AI Configuration section: { field: 'structuredOutput', type: 'composite', helpText }. It is spelled like memory and guardrails, with no hand-written fields, so Studio derives the sub-rows from the served JSON Schema.
four packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts Regenerated with pnpm i18n:extract. Two new leaves per locale: the row's label and help text. The zh-CN, ja-JP and es-ES leaves are authored, not left as copies of the English source. After the second extract, no locale's source-hashes.generated.ts holds an entry for them, so those three files are not in the diff.
packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts The per-locale translated-label control moves from 659 to 660. The test measured 660 (expected 660 to be 659) before the pin was edited.
.changeset/21374-agent-structured-output-form-offer.md @objectstack/spec minor (a new form offer), @objectstack/platform-objects patch (two catalog leaves). Neither is breaking.

The measurement (triage item 2)

Where it was read: objectui at the .objectui-sha pin 31971ff1e28f, from a local clone with git show. main moved the pin to 89cad75d5570 while this was in flight. SchemaForm.tsx and widgets.tsx are byte-identical between the two pins (git diff --stat is empty), and merge-base --is-ancestor 31971ff1e28f 89cad75d5570 exits 0. So the same reading holds at the pin this branch now carries.

What the control is given: the served node, measured with the emitter's own code. z.toJSONSchema was run with markErasedAuthoringInput, then stripUnauthorableProperties: the steps toJsonSchemaSafe in metadata-protocol takes. agent takes the output arm (24 top-level keys), and structuredOutput is served inline, with no $ref:

child served node
format, fallbackFormat type: string, enum: [json_object, json_schema]
schema type: object, propertyNames: {type: string}, additionalProperties: {}, and no properties
strict, retryOnValidationFailure type: boolean
maxRetries type: integer, minimum: 0
transformPipeline type: array, items: {type: string, enum: [trim, parse_json, validate]}

How a composite row renders (packages/app-shell/src/views/metadata-admin/, at the pin):

  1. SchemaForm.tsx resolveFieldFace (line 837): fieldSpec.type === 'composite' gives { kind: 'composite' }.
  2. FieldControl (lines 2081 to 2099): the row has no fields, so derivePropertyNames(schema) (line 3376) lists every property of the served node. CompositeField (line 2518) renders one FieldRow per property, with the child node from pickSubSchema(schema, 'composite', name) (line 2487). A child edit writes onChange({ ...obj, [field]: v }), and an untouched child is never written.
  3. Each child's face is decided by resolveFieldWidget, then resolveFieldFace:
    • schema, the open record: inferWidget (line 519) answers object-fields for type: object, and no name detector matches. object-fields is not a key of WIDGETS (widgets.tsx line 2934). isObjectForm is false (no properties), the node is not an object-row array, and the name is not in KNOWN_PASSTHROUGH_WIDGETS (line 230). So the face is { kind: 'raw-json', hint: 'object-fields' }, which is RawJsonEditor (line 3218). The editor shows JSON.stringify(value, null, 2). Each edit runs JSON.parse and passes the parsed value up. Text that does not parse shows Invalid JSON and passes nothing, and empty text passes undefined.
    • transformPipeline, the enum array: inferWidget (line 510) answers multiselect, a registered widget, so MultiSelectWidget renders it (widgets.tsx line 1396). Its options are items.enum. toggle() (lines 1428 to 1443) rebuilds the selection as options.map(o => o.value).filter(v => set.has(v)): declared enum order, duplicates collapsed, and an empty selection passed up as undefined.
    • format and fallbackFormat are selects over enum. strict and retryOnValidationFailure are switches, and maxRetries is a number input.

Round-trip verdicts ("carries" means view it, edit it and save it back unchanged):

  • schema: carries. The stored record is shown whole as JSON. An untouched value is never re-emitted, so it saves back byte-equal. An edited value saves exactly the JSON that was typed. The parse still refuses an untyped subschema at its path, and that refusal is unchanged.
  • transformPipeline: carries the step set. An untouched value saves back unchanged. A toggle writes the steps in declared enum order (trim, parse_json, validate) with duplicates collapsed, so this control cannot author another order. Every pipeline written in this repo is already in enum order (git grep transformPipeline: the agent tests and the conversion fixtures). This repo cannot read whether the cloud runtime honours another order, so this PR makes no claim about it.
  • Precedent, the same slot: action.ai.outputSchema comes from the same aiJsonSchemaSlot factory. It is served with an identical node and is already offered through the action form's ai row, where it reaches the same raw-JSON face. Offering structuredOutput adds no new face.

Verification

All runs are on this branch. Gates and suites are at the merged head 5870ff91d7 (origin/main 69a12a0952 merged in, with a full turbo run build --filter='./packages/**' of 71 tasks first).

Ablation of the pin. Two legs, both written with scripts/ablation-replace.mjs (anchor hit, then blob restored equal to HEAD and git diff HEAD empty), run at 77dedb2c86. The suite was pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/system/metadata-form-zod-reconciliation.test.ts, which imports the form registry from source, so no build sits between the edit and the test.

  • Leg A, the offer removed with the row still deleted: red, 1 failed and 75 passed, agent.(root): accepted by the Zod but unauthorable in the form … expected [ 'structuredOutput' ] to deeply equal [].
  • Leg B, the offer kept with the stale row re-inserted: red, 1 failed and 75 passed, agent.(root).structuredOutput: the form offers it now — drop the ledger entry. The first attempt at leg B was a no-op: the tool refused it because the replacement contained its own anchor, so no test ran. The retry used an anchor the replacement does not contain.
  • Restored, both files' blobs equal HEAD: f37667ab25a0 (form) and 2350eaed10be (test).

Suites:

suite result
pnpm --filter @objectstack/platform-objects test Test Files 59 passed (59) · Tests 949 passed (949)
pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 (whole package) Test Files 647 passed (647) · Tests 18407 passed | 1 todo (18408)
pnpm --filter @objectstack/spec --filter @objectstack/platform-objects run typecheck both Done
pnpm --filter @objectstack/spec check:generated ✓ All 15 generated artifacts are up to date

Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 87 commands against merge base 69a12a095, with no stale-tree warning. Each was run, and its exit code was recorded before any pipe. --ran reconciliation: 87 derived, 87 run, 0 NOT-MEASURED, 0 UNRUN. This includes pnpm check:i18n (platform-objects in sync (11 bundle(s))), pnpm check:doc-authoring, pnpm check:nul-bytes, the changeset gates (check-adr-0087-registration: no declared-breaking changeset; check-changeset-no-major: no major) and pnpm check:i18n-coverage (621 baselined untranslated string(s), none new, run in addition to the derived list).

Semver (A5): Clause-②: no. AgentSchema's accept set does not move, and check:authorable-surface and check:api-surface are green with no regeneration. The form definition ships in @objectstack/spec's dist, and the catalogs ship in @objectstack/platform-objects's dist. Both new leaves were found in the built platform-objects dist. So both packages publish a change, and this is a changeset, not skip-changeset.

Acceptance notes

Observations only. None meets a filing class here.

  • The schema child's hint. It shows objectui's announced fallback line, widget object-fields — falling back to JSON until a custom renderer is registered. The action form's ai.outputSchema shows the same line today. Polish on the objectui side.
  • English sub-row labels. The seven sub-row labels come from the served schema (prettify(name), help text from each describe()), so they read in English in every locale. memory's and guardrails' sub-rows read the same way: a schema-derived composite has no catalog key for its children.
  • Stale neighbouring help texts in agent.form.ts. planning names "strategy, max iterations, replan" (the schema declares only maxIterations). memory names "short-term" (removed, with a refusal and guidance on the key). Nobody owns them yet, so they are noted, not filed.
  • Unenforced neighbours are offered. memory and lifecycle are offered as composites while their describes say [EXPERIMENTAL — not enforced], the reverse of this ledger's not-enforced-yet discipline. That discipline is a reconciliation-ledger convention, not a published contract, and this card does not touch them.

Generated by Claude Code

claude added 5 commits October 2, 2026 09:40
…stale omit row

The reconciliation ledger excused `agent.structuredOutput` as declared but
not enforced. The key is enforced now (liveness `live`), and the ledger's
own rule hands the offer decision to the enforcement. Studio's composite
control carries every child of the block, the free-form JSON Schema
record included (raw JSON editor), so the key is offered as a composite
beside the other agent blocks and the row goes.

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…e structuredOutput row

`pnpm i18n:extract` adds the agent form's new `structuredOutput` row
(label and help text) to the four metadata-form bundles. The zh-CN, ja-JP
and es-ES leaves are authored, not left as copies of the English source,
and the re-run extract drops their source-hash entries by itself.
Changeset: spec minor (a new form offer), platform-objects patch.

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…he structuredOutput row

The per-locale positive control counts the metadata-form labels authored
in each of zh-CN, ja-JP and es-ES. The agent form's new row adds one, so
the measured count is 660 (was 659) in every locale.

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
The console pin moved on main after the reading; the renderer files the
reading cites are unchanged across the move, so the changeset names the
renderer rather than one sha.

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see

Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ecb6ca0258176466767588a6805363387c5777a6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 447015d16eaca22294645530ba650739e2474492 — the merge of head 5870ff91d74be07f621380dbfb0c17b9c85c466a into base ecb6ca0258176466767588a6805363387c5777a6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 447015d16eaca22294645530ba650739e2474492 && git checkout 447015d16eaca22294645530ba650739e2474492
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ecb6ca0258176466767588a6805363387c5777a6 5870ff91d74be07f621380dbfb0c17b9c85c466a && git checkout -B drift-repro ecb6ca0258176466767588a6805363387c5777a6 && git merge --no-ff 5870ff91d74be07f621380dbfb0c17b9c85c466a

node scripts/docs-audit/affected-docs.mjs --json ecb6ca0258176466767588a6805363387c5777a6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5870ff91d74be07f621380dbfb0c17b9c85c466a
Local-runs: none

Inputs: card #21374 (body; comments 5948883894 triage, 5949271559 claim, 5951402104 os-dev-report), PR #21398 (body, 8-file list, net diff against main at the head: +33/-8, comment 5951354292), the 33 check-runs on the head (read 2026-10-02T11:38Z), repo files at the head by git show, and objectui's renderer at the pin by REST (packages/app-shell/src/views/metadata-admin/SchemaForm.tsx and widgets.tsx at 89cad75d5570 and 31971ff1e28f). Adversarial read: the dispatch order and the dispatching seat's conclusions were not inputs.

① Derived judgments

  1. AgentSchema accept set: unchanged — RIGHT. The diff touches no Zod file. The one packages/spec/src/** non-test change is agent.form.ts (+1 row). check:api-surface and check:authorable-surface are not re-run here; they are read through the head's Lint & Repo Gates check-run (in progress at this read) and the dev's recorded lines.
  2. Published surface: agentForm gains one row — RIGHT. { field: 'structuredOutput', type: 'composite', helpText } in the ai_configuration section, spelled like planning / memory / lifecycle (no hand-written fields, so sub-rows are schema-derived). The key is declared at the agent root (agent.zod.ts:442), not retired, and graded live in packages/spec/liveness/agent.json:105-111 with cross-repo evidence; the describe reads "enforced on every final answer by the cloud AI runtime". The help text carries no tracker number and no model identifier.
  3. Ledger row deletion — RIGHT. The deleted omit row's why cited "[EXPERIMENTAL — not enforced]" and liveness experimental; both are gone at the head. The ledger's own rule (metadata-form-zod-reconciliation.test.ts:307-309) says delete and decide at enforcement. With the offer present, keeping the row reds the ledger's "the form offers it now — drop the ledger entry" assertion (test :1030); with the row deleted and no offer, the root direction reds at :941. The dev's two ablation legs match those two lines. No limb reading a why text was added (triage item 3 held).
  4. The measurement (triage item 2) — RIGHT, re-read at the pin. The head and base both carry .objectui-sha 89cad75d5570; SchemaForm.tsx (blob 2dfa3d3f0d6f) and widgets.tsx (blob 4cce2e379a68) are byte-identical there and at the dispatch pin 31971ff1e28f, which is 64 commits behind and an ancestor (compare status ahead, behind_by 0). Confirmed at the cited lines: resolveFieldFace:837 gives composite; FieldControl:2081-2099 falls back to derivePropertyNames:3376 (every served property) when fields is absent; CompositeField:2518 renders one FieldRow per property via pickSubSchema:2487 and writes onChange({ ...obj, [field]: v }) on a child edit only, so an untouched child is never rewritten. The schema child: inferWidget:519 answers object-fields for type: object; it is not a WIDGETS key (widgets.tsx:2934 onward), the node has no properties so it is not a nested form or an object-row array, and it is not in KNOWN_PASSTHROUGH_WIDGETS:230, so resolveFieldFace returns raw-json and RawJsonEditor:3218 renders it: JSON.parse on each edit then onChange(parsed), invalid text shows the error and emits nothing, empty text emits undefined. The name detectors read at the pin (icon / *Icon, color / *Color, *When / condition names, the secret regex) match none of the seven child names. The transformPipeline child: inferWidget:510 answers multiselect, registered at widgets.tsx:2951; toggle():1428-1443 rebuilds the selection in declared enum order, collapses duplicates, and emits undefined for an empty set. The round-trip verdicts in the PR body ("carries" for schema; "carries the step set" for transformPipeline) hold on this reading.
  5. Sub-row required markers — RIGHT as shipped, noted. Schema-derived sub-rows carry no required flag; the Zod requires format (no default, agent.zod.ts:141). A block with schema filled and format blank is refused at save at structuredOutput.format with the enum, exactly as the Source tab refused it before. No accept-set movement; the model and guardrails composites share the shape.
  6. Catalog bundles — RIGHT. Four *.metadata-forms.generated.ts gain a structuredOutput { label, helpText } leaf each. The en help text is byte-equal to the form's; the label Structured Output follows the generator's convention (useGrouping is Use Grouping). zh-CN, ja-JP, es-ES are authored, and no source-hashes entry for the leaf exists at the head (grep: none). check:i18n and check:i18n-stale-fill are read through Lint & Repo Gates (in progress).
  7. Translated-label pin 659 to 660 — RIGHT. object-lifecycle-panel-echo-decisions.test.ts counts .label leaves translated per locale; one new label authored in all three locales moves it by exactly one in each. The comment line is appended in the file's own ledger style.
  8. No other enumeration of the agent form's rows exists — RIGHT. No test outside the two touched packages names agentForm; inside spec, only repeater-item-titles.test.ts imports it, generically. No docs page lists the AI Configuration rows (the docs drift check also listed nothing). check:generated's 15 artifacts include no form projection.
  9. Scope and governance — RIGHT. 8 files, all inside the claim's declared file surface; no objectui edit; no governed path (Governed Surface Queue Guard: success). Head repo equals base repo.

② Semver level

  • Changeset .changeset/21374-agent-structured-output-form-offer.md: @objectstack/spec: minor, @objectstack/platform-objects: patch. Body carries Clause-②: no, no ADR-0087 marker (nothing breaking, nothing removed or renamed), no model identifier, and states the two editing limits truthfully.
  • Clause-②: no — RIGHT. The accept set does not move (no Zod edit), and the published surface read off the exports map does not move: agentForm was already exported and its type is unchanged (check:api-surface: "public API surface + factory signatures unchanged" in the dev's run, re-read through Lint & Repo Gates). What moves is the form's content served to Studio, the authoring door, which the card itself frames as "the Studio surface, not the schema's accept set". The PR body line 2 carries the same line (Check Changeset: success).
  • minor for spec — RIGHT. A new form offer is a feature, and the identical precedent feat(spec): offer useGrouping on the number field form, and delete its stale not-enforced-yet omit row #20934 (useGrouping offered on the field form plus its stale not-enforced-yet row deleted) shipped as spec minor / platform-objects patch / Clause-②: no; the 45-row offer landed under Minor Changes too. The dev's flag "the PM may prefer patch" is answered here: minor stands.
  • patch for platform-objects — RIGHT: two catalog leaves per locale, nothing else.
  • Not skip-changeset — RIGHT: both packages publish changed bytes.

③ Boundary flags

open_questions: empty. Nothing to answer.

Dev deviations, each answered:

  1. origin/main 69a12a0952 merged in at the head after a stale-tree warning: fine; the net diff against main is the 8 files, and the recorded gate and suite runs are at the merged head.
  2. Pin moved mid-flight: verified above (item 4); head and base both carry 89cad75d5570, the renderer files are blob-identical, the reading stands.
  3. Three derived gates exited 3 before builds, then 0: a prerequisite ordering, not a verdict; CI reads them on its own tree.
  4. Ablation leg B's first attempt was a refused no-op and was retried with a disjoint anchor: the retried leg is the one that counts, and both legs red on the lines this ledger owns (item 3).
  5. Six labels applied by automation; no skip-changeset: fine, a changeset exists.
  6. Spec bump minor vs patch: answered in ②, minor.
  7. Commit trailers model-free: verified on all five commits, each ends with Claude-Session: plus the Co-authored-by: Claude trailer at the noreply address; matches the AGENTS.md rule, and the harness reminder's model-named form was rightly not copied.
  8. The whole spec suite run twice (pre-merge and at the merged head): fine.

Out-of-scope findings (four, noted not filed), judged:

  • (a) Stale neighbouring help texts in agent.form.ts: planning names "strategy, max iterations, replan" while the schema declares only maxIterations; memory names "short-term" while memory.shortTerm is refused with guidance (ADR-0013 D3). Author-shown copy that names a tombstoned key is a metadata-authoring trap under Prime Directive 10's corollary, not polish. ESCALATED to the seat: one card (domain:spec, the two help texts), outside this PR's claim; not a FAIL item here, pre-existing.
  • (b) memory and lifecycle are offered as composites while their describes read "[EXPERIMENTAL — not enforced]": a Studio row advertising an unenforced block runs against the same corollary. Pre-existing and in the ai: guardrails, memory, structuredOutput, lifecycle and tool.outputSchema are enforced by the agent runtime (5 keys), starting with the guardrails the built-in agents already declare #20274 family's territory. ESCALATED to the seat to decide a card or a fold into that family.
  • (c) The objectui fallback hint on the schema child: polish, objectui carrier; noted is right.
  • (d) transformPipeline order normalisation on toggle: the spec describes the steps without an order rule, the control refuses nothing and rewrites nothing untouched, and the changeset states the limit. Noted is right.

Check-runs on the head, read 2026-10-02T11:38Z: 23 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in): path-filtered or opt-in), 7 in progress (Lint & Repo Gates, Test Core 1/6 and 3/6 to 6/6, Type Check · workspace). An in-progress gate is read as in progress, not as a pass; the pre-landing "every check green" read is the seat's, after this record. No failure on the head at this read.

Implemented-by: claude/issue-21374-structured-output-form-offer
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec(ai): the form reconciliation gate keeps agent.structuredOutput unoffered on a reason PR #21367 falsified — the offer is undecided

2 participants