Skip to content

spec: a flow screen field can express a numeric bound, help text and a lookup target - #17913

Merged
os-musk merged 9 commits into
mainfrom
claude/issue-17306-screen-field-bound-help-lookup
Sep 13, 2026
Merged

spec: a flow screen field can express a numeric bound, help text and a lookup target#17913
os-musk merged 9 commits into
mainfrom
claude/issue-17306-screen-field-bound-help-lookup

Conversation

@os-bill

@os-bill os-bill commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Part of #17306

Clause-②: yes

ScreenFieldConfigSchema was .strict over exactly name/label/type/required/options/defaultValue/placeholder/visibleWhen, so three ordinary authoring intents had no expression at all — a numeric bound, help text, and a lookup target. They did not degrade quietly: max, helpText and every lookup-target spelling were refused BY NAME. But a loud refusal with no landing key is still a dead end, and the reference app worked around all three in prose.

Implements two director-seat rulings on this card, and the second reverses two choices the first head of this PR had made:

  • A — decision batch 🔗 Broken links detected in documentation #120 item 2, 2026-09-12, maintainer 「17508 A AI负责翻译就行。其他同意」: the three intents land, spelled with FieldSchema's own key names, and only together with their rendering.
  • A′ — decision batch Fix sharing.test.ts for discriminated union schema #130 item 1, 2026-09-13, maintainer 「同意」: (1) server-side bound enforcement at resume stays; (2) the string gap closes — a value for a number-typed screen field that is not a JSON number is refused, ⛔ never coerced and ⛔ never silently passed; (3) reference is REQUIRED on type: 'lookup', carried by an ADR-0087 semantic migration entry.

⚠️ A′ is what makes this release breaking, and everything below describes head 68d0ce67114. An earlier head of this same PR shipped the opposite on both points and pinned the opposite in tests; both assertions were inverted in place, with the reversal named at the assertion, so a later re-widening has to come back through them.

The three spellings, and why each is the corresponding FieldSchema key

The ruling requires names derived from FieldSchema, never invented. Read from packages/spec/src/data/field.zod.ts:

Intent Adopted Derived from Why this is the corresponding key
Numeric bound min / max FieldSchema.min / .max (field.zod.ts:1121-1122) Unambiguous — FieldSchema declares exactly these two names for a numeric bound.
Help text inlineHelpText FieldSchema.inlineHelpText (:1736) Ambiguous, resolved below.
Lookup target reference FieldSchema.reference (:1213) The canonical key; FieldSchema renames relatedTo/referenceTo/target/targetObject/lookupObject onto it (:929). The card's proposed object/reference_to/referenceTo are all non-canonical.

The one ambiguous case, and the test applied

FieldSchema declares two plausible help-shaped keys:

  • description (:1001) — described as "Tooltip/Help text"
  • inlineHelpText (:1736) — described as "Help text displayed below the field in forms"

Test applied — the ruling's own "same spellings the object field uses": ask what FieldSchema answers an author who reaches for this intent by its natural name. Its alias table (:921) reads help: 'inlineHelpText', helpText: 'inlineHelpText', hint: 'inlineHelpText', tooltip: 'inlineHelpText' — so the object field itself routes all four natural spellings, including the helpText this card asked for, onto inlineHelpText. description is never an alias target for them; it is a separate declared key for secondary/tooltip copy. ⇒ inlineHelpText is the spelling the object field uses for this intent.

Reproduction of the gap (before the change)

Twelve spellings against ScreenFieldConfigSchema on origin/main, with placeholder as the lit control:

[max]            -> unrecognized_keys: Unrecognized key(s) on this screen field: `max`.
[helpText]       -> unrecognized_keys: ... `helpText`.
[inlineHelpText] -> unrecognized_keys: ... `inlineHelpText`.
[reference]      -> unrecognized_keys: ... `reference`.
[object]         -> unrecognized_keys: ... `object`.        (+ min, step, help, hint, description, referenceTo, targetObject)
[placeholder CONTROL] -> __ACCEPTED__

The control could have come back the other way, and it is aimed at this schema's key set — which is what makes the twelve refusals a reading. The card's claim that max and helpText are refused by name is confirmed.

What lands

