Skip to content

feat(rest): the public form submit answer carries the engine-generated fields its thank-you panel names - #28479

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-c1oruc
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-c1oruc

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #28443. This is the server step of #28005 (ruling B, record 6545210989; names approved in record 6548758413). The spec half landed in #28440; the ui half (#28444) follows this one.

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:匿名公开表单提交接口 POST /api/v1/forms/:slug/submit 的回包,除了新记录的 id,现在还会带上 fields:表单「感谢页」里用 {{record.字段名}} 点名的、由引擎在插入时生成的字段值(今天只有自动编号 autonumber,比如工单号)。感谢页没点名任何字段的表单,回包仍然只有 { id }。
  • 为什么改:web-to-case 这类公开表单,访客提交后需要看到自己的工单号。Public form thank-you text cannot name the record it created: no template slot, and the submit answer carries the id only #28005 裁决 B 定了顺序:spec 先、server 次、ui 后;spec 的名字(PublicFormSubmitResponseSchema、thankYouRecordFieldNames)已在 feat(spec,lint): a form's thank-you panel names the record's engine-generated fields, and the public submit answer has a declared shape #28440 合入,本 PR 是 server 这一步。
  • 风险与回滚:这是一次放宽:匿名访客能多看到被点名的自动编号值。插入后存下的其它值(包括提权钩子从访客无权读的记录里算出的值)一律不回。服务端在回包时再按字段的当前类型核对一次,不是自动编号的名字直接丢弃,所以发布后把字段改成文本类型也不会泄露。回滚即 revert 本 PR,回包退回 { id }。
  • 先看:packages/rest/src/form-routes.ts 的 thankYouAnswerFields(两条丢弃规则)和提交处理末尾组装 answer 的几行;packages/rest/src/public-form-submit-answer.test.ts 新增的 [#28005] 一组用例。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/public-form-submit-answer.test.ts,预期 10 条全绿,其中点名自动编号的表单回包恰为 { id, fields: { case_number } }。
  • 需要你做的:无,席位落地。

What changes

The answer. After the insert, the submit handler reads thankYouRecordFieldNames(match.form.submitBehavior) (@objectstack/spec/ui), the one reading of which names a thank-you panel holds. When it returns at least one name, thankYouAnswerFields builds fields from the row createData answered, and the body is typed PublicFormSubmitResponse. A panel with no token, or a form with any other submitBehavior, answers { id } exactly as before.

Two drop rules, applied at answer time. A named field reaches the answer only if both hold:

  1. Type. The object's current declaration has the name as a field whose type is in RUNTIME_OWNED_FIELD_TYPES (@objectstack/spec/data, today autonumber). A field retyped after publish fails this rule. So does a name that is no declared field: a system column such as created_at, a lookup such as owner_id, or a prototype key such as constructor.
  2. Value. The answered row carries the name as a string or a number. A name the row lacks is dropped, never answered as null: a field masked by the grant's result masker, one stripped as internal: true, or one never generated. A null or any other shape is dropped too.

fields is left out when no name survives. The row is the one createData already answered, so this adds no second read. It is stripped of internal: true fields and masked for the grant. The object declaration is read at most once per submission: the read the required / file / option judgements already make is now shared with the answer through a memo.

Route ledger. The POST /api/v1/forms/:slug/submit row names responseSchema: 'PublicFormSubmitResponseSchema', with its conformance test cited in the note.

Comments and docs that stated the answer. These now say { id } plus the panel-named engine-generated fields:

  • the [#22437] comment in form-routes.ts and the private registerFormEndpoints docblock in rest-server.ts;
  • the internal-fields tripwire disposition. It moves from no-record-echo to protocol-ingress, because the values come off createData's stripped record;
  • content/docs/ui/forms.mdx §4 (the success response and the minimal client) and the masker bullet, and content/docs/ui/public-data-collection.mdx.

Tests

packages/rest/src/public-form-submit-answer.test.ts, new [#28005] block. Every answer is parsed with PublicFormSubmitResponseSchema (strict):

  • A panel naming an autonumber field answers { id, fields: { case_number } }. No other stored value (derived, defaulted, owner, email, timestamps) appears in the serialized body.
  • A name in both title and message is answered once.
  • A panel with no token answers { id } only.
  • Type rule: a field retyped to text after publish is dropped. Names that are no declared field (created_at), a lookup (owner_id) or constructor are dropped, and the autonumber beside them is kept.
  • Value rule: a name the answered row does not carry is dropped, and so is a stored null.
  • The ledger row names PublicFormSubmitResponseSchema, and that name resolves to the very schema the cases parse with.

rest-write-response-internal-fields.tripwire.test.ts: the disposition change above.

Whole package on ab25d2a7: pnpm --filter @objectstack/rest exec vitest run: Test Files 363 passed (363), Tests 7166 passed | 352 skipped (7518). pnpm --filter @objectstack/rest typecheck: exit 0 (tsc, plus the test-layer check). packages/client route-ledger-response-schema.test.ts + client-url-conformance.test.ts: 10/10.

Ablations

Each mutation went through scripts/ablation-replace.mjs on packages/rest/src/form-routes.ts. The anchor landed (count 1 → 0), and the file was restored with the blob equal to HEAD (bd027a8f4c2a). The suite imports ./rest-server from src, so no dist was involved.

mutation red (of 10)
answer the whole stored row as fields (answer.fields = { ...result.record }) 3: the autonumber case, the title+message case, the system-column case
type rule removed (if (typeof type !== 'string' || !RUNTIME_OWNED_FIELD_TYPES.has(type)) continue; → void type;) 2: the retyped-to-text case and the system-column case
value rule removed (if (typeof value !== 'string' && typeof value !== 'number') continue; → void 0;) 2: the absent-from-row case and the stored-null case

A first version had a third check (the row has the name as an own key). Its ablation stayed green, because the value rule already drops an absent name. It was folded into the value rule (commit ba54dc43), so each remaining check is one an ablation can red.

Live measure (showcase, --fresh)

Premise, on 9b1dc992 (main after #28440): an admin authored probe_case (a case_number autonumber CASE-{0000}) and a public form view whose thank-you message names {{record.case_number}}. The anonymous submit answered {"id":"AVTOnpdO_UpgsK3G"} with HTTP 201, while the stored row read back by the admin carried "case_number":"CASE-0001". The showcase contact-us form answered {"id":…} too.

After, on ab25d2a7 (same recipe, fresh boot):

  • token form → {"id":"2nE-CA41J2muTSu-","fields":{"case_number":"CASE-0001"}} · 201
  • contact-us (no token) → {"id":"65dfSz6YqZFzzpvC"} · 201
  • case_number retyped to text (PUT /meta/object/probe_case?force=true), then submitted with "case_number":"FORGED" in the body → {"id":"H1HHBhQH941n-pjM"} · 201 (the name was dropped)

Acceptance notes

  • fields is omitted, not {}, when every named field is dropped. The card says fields appears "only when it returns at least one". This PR reads that as at least one surviving value, so the body never carries an empty map. A renderer reading fields?.[name] sees the same thing either way.
  • A retype's publish gate. Retyping an autonumber to text is refused with 409 DESTRUCTIVE_CHANGE unless forced. After a forced retype, the view that names the field is not re-judged by thank-you-record-field, because that rule runs on a view or page write. The answer-time type rule is what holds that case, as the card intended.
  • Not touched, outside the file surface: the docblock of packages/verify/src/handle.ts (run) still says the public form route "answers { id }". It is true for every form whose panel names no field. content/docs/releases/v18/18-0.mdx says the same thing and is release-owned.
  • Docs for the panel token itself (that a thank-you panel may name an autonumber field and the console fills it) belong to the ui half console: the public form thank-you panel fills its record field tokens from the submit answer (#28005 ui half) #28444. The console does not fill the tokens yet, so this PR documents only the wire answer.
  • Branch claude/project-thread-c1oruc is the card thread's platform-assigned branch; claim amendment 6549411063 names it.

Verification

Local, on ab25d2a7: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 91 families. Each was run with stdin from /dev/null, and its exit was captured by redirect before any pipe. All 91 exit 0. Four first exited non-zero on build state, not on the diff. check:plugin-teardown-shape --self-test (exit 3, a shallow clone) was re-run after fetching its pinned fixture commit. check:skill-examples (exit 3, no declarations) was re-run after building @objectstack/client{,-react}. check:dts-closure and check:dual-build-cjs-loads (exit 1, packages built with OS_SKIP_DTS=1) were re-run after a forced full declarations build of every non-ui package. --ran: 91 derived famil(ies) accounted for — 91 run, 0 NOT-MEASURED, exit 0. Scoped lint, the repo's eslint with --no-inline-config over packages/rest/src: exit 0. Run record: server-seat-1/r2026-10-31/out/28443-gates-ran-ab25d2a7.list.


Generated by Claude Code

…enerated fields

POST /forms/:slug/submit answers PublicFormSubmitResponseSchema: the
created id, plus `fields` holding the stored values of exactly the names
thankYouRecordFieldNames(form.submitBehavior) returns. Each name is
re-checked at answer time against RUNTIME_OWNED_FIELD_TYPES on the
object's current declaration and dropped otherwise, and only a string or
number the createData row carries is answered. The route ledger names the
schema; the docs state the answer.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzzJHmF6J8Rdez8TWu
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 4 documentable anchor(s).

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

  • content/docs/releases/implementation-status.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v12.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v17/17-3.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via RestServer (symbol, a top-level class))

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
  • 1 cross-cutting symbol(s) contributed no route anchor: responseSchema (6 routes)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 212 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 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 5e706e7a9e938925de326ed52b6d65475f43d095 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5e706e7a9e938925de326ed52b6d65475f43d095

⚠️ 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 5e706e7a9e938925de326ed52b6d65475f43d095 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head ab25d2a7f6f7b4cc37745c107e3b76e64ffa5d75 · 2026-10-31T22:44Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ab25d2a7f6f7b4cc37745c107e3b76e64ffa5d75
Local-runs: none
Scope: PR #28479 (Fixes #28443, the server step of #28005 ruling B 6545210989, names A 6548758413; claim 6549394777 + amendment 6549411063; dev report 6550023100), 8 files, +229/−17 against merge-base 9b1dc992 (main after the spec half #28440), merge-tree clean. Tier S: no docs/adr/**, no skills/**, no packages/spec; the prose changes are two content/docs/ui pages.
Inputs: card #28443 (Wanted, ⛔ no other field, ledger line, Tests), the PR body (速读, ablation table, live measure on --fresh), the full diff, origin/main for the four approved names (PublicFormSubmitResponseSchema protocol.zod.ts L2804: id + optional fields: record<string, string | number>, .strict(); thankYouRecordFieldNames view.zod.ts L4793: thank-you only, title then message, de-duplicated; RUNTIME_OWNED_FIELD_TYPES field.zod.ts L304 = autonumber), check-runs (read 2026-10-31T22:51Z: all 50 latest-per-name check-runs completed, 36 success + 14 skipped, none failed, none running) — 50 checks

① Ruling → code

  • Letter B item 2: the submit answers id plus exactly the engine-generated fields the thank-you text names, nothing else; the elevated-hook comment stays true. The diff is exactly that: after createData, thankYouRecordFieldNames(match.form?.submitBehavior) gives the names; thankYouAnswerFields(names, objectDef, result.record) keeps a name only when the object's current declaration types it in RUNTIME_OWNED_FIELD_TYPES and the answered row carries it as a string or a number; the body is typed PublicFormSubmitResponse and fields is omitted when none survives. The row is createData's own answer (already internal-stripped and grant-masked), no second read; the object declaration is memoised with the existing required / file / option read.
  • The card's ⛔: both drop rules are the card's answer-time type re-check (an object retyped after publish, which the thank-you-record-field authoring rule cannot see) plus the value rule that keeps a masked or stripped name from answering null; a name that is no declared field (system column, lookup, constructor) has no string type and drops. The [#22437] comment is reworded, not weakened.
  • Ledger: the submit row names responseSchema: 'PublicFormSubmitResponseSchema', and the test resolves that name to the very schema it parses with. Out of the file surface only by the docblocks and the two docs pages that stated { id }, which the claim's surface names.

② Declarations

③ Pins and ablations

  • Ten cases, each answer parsed strict: autonumber named → { id, fields: { case_number } } and no other stored value on the wire (derived, staff, defaulted, email, timestamp, record, droppedFields all asserted absent); title + message → once; no token → { id }; retyped to text → dropped; created_at / owner_id / constructor dropped beside a kept autonumber; row lacks the name → dropped; stored null → dropped; ledger row resolves to the schema. Tripwire disposition moves no-record-echo → protocol-ingress with the reason stated.
  • Three ablations (whole row answered; type rule; value rule), each red on the matching cases; a redundant own-key check was removed because its ablation stayed green. Live measure on --fresh: { id } before on 9b1dc992, { id, fields: { case_number: "CASE-0001" } } after, no-token form unchanged, forced retype to text + forged body value → { id }. 91 derived families exit 0, rest 363 files / 7166 green, client ledger conformance 10/10.

④ Traps and should-fix

Implemented-by: claude/project-thread-c1oruc
Reviewed-by: triage#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-tesla · session_01L9qibz3ZUbZ8Qcn1rDw9yc

维护者速读

席位意见:契约复审 PASS,使用的正是批准的名称。可以直接落地。

席位核对

ACCEPT · seat domain:server#1 · head ab25d2a7 · 2026-10-31T22:52Z

  1. Shape: Fixes #28443. Draft, base main, 8 files, +241/−19, with a patch changeset for @objectstack/rest. No packages/spec and no ADR path.
  2. Ruling: Public form thank-you text cannot name the record it created: no template slot, and the submit answer carries the id only #28005 B 6545210989, names A 6548758413. The submit answers PublicFormSubmitResponse: id, plus fields for exactly the names that thankYouRecordFieldNames returns.
    • Each name is kept only if it is typed in RUNTIME_OWNED_FIELD_TYPES at answer time and carried as a string or number.
    • fields is omitted when no name survives.
    • The ledger row names the responseSchema.
  3. Contract review: lane PASS 6550210024 at this head. It declares Clause-②: yes (widening) and Metadata-protocol: none.
  4. Gates / check-runs:
    • Dev report 6550023100: the live premise and fix were both measured, 10/10 tests passed, rest passed 363/363 files, three ablations went red, all 91 derived gates exited 0, and --ran showed 0 NOT-MEASURED.
    • On head ab25d2a7: 50 check-runs completed, 36 success, 14 skipped, 0 failed.
  5. Accepted deviations: none. Omitting fields when every name drops is the card's "only when there is at least one", and the seat confirmed it. Two release-owned or verify lines still say { id }; that is true for a form with no token, and they are named in the body.
  6. Landing: check-governed-merges --pr 28479 reports NOT governed. The seat readies the PR and arms auto-merge under the maintainer's 「按 pm-dispatch 审过的pr你可以自主落地」. The ui half console: the public form thank-you panel fills its record field tokens from the submit answer (#28005 ui half) #28444 follows in the ui lane.

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 size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: the public form submit answer carries the record fields its thank-you panel names (#28005 server half)

1 participant