Skip to content

fix(rest): an anonymous public-form submit answers the created id, not the stored row (#22437) - #22462

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22437-public-form-submit-answers-id
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22437-public-form-submit-answers-id

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22437
Clause-②: no (narrowing)

What changed

POST /api/v1/forms/:slug/submit (the anonymous public-form submit) now answers 201 with the created record's id and nothing else: { "id": "..." }. It used to relay the protocol's whole create answer, { object, id, record, droppedFields? }, where record is the row as stored after the insert pipeline. That served the anonymous caller every field it never sent, including defaults and fields a beforeInsert / afterInsert hook stamped. A hook running elevated (runAs: 'system') can derive such a field from existing records that the caller's grant may never read.

The shape is triage's call (6076863767): the id only. Projecting to the form's declared fields was rejected, because a hook may rewrite a declared field too. The write path is unchanged: same whitelist, same server-managed anchors, same grant, same hooks. No second read builds the answer. The handler sends { id: result.id } from the createData result it already holds.

Measured first: what the door answered before and after (real boot, keys and statuses only)

Driven through the real door on bootStack: real SecurityPlugin, ObjectQL, SQL driver, hook sandbox, REST and auth. The fixture is synthetic. A form-target object has two declared fields (subject, email), two hook-stamped fields and one defaulted field. A second object holds an existing record. Four boots: plain and elevated hook, each on a deployment with no guest set and one that declares the guest set (reading neither object). The elevated hook is a beforeInsert L2 body with runAs: 'system'. It looks the submitted email up among existing records and stamps the match.

boot before (da159f74e + pins only) after (d7eb45da9)
plain, no guest set 201 · top-level id, object, record · record keys: created_at, created_by, email, id, match_kind, match_ref, organization_id, owner_id, owning_business_unit_id, stage, subject, updated_at, updated_by (13, equal to the stored row's keys) 201 · top-level id only · no record
plain, guest set same as above 201 · top-level id only
elevated hook, no guest set same 13 keys; record.match_ref equals the existing record's id (the derived value reached the anonymous caller) 201 · top-level id only; the stored row still holds the stamp (the hook ran)
elevated hook, guest set same as above, the existing record's id served 201 · top-level id only; stamp stored

Every answer was application/json with the id a string at the top level, before and after.

H2: the console's success screen (measured at objectui origin/main 47b1f0bb7)

  • apps/console/src/components/FormPage.tsx submitPublic posts the door and returns res.json().
  • The public path's default behavior is thank-you (resolveSubmitBehavior). It reads nothing off the answer beyond res.ok.
  • The redirect arm builds its token scope from the submitted payload, then unwrapTransportEnvelope(result)?.record layered over it, then readCreatedRecordId(result). That reads the top-level id after stripping a { success, data } envelope when one is present. This door answers a bare body, so the top-level id is the key path. It is kept, and pinned on the wire.
  • created-record is the internal path's default only. The public path never reaches it.
  • objectui's own tests already stub the public submit as answering no record (FormPage.redirect.test.tsx, "interpolates from the submitted values on the anonymous path"). No objectui change is needed.
  • After this change, a redirect token over a submitted field still resolves from payload, and {{record.id}} from the answer. A token over a server-filled field resolves empty (urlValue reads absent as empty), which is exactly the disclosure closed here.
  • Shipped forms with a redirect over an undeclared field: zero hits. git grep for submitBehavior across examples/ and the test trees finds three forms, all thank-you (the instrument's control). No kind: 'redirect' appears anywhere in examples/, apps/, packages/qa or the test trees, and no {{record. token appears in an example or a public-form fixture.

H3: no spec declaration of this door's answer

  • packages/spec declares CreateDataResponseSchema for the protocol's createData method, not for this REST door.
  • The route ledger row POST /api/v1/forms/:slug/submit (rest-route-ledger.ts) is disposition: 'public', with no client and no responseSchema.
  • The OpenAPI builtin paths invent no response schemas.
  • git grep of packages/spec/src for the door finds only the server-managed field set (security/public-form.ts), slug normalisation, and conversion fixtures.
  • So narrowing this answer changes no published spec contract, and packages/spec is untouched.

Translation-flip sweep

Repo-wide git grep for the door (forms/ together with /submit) across test, fixture and docs trees. Every pin that read the stored-row echo is flipped to assert the new meaning:

  • packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts: the security(forms): two public-form doors meet a field masking rule wrongly — the submit read-back serves masked fields stored to an anonymous submitter, and a picker whose first display field is masked answers 403 to every caller it applies to #21062 masking pin keeps its subject (both masked fields: one collected, one defaulted). It now asserts each is absent at any depth of the answer, and that its stored value rides no key. The answer is exactly { id }. A system read of that id still holds the stored values (the scene is real), and the forged owner still never lands.
  • packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts: the authz-row public-form-managed-anchors proof and the status/source hook-stamp pin cited by records-forms.json. Both now read the landed row through a system read of the answered id, not off the anonymous answer. Each also asserts the answer is exactly { id }. The forged-anchor and stamped-default assertions are unchanged in substance.
  • packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts: the door's disposition moves from protocol-ingress ("201s its result") to no-record-echo with the new reason, because the old reason became false.
  • Read and left alone, because they assert no echo: public-form-routes.test.ts, public-form-routes.stored-row.test.ts, public-form-withdrawal.test.ts, public-form-intake-availability.test.ts, the withdrawal and walled-intake dogfood files (they read code / status / raw text of a refusal, or count landed rows), showcase-public-form-redirect.dogfood.test.ts (it counts landed rows), the platform checklist items (they read the landed row as staff), and console.public-form-redirect.test.ts.
  • content/docs/ui/forms.mdx: the documented 201 answer is now { "id": ... }, with the authenticated-read remedy. The redirect section says what a token resolves from on the public path.

New pins

  • packages/rest/src/public-form-submit-answer.test.ts runs on the registered handler, with a createData double that answers a stored row, a derived stamp and a drop report. The body is exactly { id }, and no stored value appears in the serialized answer. Through the real Hono transport: 201, a bare object (no success / data), and a non-empty string at the top-level id.
  • packages/qa/dogfood/test/public-form-submit-answer.dogfood.test.ts is the elevated-beforeInsert pin on a real boot, in both deployment shapes. A system read shows the hook found the existing record and stamped it. The answer names no stored field at any depth, carries none of the derived or defaulted values under any key, and is exactly { id }. Control: 201, and the top-level id names the row that landed.

Ablation (committed fix, then mutate, then restore)

  • Mutation: node scripts/ablation-replace.mjs put the echo back with an identifiable marker (res.status(201).json({ ...result, ablation22437: true })). On disk the anchor count was 0 and the marker count 1. pnpm --filter @objectstack/rest build ran, then ablation-dist-preflight.mjs @objectstack/rest ablation22437 found the marker in dist/index.js and dist/index.cjs.
  • Mutated: the rest pin failed 2 of 2. The dogfood pins failed 6 of 8 (both new, both masking, and both showcase submit cases). The 2 that passed are the showcase resolve and list-denial cases, which read no answer.
  • Restore: blob equal to the HEAD blob, git diff HEAD empty. Then a rebuild, and --absent printed "marker absent from all 6 built files" and "working tree clean against HEAD". Rest 2 of 2 passed, dogfood 8 of 8 passed.
  • Direction: the pins go red without the fix (the expected direction).

Re-run on the merged head 74a7828f5, after the absence assertions were widened to any depth of the answer:

  • Mutated: the rest pin failed 2 of 2 and the dogfood pins failed 6 of 8. The first failing assertion is now the per-field one: "pfmask_code is absent from the answer, at any depth" and "stored field created_at must not reach the anonymous caller".
  • Restored: blob equal to HEAD, --absent clean, rest 2 of 2 and dogfood 8 of 8 passed.

Tests and gates (head 74a7828f5)

All at head 74a7828f5, which merges origin/main 2b61f2d9d (no conflict):

  • pnpm --filter @objectstack/rest test: exit 0. 266 files passed; 4962 tests passed, 326 skipped.
  • pnpm --filter @objectstack/rest typecheck: exit 0. That is tsc --noEmit plus check:test-typecheck over tsconfig.test.json, whose program lists both touched rest test files (counted with --listFilesOnly).
  • pnpm --filter @objectstack/dogfood typecheck: exit 0. Its program lists all 3 touched dogfood files.
  • The whole dogfood suite, through the verify lock (pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2): exit 0. 234 files passed, 1 skipped; 1840 tests passed, 9 skipped.
  • pnpm lint, the full run (eslint . --no-inline-config): exit 0.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, with no paths, at this head derives 94 commands. All 94 exited 0, and --ran reconciles with "94 run, 0 NOT-MEASURED".
    • Two of them first refused with exit 3 (prerequisite not met: dist/ absent for packages outside the dogfood build closure). They passed after a full turbo run build: check:skill-examples ("262 prose examples type-check across 3 surface(s)") and check:dual-build-cjs-loads ("107 published require entry point(s) across 66 package(s) load").
    • The dispatch's 69 named gates are a subset of the 94. The other 25 are documentation families, derived from the content/docs/ui/forms.mdx edit.

Patch round 1 (head 38e9287e3)

  • .changeset/22437-public-form-submit-answers-id.md: minor, fix(rest)!:, Clause-②: no (narrowing), a BREAKING note, and one ADR-0087 marker, not-required (no-migration-prescription). node scripts/check-adr-0087-registration.mjs --base origin/main exits 0 and reports "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition". node scripts/check-changeset-no-major.mjs --base origin/main --event (fed this PR's payload) exits 0: "this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch".
  • packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts: header prose only, with no test logic. The file passes through the verify lock, 1 of 1.
  • The derived gates were re-derived with no paths at 38e9287e3: the same 94 commands. All 94 exit 0, and --ran reports "94 run, 0 NOT-MEASURED". pnpm check:changeset-gate-self-tests and pnpm check:doc-authoring exit 0.
  • merge-tree against origin/main 3ca71b6e0 is clean, so main was not merged.

Acceptance notes

  • Clause-② spelling: no (narrowing), BREAKING. The answer drops fields a host may have read, so it is a narrowing. Triage 6076863767 named it that way, and the seat's review answered the dev's open question with B and corrected the claim. The changeset is minor, with a fix(rest)!: summary, the Clause-②: no (narrowing) line, a BREAKING note, and the ADR-0087 disposition not-required (no-migration-prescription), which check-adr-0087-registration accepts. The door's answer has no spec declaration, so there is no tombstone and nothing for objectstack migrate meta to rewrite, and packages/spec is untouched. The migration line stays: a host that read the record off this answer reads it through an authenticated read.
  • The zero-set masking header, corrected in patch round 1. packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts said the masker's zero-set reading is still reached on a real boot through the submit's echo. It now says that reading reaches no caller through any door on a real boot. The form grant's read-back is still masked inside the engine, but the submit answers the created id alone. The masked fields' absence from that answer is pinned by public-form-read-back-masking. The masker's zero-set output itself is pinned at the security middleware, in plugin-security's public-form-grant-masking.test.ts, on a synthetic harness rather than a boot. Prose only; the file passes 1 of 1 at 38e9287e3.
  • The drop report is gone from this door. The answer no longer carries droppedFields. Measured: the console reads no droppedFields, and this door never set X-ObjectStack-Dropped-Fields. A public form that declares a readonly field already dropped the visitor's value with no other signal. Noted, not filed.
  • Unrelated drift, not edited. In content/docs/ui/forms.mdx, the "Current renderer status (2026-08-11)" note says the console does not substitute {{record.field}} tokens. objectui origin/main submitRedirect.ts substitutes them.
  • The docs page content/docs/ui/forms.mdx is domain:devx's and is declared on [PM seat] domain:devx @ objectstack — 🟢 os-bill · session_01LYXc6ckoWuZyVZpWYizdMh #6023 (done by the seat).

Generated by Claude Code

claude added 4 commits October 9, 2026 09:21
WIP: the pins, the flipped dogfood pins, the docs page and the changeset.
The handler change follows the measured before-table.

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
The anonymous POST /forms/:slug/submit relayed createData's whole answer,
so the caller was shown the row as stored after the insert pipeline,
hook-derived fields included. It now answers 201 with { id } alone, at
the top-level key the console reads.

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/ui/forms.mdx (via /forms/:slug/submit (route, a path literal in a comment on a changed line))
  • content/docs/ui/public-data-collection.mdx (via /forms/:slug/submit (route, a path literal in a comment on a changed line))

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

  • content/docs/releases/v17/17-7.mdx (via /forms/:slug/submit (route, a path literal in a comment on a changed line))

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

Coarse fallback — 17 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 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 3ca71b6e05efbfc6ec5908c8c263fee6cceba389

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

The changeset moves to minor with a breaking summary, the
Clause-② narrowing arm and its ADR-0087 disposition. The zero-set
masking dogfood header no longer claims a door the submit answer closed.

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 11:32
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 11:32
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 7806a14 Oct 9, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22437-public-form-submit-answers-id branch October 9, 2026 11:52
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/l tests tooling

Projects

None yet

2 participants