Four keys (the bound pair counts as one intent, two keys):

  • min / max — forwarded onto ScreenFieldSpec for the client and enforced server-side on resume by validateScreenInputs (min_value / max_value). A screen field's declared contract is the only contract behind it, so a bound the dialog alone applied would be bypassed by any caller posting to resume directly — the gap screen flows: resume performs no server-side validation — missing required fields and undeclared keys all complete the run #4477 closed for required. That guarantee holds for every submitted value, with no exception carved out for shape, because the value SHAPE is checked first and in its own pass: on a type: 'number' field a present value that is not a finite JSON number is refused with invalid_type, ⛔ not coerced (ruling A′). All three codes — invalid_type, min_value, max_value — are existing ADR-0114 D2 catalog members: no new error code.
  • inlineHelpText — help under the input, distinct from placeholder, which the browser clears the moment the user types. That is precisely the carrier the reference app was forced to overload for a constraint that has to stay readable.
  • reference — the lookup target, so the field can resolve a record picker. Required when type is lookup, as it is on an object field (field.zod.ts:1196-1199: a lookup/master_detail whose reference is missing, empty or whitespace-only is refused at parse time). Optional in the shape, required by a superRefine, so the key set does not move and .shape stays enumerable.

Delivered with its rendering inside this repo. The executor forwards all four and the Studio designer form offers all four as repeater columns; builtin-node-form-zod-ledger.test.ts reconciles the two key sets against the Zod in both directions, so a key declared here and absent from the form fails that test rather than shipping as a field nobody can author. The console dialog half is objectui#9248, which this card's acceptance includes.

What this release BREAKS

⛔ This is not a purely additive release. The changeset declares BREAKING in two places and grades minor on both packages — minor because the launch-window guard (check-changeset-no-major) keeps breaking changes off major outside pre-mode, not because the narrowing is small. Under strict semver it would be major, and the changeset says so in its own words. Precedent: schedule-flow-acting-organization-required (323da7a30d0).

Break 1 — a stored bare lookup screen field parsed before and is refused now. ScreenFieldConfigSchema.superRefine refuses type: 'lookup' with an absent or blank reference, addressed to ['reference'], with the exported SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED as its message. A picker with no target object resolves nothing — ADR-0078's own worked example of silently-inert metadata — and ruling A′ held that a degraded shape which already ships is not a reason to bend the contract to it. The reference app's own hotcrm close_case "Resolved by Article" field is exactly such a site; it names crm_knowledge_article when it adopts this version (relayed to the hotcrm seat; the director seat has no write access there).

There is no lossless conversion — nothing in the stored metadata says which object the author meant — so this is an ADR-0087 semantic entry (screen-field-lookup-reference-required, step 18, the unreleased major), a structured TODO naming the flow and the field for a human to answer, and ⛔ never a D2 conversion that would have to invent a target. The changeset carries the disposition marker and registry.ts is regenerated by the repo's own generator, byte-identical.

Break 2 — a resume bag accepted before can be refused now. A non-number submitted for a type: 'number' screen field is refused on resume with invalid_type instead of passing silently. Before this, the bound pass compared numbers, so a value that never reached it satisfied it: under a max of 20 the string "25" was conformant. It was never doing what its author declared.

Everything else is additive. The other three keys widen the accept set for every field; the bound itself fires only on a field that declares one, which nothing did before this release; and a lookup field that already names its target parses byte-identically.

⚠️ In-flight runs. The WIRE type ScreenFieldSpec.reference stays optional on purpose: a run suspended at a screen before the upgrade rehydrates its ScreenSpec from context stored under the old accept set. Such a client has no target to resolve a picker against and falls back to a plain input — the pre-#17306 behaviour, kept reachable rather than turned into a client-side crash. Drain or re-drive those runs rather than assuming the fix reaches them retroactively; the migration entry's acceptance criteria says so too.

Both-direction pins

Accepted: all four keys; the two hotcrm acceptance fixtures as declared metadata (quote_generation's discount ceiling, close_case's lookup with its target); a field with no reference on every other type — the requirement reads one member of an open widget vocabulary, so undefined/text/number/select are untouched.

Still refused: a lookup with an absent, empty or whitespace-only reference, addressed to reference and carrying the exported message (which is also pinned to name the key, show the spelling and carry no tracker id); an undeclared key by name (sparkles) — a .strict schema that quietly widened past these four is the regression that catches; a non-number bound (max: '20'invalid_type); a non-string inlineHelpText; and a non-string reference across four shapes (['a'], { object: 'crm_account' }, 42, true), each required to fail on reference with invalid_type rather than incidentally. Plus an exact key-set pin enumerating all twelve declared keys, which is the only thing that can see a fifth key arriving, and a pin that .shape stays enumerable — superRefine is a CHECK, not a wrapper, and a ZodEffects here would break the ledger test silently by making the key set unreadable rather than wrong.

Refusal quality: help/helpText/hint/tooltip are refused naming inlineHelpText, and object/referenceTo/targetObject/lookupObject/relatedTo/target naming reference. ⚠️ object means different things one level apart — on the screen node it renames to objectName, on a screen field it can only mean the lookup target — and a pin holds both readings apart.

