Repository navigation
Commit 7806a14
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 #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 #6023 (done by the seat).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 166a94f commit 7806a14
9 files changed
Lines changed: 492 additions & 55 deletions
File tree
- .changeset
- content/docs/ui
- packages
- qa/dogfood/test
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
214 | | - | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | | - | |
218 | | - | |
219 | | - | |
220 | | - | |
221 | | - | |
222 | | - | |
223 | | - | |
| 217 | + | |
224 | 218 | | |
225 | 219 | | |
| 220 | + | |
| 221 | + | |
226 | 222 | | |
227 | 223 | | |
228 | 224 | | |
| |||
271 | 267 | | |
272 | 268 | | |
273 | 269 | | |
274 | | - | |
| 270 | + | |
275 | 271 | | |
276 | 272 | | |
277 | 273 | | |
| |||
368 | 364 | | |
369 | 365 | | |
370 | 366 | | |
371 | | - | |
| 367 | + | |
372 | 368 | | |
373 | 369 | | |
374 | 370 | | |
| |||
Lines changed: 31 additions & 25 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
4 | | - | |
| 3 | + | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | | - | |
11 | | - | |
12 | | - | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
13 | 17 | | |
14 | 18 | | |
15 | 19 | | |
16 | 20 | | |
17 | 21 | | |
18 | 22 | | |
19 | 23 | | |
20 | | - | |
21 | | - | |
22 | | - | |
23 | | - | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
24 | 28 | | |
25 | 29 | | |
26 | | - | |
27 | | - | |
28 | | - | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
29 | 33 | | |
30 | 34 | | |
31 | 35 | | |
| |||
106 | 110 | | |
107 | 111 | | |
108 | 112 | | |
109 | | - | |
110 | | - | |
111 | | - | |
112 | | - | |
113 | | - | |
114 | | - | |
| 113 | + | |
| 114 | + | |
115 | 115 | | |
116 | 116 | | |
117 | 117 | | |
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
126 | | - | |
127 | | - | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
128 | 129 | | |
129 | 130 | | |
130 | 131 | | |
| 132 | + | |
131 | 133 | | |
132 | 134 | | |
133 | 135 | | |
134 | 136 | | |
135 | | - | |
136 | | - | |
137 | | - | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
138 | 144 | | |
139 | 145 | | |
140 | 146 | | |
141 | 147 | | |
142 | 148 | | |
143 | | - | |
| 149 | + | |
144 | 150 | | |
145 | 151 | | |
146 | 152 | | |
0 commit comments