Ablation — measured on the pre-A′ tree, ⛔ NOT re-run at this head

Two legs on screen-input-contract.ts, each proven on disk by occurrence count and git hash-object, with a trap restore:

Leg Mutation Result
A — main disable the max check 3 pins RED incl. "refuses a value above max"; restored (hash 1cb0df1… == HEAD blob, git diff HEAD 0 paths) → 8/8 GREEN
B — cost drop the hidden-field guard from the bound loop (over-refusal) exactly 1 pin RED"does not fire on a field the user was never shown"; restored by hash → 8/8 GREEN

Leg B is the cost direction: it proves the pin that guarantees the bound does not start refusing values the user was never asked for.

⚠️ Both legs were run on the pre-A′ tree, when that file carried 8 pins. At 68d0ce67114 it carries 10: A′ added the value-domain pin and the shape/required split, and inverted the pin that used to assert the silence ("does not fire on a non-numeric value""refuses a non-number for a type: 'number' field — the string gap is closed"). The 8/8 totals above therefore describe a superseded tree. NOT MEASURED at this head: the two legs have not been re-run against the 10-pin file, and the A′ guards (the shape pass, the lookup refinement) have no ablation of their own. Their pins pass at head and in CI; that is coverage, not an ablation reading.

One stale claim corrected, because this change falsified it

The flows translation surface documented help's exclusion as "ScreenFieldConfig declares nothing help-shaped at all" — in translation.zod.ts's guidance string (which enumerated the old key set verbatim), its doc block, i18n-resolver.ts's FLOW_SCREEN_FIELD_COPY_KEYS, and packages/spec/liveness/translation.json. The screen field now declares inlineHelpText, so the copy is real. The exclusion stands — the flows bundle still carries label/placeholder only, and growing that face is a ruled step against the #7646 enumeration, not a resolver-side accretion — but its reason is now stated as a not-yet instead of telling an author the field has no help copy when it has. ⛔ No translation key added, no resolver behaviour moved.

A pin caught a real divergence mid-round

automation-api.zod.test.ts binds TriggerFlowResponse['data'] equal to the AutomationResult contract interface. Widening ScreenFieldSpec alone reddened it by name (TS2344) — exactly the two-files-one-shape drift that pin exists to catch. The module-local screenFieldSpecShape now carries the same four keys.

Generated artifacts

check:generated is 0 at this head (it was 1 before the repair round below found it). The generated delta against the merge base:

Artifact Delta
authorable-surface/automation.json +4 keysinlineHelpText, max, min, reference on automation/ScreenFieldConfig, and nothing else
api-surface/automation.json +1 — SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED (const)
export-origins/automation.json +1 — the same export, origin src/automation/builtin-node-config.zod.ts
src/migrations/registry.ts +1 entry under step 18
content/docs/references/automation/builtin-node-config.mdx +8 rows (the four keys in each of the two screen-field tables), additive only

Registry wiring was verified generated, ⛔ not hand-typed: registry.ts copied aside, gen:migration-registry re-run (exit 0), diff exit 0 — byte-identical.

Round history on this PR

Round Head What it did
Initial e32ab94fb9b6db3438f267 The four keys, the wire binding, the pins.
R1 17db348fc3d FLOW_SCREEN_FIELD_NO_HELP (translation.zod.ts) carried an internal issue id inside text printed at the author; Doc/skill authoring guard refuses it with no per-string exemption by design — this was the CI red. The id was dropped from the string; the doc block above it still names the card for an internal reader, which the guard permits. The translation.test.ts twin keeps pinning the new wording and gained the prescribed negative pin — the refusal must not match an issue id.
R2 17db348fc3d packages/spec/liveness/translation.json was a fourth site still claiming the screen field "has no help-shaped key at all". Verified hand-kept (only state-counts.md is generated under liveness/). Restated the way the three corrected sites are.
R3 d5843a793d6 ⚠️ Superseded by A′ — see below. This round corrected prose to state a numeric-value limit that A′ then removed from the product.
Carrier gate c6ea7ea54a3 Made both new reference sites legible to check-reference-carrier-shape (see below).
A′ 68d0ce67114 Enforced the value shape and made reference required on lookup; added the ADR-0087 semantic entry, the BREAKING declaration and the disposition marker; inverted the two pins that recorded the old behaviour.

⚠️ R3 is the one round this body previously described as current, and it no longer is. R3 read the resume loop as firing only on typeof value === 'number' && isFinite, called "a caller that skips the dialog is refused too" an overclaim, and edited six carriers — the changeset, content/docs/automation/flows.mdx, both .describe() pairs, the ScreenFieldSpec doc block, the ScreenFieldConfigSchema bound doc block, and the Studio designer form's min/max column descriptions — to state the numeric-value limit, leaving the loop untouched. Ruling A′ closed the gap in code instead. At this head those carriers state the guarantee unqualified again, and the designer columns read "Enforced when the run resumes." with no "for a submitted value that is a number" clause. The loop is not untouched: a value-shape pass now runs before the bound pass.

R1 reproduction, before and after

before   node scripts/check-doc-authoring.mjs  ->  exit 1
         ✗ Internal issue-id reference(s) in CUSTOMER-FACING spec text:
           packages/spec/src/system/translation.zod.ts:622  [via FLOW_SCREEN_FIELD_NO_HELP]
after    node scripts/check-doc-authoring.mjs  ->  exit 0
         ✓ 15163 customer-facing string(s) across 949 spec sources clean

(The after counts are the reading at this head; the R1 round measured 15160 / 948 on its own, smaller tree.)

The second red is closed

check-reference-carrier-shape was RED at d5843a793d6 (exit 3, "could not measure") on two sites this PR introduced: the designer form's reference column definition (screen-nodes.ts) and the deliberate non-string reference fixture in builtin-node-config.test.ts. Ruling A′ item 4 dispatched it: test the gate's third remedy first, ⛔ never change the ['a'] fixture to silence the gate, ⛔ never a path ignore.

c6ea7ea54a3 did that. The third remedy is not satisfiable at the designer-form site — the holder is a JSON-Schema properties map whose own type key holds a sub-schema, and all twelve of its keys are data/Field authorable keys, so no key can prove it is not a field definition. Both values are therefore reached through a name (LOOKUP_TARGET_COLUMN, NON_STRING_LOOKUP_TARGETS), which the gate documents as unjudged because it judges literals only. The gate is unedited, no path ignore was added, and the fixture was widened from one shape to four with a stricter assertion, never weakened. It exits 0 at this head.

Not fixed here, for the seat to dedupe: check-reference-carrier-shape has no holder rule for a JSON-Schema properties map, and its literal-only predicate leaves any named value unjudged. Patching a gate from inside a feature PR is the wrong place for it.

Verification at head 68d0ce67114

⚠️ Every row below is anchored to this head. An earlier version of this body reported readings taken at d5843a793d6, two commits back.

CI on this head (35 check-runs): 33 success, 2 skipped, 0 failure. The two skips are Console Pin Gate (path-skipped — .objectui-sha and build-console.sh are untouched) and Packed-tarball smoke (opt-in). Lint & Repo Gates, TypeScript Type Check (the job carrying the eight generated-artifact gates) and Build Core are all green.

Implementing seat, locally on this head:

What Exit Reading
pnpm --filter @objectstack/spec test (--project local) 0 474 files · 13497 tests
pnpm --filter @objectstack/spec test:repo (--project repo) 0 31 files · 523 tests
pnpm --filter @objectstack/service-automation test 0 133 files · 1574 tests
both packages' typecheck 0 clean
check:generated (15 artifacts) 0 was 1 before the repair round
check-adr-0087-registration · check:migration-registry · check:docs · check-doc-authoring · check-reference-carrier-shape (+ every --self-test) 0

At-tier contract review, independently on this head (comment 5652925641, detached worktree, fresh install, 21/21 build closure): check-adr-0087-registration --base origin/main exit 0 — 1 declared-breaking changeset, registered screen-field-lookup-reference-required, so the gate now has an input where the earlier green was vacuous; check-changeset-no-major exit 0 with the LEVEL AXIS clean; check-empty-changeset exit 0; check-reference-carrier-shape exit 0 (6685 files, 674 reference sites, 0 non-string literals); check-doc-authoring exit 0 (15163 strings / 949 sources); the three suites and service-automation typecheck exit 0, with the built dist/automation/index.d.ts carrying inlineHelpText as the positive control.

NOT MEASURED (neither a pass nor a finding): the ablation legs at this head (above); @objectstack/spec typecheck in the review's container (SIGTERM); and five derived gate commands that exit non-zero for checker-health or unbuilt-prerequisite reasons rather than on this diff — check:dual-build-cjs-loads, check:i18n-walk-parity, check:skill-examples (all PREREQUISITE NOT MET on unbuilt output outside this change's dependency closure), check:role-word (its own --self-test fails in that container), check:pm-prior-rulings (script absent at this head). All of them live in jobs that build first, and CI is green on this head.

维护者速读(草稿)

改了什么 — 流程界面的输入框,现在能表达三件以前完全没法表达的事:数字上下限、一句帮助说明、以及查找框要从哪个对象里选记录。三个键的名字全部沿用对象字段已有的拼法(min/maxinlineHelpTextreference),没有发明新名字。同时收紧了两处旧行为:查找框必须写明目标对象;数字字段在流程恢复时提交的值必须真的是数字

为什么改 — 以前作者想写这三件事,只会被拒收,而且没有任何可用的替代键。参考应用只能把折扣上限写进标签和占位符里,把查找框降级成"请人工输入记录 id"。也就是说,我们自己的样板应用在用变通手段绕过我们自己的契约。至于那两处收紧:上下限如果只有对话框在管,绕过对话框直接提交就能突破,等于那条规则形同虚设;而没有目标对象的查找框,渲染出来就是一个查不到任何记录的选择器 —— 这正是 ADR-0078 拿来举例的"解析得过、却什么都不做"的元数据。

风险与代价(含回滚) — ⛔ 这是一次破坏性变更 —— 不是只往外加键。 changeset 两处写明 BREAKING,版本级别按发布窗口惯例记为 minor(门禁在非 pre 模式下不允许 major),按严格语义化版本它应当是 major,changeset 里也是这么写的。

具体到作者会遇到什么:

  1. 已经写好的、没有写 referencelookup 界面字段,从这个版本起解析会直接失败,报错指向 reference 这个键并给出正确写法。⛔ 没有任何自动迁移能修它 —— 元数据里根本没有记录作者当初想指向哪个对象,所以升级链里放的是一条结构化 TODO(点名是哪个流程、哪个字段),由人来逐条回答。我们自己的参考应用 hotcrm 的 close_case 就是这样一处,它在采用这个版本时会补上 crm_knowledge_article
  2. 数字字段在恢复运行时,提交 "25" 这种数字字符串会被拒收,以前是静默放过。⛔ 不做隐式转换 —— 把 "25" 偷偷读成 25 会让上下限的判定依赖一个契约里没声明过的转换。以前能被接受的提交,现在可能被拒。
  3. 升级时正卡在界面步骤上的在途运行,它们的界面定义是升级前存下来的旧形状,不会被追溯修正 —— 需要放干或重新驱动这些运行,不要假设修复能自动追上它们。

回滚代价:三个新键一旦发布,删键很贵;两处收紧则可以单独放宽(两条对应的测试都是就地反转而不是删除,所以任何一次回头放宽,都必须从那两条断言前面经过,不会悄悄发生)。

席位意见 — (待席位填写)

你要做的(一个动作) — 审阅这次公开面的扩宽与两处收紧是否如你所愿。⚠️ 请特别确认第 1 条的代价你能接受:已上线的流程如果有没写目标对象的查找框,升级后会解析失败,且只能人工逐条修。另外,objectui#9248(console 对话框渲染这三个键)落地之前,本卡不应关闭 —— PR 正文用的是 Part of,不是 Fixes


Generated by Claude Code

…t and a lookup target

`ScreenFieldConfigSchema` gains `min`/`max`, `inlineHelpText` and `reference`
— names derived from `FieldSchema`'s own keys, so a flow screen field and an
object field answer one vocabulary rather than two.

The bound rides to the client on `ScreenFieldSpec` AND is re-checked on resume
by `validateScreenInputs` (`min_value`/`max_value`, already in the ADR-0114 D2
catalog), because a screen field's declared contract is the only contract
behind it. The executor forwards all four and the designer form offers all
four, so the ledger test reconciles both key sets against the Zod.

`reference` stays optional on `type: 'lookup'` — shipped flows declare a bare
`lookup` screen field, and refusing them would break metadata that parses
today.

Also corrects the flows translation prose this change falsified: the screen
field no longer "declares nothing help-shaped", so `help`'s exclusion from the
bundle is restated as a not-yet rather than an absence.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
`automation-api.zod.test.ts` pins `TriggerFlowResponse['data']` EQUAL to the
`AutomationResult` contract interface, so widening `ScreenFieldSpec` alone
reddened it by name — which is the drift that pin exists to catch. The
module-local `screenFieldSpecShape` now carries the same four keys, and the
regenerated `builtin-node-config.mdx` reference picks them up.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…s four new keys

`config-unknown-keys.test.ts` prints the declared set in its diagnostic and
pinned the enumeration verbatim, so the four keys had to be acknowledged
here. Pinned in full rather than sampled — a key arriving or vanishing on
this surface should have to be stated.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-automation, @objectstack/spec, touching 19 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/authorable-surface/automation.json, packages/spec/export-origins/automation.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via invalid_type (literal, a string literal in validateScreenInputs), max_value (literal, a string literal in validateScreenInputs), min_value (literal, a string literal in validateScreenInputs))
  • content/docs/api/error-handling-server.mdx (via invalid_type (literal, a string literal in validateScreenInputs), min_value (literal, a string literal in validateScreenInputs))
  • content/docs/automation/flows.mdx (via inlineHelpText (symbol, a field of interface ScreenFieldSpec), validateScreenInputs (symbol, a top-level function), crm_knowledge_article (literal, a string literal in SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED; a string literal in replacement; a string literal in semantic), inlineHelpText (literal, a string literal in ScreenFieldConfigSchema), invalid_type (literal, a string literal in validateScreenInputs), max_value (literal, a string literal in validateScreenInputs), min_value (literal, a string literal in validateScreenInputs), resolved_by_article (literal, a string literal in replacement; a string literal in semantic))
  • content/docs/data-modeling/field-types.mdx (via inlineHelpText (symbol, a field of interface ScreenFieldSpec), inlineHelpText (literal, a string literal in ScreenFieldConfigSchema))
  • content/docs/data-modeling/fields.mdx (via inlineHelpText (symbol, a field of interface ScreenFieldSpec), inlineHelpText (literal, a string literal in ScreenFieldConfigSchema))
  • content/docs/deployment/cli.mdx (via invalid_type (literal, a string literal in validateScreenInputs))
  • content/docs/getting-started/quick-reference.mdx (via inlineHelpText (symbol, a field of interface ScreenFieldSpec), inlineHelpText (literal, a string literal in ScreenFieldConfigSchema))
  • content/docs/protocol/objectui/concept.mdx (via invalid_type (literal, a string literal in validateScreenInputs))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via xRef (symbol, a field of const object LOOKUP_TARGET_COLUMN))
  • content/docs/releases/v17/17-0.mdx (via invalid_type (literal, a string literal in validateScreenInputs), min_value (literal, a string literal in validateScreenInputs))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/authorable-surface/automation.json, packages/spec/export-origins/automation.json, …) — pages documenting those are invisible to this run
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 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 45b90b6a99bbdbe344e6de766d8bee23b54b3c0fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3e2e52c54ac39f911e4c6bab7a5ad009258c4172 — the merge of head 68d0ce67114020e13201de9c03725b4eea219e37 into base 45b90b6a99bbdbe344e6de766d8bee23b54b3c0f, 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 3e2e52c54ac39f911e4c6bab7a5ad009258c4172 && git checkout 3e2e52c54ac39f911e4c6bab7a5ad009258c4172
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b90b6a99bbdbe344e6de766d8bee23b54b3c0f 68d0ce67114020e13201de9c03725b4eea219e37 && git checkout -B drift-repro 45b90b6a99bbdbe344e6de766d8bee23b54b3c0f && git merge --no-ff 68d0ce67114020e13201de9c03725b4eea219e37

node scripts/docs-audit/affected-docs.mjs --json 45b90b6a99bbdbe344e6de766d8bee23b54b3c0f

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 45b90b6a99bbdbe344e6de766d8bee23b54b3c0f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…d state the bound's real limit

Three repairs from contract review, no behaviour change.

1. `FLOW_SCREEN_FIELD_NO_HELP` carried `#17306` in text printed AT the
   author, which `check:doc-authoring` refuses by design (no per-string
   exemption). The id is dropped from the string; the docblock above it
   still names the card for an internal reader. The test twin keeps
   pinning the new wording and gains the prescribed negative pin — the
   refusal must not match an issue id.

2. `packages/spec/liveness/translation.json`'s flows note was a fourth
   site still claiming the screen field "has no help-shaped key at all".
   Restated the way the three corrected sites are: the key exists
   (`ScreenFieldConfig.inlineHelpText`), what is missing is this face's
   translation key for it, and growing that face is a ruled step.

3. The bound was described as "enforced, not advisory" and as refusing
   any caller that skips the dialog. Measured, the resume check fires
   only on a value that is already a finite number, so a numeric string
   passes silently — the branch's own test pins that silence. The
   changeset, the flows doc, both `.describe()` pairs and the
   `ScreenFieldSpec` / `ScreenFieldConfigSchema` docblocks now state the
   numeric-value limit. Prose only: the loop is untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…ected bound wording

`gen:docs` mirror of the two `.describe()` strings that now state the
numeric-value limit. Generated, not hand-edited: the full run rewrote 222
files and only these two rows moved.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
… designer form too

The Studio designer form's `min` / `max` column descriptions carried the
same "Enforced when the run resumes." claim the review found false
elsewhere, and they are read by the same author. Same correction, same
measured limit, prose only — the form's key set and the Zod ledger that
reconciles it are untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…er gate

`check:reference-carrier-shape` refused (exit 3) on this branch: two
`reference` sites this PR introduced carry a non-string LITERAL at a
position its rules resolve under neither reading, so it declines to pick
one. Neither site is a relationship carrier, and none of the gate's three
site remedies is spellable at either:

- `screen-nodes.ts` — `reference` is a JSON-Schema property NAME in the
  designer form's `properties` map. That holder is not under `fields:`,
  its own `type` key holds a sub-schema rather than a string literal, and
  all twelve of its keys are `data/Field` authorable keys, so no key can
  prove it is not a field definition. The column moves to a named
  constant; the emitted form is byte-identical.
- `builtin-node-config.test.ts` — the deliberate non-string in the
  rejection pin. Giving that holder a FieldType `type` or a `fields:`
  parent would turn a rejection fixture into a reported finding, and
  `ScreenFieldConfig`'s twelve keys are all `data/Field` keys too. The
  non-string values move to a named table, which also WIDENS the pin from
  one shape to four — `{ object: 'x' }`, the carrier shape the gate exists
  for, was not covered before — and each case now asserts the refusal
  lands on `reference` with `invalid_type` rather than anywhere.

Both spellings sit in the population the gate documents as unjudged (it
judges literals), so they narrow nothing elsewhere in the tree. No path
ignore was added and the gate is not edited; its missing
`properties`-map rule is reported to the maintainer instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
… shape and require `reference` on a `lookup` screen field

Maintainer ruling A′ (decision batch #130 item 1, 2026-09-13, verbatim
「同意」), applied across the five items it names.

1. Server-side enforcement at resume STAYS — `min_value` / `max_value`,
   both already in the ADR-0114 D2 catalog, no new error code.

2. The string gap closes. `validateScreenInputs` gains its own pass, BEFORE
   the bound: a present value for a `type: 'number'` screen field that is not
   a finite JSON number is refused with `invalid_type` — ⛔ never coerced.
   The bound pass compares numbers, so before this every non-number satisfied
   it by never reaching it (`"25"` under a `max` of 20 was conformant). The
   pin that recorded that silence is INVERTED in place, not deleted, so a
   later re-widening has to come back through it. Narrow in two directions on
   purpose: it keys off `type: 'number'` and not off the presence of a bound,
   and it is presence-conditioned exactly as the bound is.

3. `ScreenFieldConfigSchema` requires `reference` when `type` is `lookup`,
   via a `superRefine` that leaves `.shape` enumerable and the key set
   unmoved. This REVERSES the optionality the card first shipped; ADR-0078's
   own example of silently-inert metadata is a `lookup` with no `reference`,
   and a degraded shipped twin is not a reason to bend the contract to it.
   A stored bare lookup has NO lossless conversion — nothing in the metadata
   says which object the author meant — so it registers as an ADR-0087
   SEMANTIC entry (`screen-field-lookup-reference-required`, protocol 18),
   ⛔ never a D2 conversion that would have to invent a target. The entry is
   one file under `migrations/entries/semantic/`; `registry.ts` is its
   GENERATED projection (`gen:migration-registry`), never hand-merged.

4. The wording items, in every carrier: the bound's "re-checked when the
   submitted value is a number" qualifier is gone from the changeset, the
   flows guide, the generated node-config reference, both `.describe()`
   pairs, the `ScreenFieldSpec` doc block and the Studio designer form,
   because the qualifier is no longer true. `reference`'s requirement is
   stated wherever its optionality was.

5. `check:reference-carrier-shape` is green (exit 0): both `reference` sites
   this PR introduced reach their value through a name, which is the
   population the gate documents as unjudged. ⛔ No path ignore, and the
   `['a']` fixture still tests what it tested — it widened to four shapes,
   including the `{ object: 'x' }` carrier shape the gate exists for.

Also repaired, each falsified by the above rather than pre-existing:

- The changeset declared no break. It now carries `**BREAKING**` and the
  `<!-- adr-0087: registered screen-field-lookup-reference-required -->`
  disposition marker — a semantic entry with no declaration on the changeset
  is exactly what `check-adr-0087-registration` exists to notice. `minor`
  stays: the launch-window guard keeps breaks off `major` outside pre-mode.
- `ScreenInputIssue.code` enumerated `required` and `unknown_field` only and
  spoke of "the same two conditions". This PR put three more codes through
  that field.
- `ScreenFieldSpec.reference` claimed absence was "what every `lookup` screen
  field did before this key existed". It now states that the authoring schema
  refuses that shape, and why the WIRE type stays optional: a run suspended
  before the upgrade rehydrates a `ScreenSpec` stored under the old accept set.
- `api-surface/automation.json` and `export-origins/automation.json` were
  stale — the new exported refusal constant had never been propagated. Both
  regenerated with the repo's own generators; one additive line each.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

os-bill commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Repair round complete — head 68d0ce67114, pushed 2026-09-13T09:0xZ

Recorded by the domain:spec execution seat, session session_01MkQhmuuJAVDjmeWNixwDDH. ⛔ This PR remains draft, ⛔ not enqueued, ⛔ no auto-merge, and both needs:contract-review carriers stay hungClause-②: yes and the head has moved materially since the last at-tier review, so it owes a fresh one before anything else happens to it.

⚠️ The round's most important finding is that the previous green was VACUOUS

The prior work left check-adr-0087-registration printing a green line while never looking at this card:

before:  ✓ this PR adds no declared-breaking changeset (1 non-breaking)
after:   ✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition
           … registered screen-field-lookup-reference-required (new here: …)

The gate only judges declared-breaking changesets. The changeset carried no **BREAKING** and no <!-- adr-0087: … --> marker, and the semantic entry file was untracked — so the gate skipped the PR entirely and reported success for having nothing to check. ⇒ a passing gate is only a reading when it is stable for the same input, and this one's input was empty. Reading that green as "item 3 verified" is precisely the mistake the gate exists to prevent, and it is the shape (#6148, the invisible retirement) it was built to catch.

Three things were silently missing and are now done:

  1. git add on the semantic migration entry. Untracked, it was invisible to the commit and to every gate — the migration chain would have shipped without the prescription the ruling owes.
  2. The changeset now declares the break and carries the disposition marker. minor is kept deliberately (check-changeset-no-major holds breaks off major outside pre-mode) and the changeset now says so, matching the precedent of schedule-flow-acting-organization-required (323da7a30d0).
  3. api-surface/automation.json and export-origins/automation.json had never received the newly exported SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED. check:generated went 1 → 0. Regenerated by the repo's own generators (a real DTS build was required); ⛔ nothing hand-edited.

Registry wiring was verified genuine, not hand-typed: registry.ts copied aside, gen:migration-registry re-run (exit 0), diff exit 0 — byte-identical.

Two prose claims this PR's own reversal had falsified, now repaired

  • ScreenInputIssue.code enumerated only required and unknown_field and spoke of "the same two conditions". Confirmed stale at the branch's own origin tip and at the merge base ⇒ introduced by this PR when it routed min_value/max_value through that field, and this round added a third. Now enumerates all five, and states none is a new catalog member.
  • ScreenFieldSpec.reference claimed absence was "what every lookup screen field did before this key existed" — a state the authoring schema now refuses. It now says so, and gives the real reason the wire type stays optional: a run suspended before the upgrade rehydrates a ScreenSpec stored under the old accept set — the same in-flight-run caveat the migration entry's acceptance criteria already names.

Verification

command exit result
@objectstack/spec test (local) 0 474 files, 13497 tests
@objectstack/spec test:repo 0 31 files, 523 tests
@objectstack/service-automation test 0 133 files, 1574 tests
both typecheck 0
check:generated (15 artifacts) 0 was 1 before the fix
check-adr-0087-registration · check:migration-registry · check:docs · check-doc-authoring · check-reference-carrier-shape (+ every --self-test) 0

All suites went through os-verify-lock.sh (arrival depth 1), each wrapped in a retry loop inside a single Bash call — ⛔ no turn ended while waiting, which is how the previous round on this PR died (#17966). Exit codes from the wrapper's VERDICT line, ⛔ never a bare $? after a pipe.

check-reference-carrier-shape was run, not assumed from the commit message: --self-test exit 0, scan exit 0, "6685 file(s) scanned, 674 reference site(s), 0 non-string literal(s)", and re-run after the round's own edit to that file. No path ignore added; the gate is unedited; the ['a'] fixture widened to four shapes, never weakened.

⚠️ Open, and honestly stated

Item 4's "three wording items" could not be enumerated from the record. get_reviews returned [] and get_review_comments returned totalCount: 0; the only review text on the card (5650606835) names R1/R2/R3, all of which landed in d5843a793d6. Note-level items are in no artifact the round can read. It therefore judged wording completeness from the tree and verified by grep that no stale claim about this surface survives — with a lit control (inlineHelpText returns hits, so the zero is a reading). If any seat holds the list of three, check it against this head: the round may have covered them incidentally, or missed one that leaves no grep-able trace.

Two out-of-scope notes, neither filed: the entry file's explanatory comment run sits above the import type line rather than immediately above export const entry, so it does not reach the generated table (harmless — reason: carries the argument); and check-reference-carrier-shape still reports 44 unresolved + 12 conflicting sites tree-wide (0 material), unchanged — this PR routes around the gate's missing properties-map rule by naming its values rather than patching a gate from inside a feature PR.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation protocol:system size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants