Skip to content

test(verify): pin the anonymous public-form door a booted stack already serves - #22543

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22301-form-door
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22301-form-door

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #22301
Clause-②: no

What this is

Item 4 of #22301, the anonymous public-form door on the verify handle. Items 1-3 landed earlier, items 5-9 are not addressed here, and #22301 remains open.

I checked the premise first, and it does not hold on this base. A booted stack already serves POST /api/v1/forms/:slug/submit anonymously, through the stack's own HTTP surface. The item therefore reduces to pins and one documentation pointer. Nothing changes in packages/rest, packages/runtime or packages/objectql, and the handle gains no method.

Premise reading (base 40a6ee50a, re-checked after merging faf634850)

A fixture app declares one public form (sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/prm-intake' }) and is booted with bootStack:

request answer
stack.api('/forms/prm-intake/submit', POST), no token 201 {"id":"XP3f15bln6eNEnGH"}
the handle's in-process HttpDispatcher.dispatch('POST', '/forms/prm-intake/submit', ...) 404 ROUTE_NOT_FOUND
stack.api('/api/v1/forms/prm-intake/submit', ...) (prefix twice) 404 ENDPOINT_NOT_FOUND
stack.raw('/forms/prm-intake/submit', ...) (no prefix) 404 ENDPOINT_NOT_FOUND
stack.api('/forms/nope/submit', ...) 404 FORM_NOT_FOUND
stack.api('/data/prm_request', POST), no token 401 UNAUTHENTICATED
  • The route has one owner. RestServer.registerFormEndpoints registers it (packages/rest/src/rest-server.ts:10563-10565 at the base). bootStack mounts that server through createRestApiPlugin (packages/verify/src/harness.ts:1063) onto the stack's Hono app, so stack.api reaches the route.
  • The handle's in-process dispatcher does not serve /forms, and should not. That dispatcher drives flows.* and actions.run. A second implementation of the form route would fork its invariants (AGENTS.md, Route and surface ownership, rule 1).
  • Where ENDPOINT_NOT_FOUND comes from. It is the Hono app's not-found answer (plugin-hono-server/src/adapter.ts:1219). On this base, the only requests that get it are paths that miss the route, like the two spellings above. That is the likely origin of the 17.7.0 reading on the card.
    • I did not run 17.7.0 itself (NOT MEASURED). However, at the @objectstack/verify@17.7.0 tag commit 4e4e88142, packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts already submits through bootStack and stack.api('/forms/contact-us/submit').
  • What the engine receives. A middleware on the engine recorded exactly { publicFormGrant: { object: 'prm_request' }, permissions: ['guest_portal'], anonymous: true }. That is the context the route builds at rest-server.ts:10664-10668.

What changes

  • packages/verify/src/handle.public-form-door.test.ts (new). Seven pins, all through a booted stack and the route's own path (stack.api('/forms/:slug/submit'), no token):
    1. An anonymous submit answers 201 with the created id, and the row lands with what was submitted.
    2. The engine receives the door's own execution context, exactly. The pin reads the context through the engine's own registerMiddleware seam and does not re-spell it: { publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }, with no user, no system principal, and nothing else.
    3. The bound hook sees a guest: session and user are both undefined, so an app's guest branch runs (here it stamps origin: 'web'). The control is a person's insert through hooks.run, where the hook sees that person and stamps 'internal'.
    4. The record-change trigger fires the object's record-after-create flow with no trigger user, and the flow reaches its write. The control is a person's insert, whose run carries that person.
    5. A key the form does not collect (status) never reaches the engine. The engine is handed only the payload keys ['email', 'subject'].
    6. Refusal control: an unknown slug gets 404 FORM_NOT_FOUND. The engine is never handed a write, no hook runs, and no row lands.
    7. Refusal control: a form that is shared but not anonymous (allowAnonymous: false, with a publicLink) gets the same answer as 6, and nothing reaches the engine.
  • packages/verify/src/handle.ts. Documentation only:
    • The header's door roster now names the anonymous form door and says why it is not a handle method.
    • hooks.run's JSDoc tells a reader that an anonymous write is the form's own route, api('/forms/:slug/submit', ...) with no token. The hotcrm port reported "the handle offers no way to reach that branch at all", so this is the pointer it was missing.
  • .changeset/22301-verify-public-form-door.md. @objectstack/verify patch, Clause-②: no. The JSDoc ships: after pnpm --filter @objectstack/verify build, packages/verify/dist/index.d.ts carries the new phrase once, with the positive control (an existing JSDoc phrase, "address the row by") also present once. The test file ships nothing (pfd_request and public-form-door have 0 hits in dist/index.js and dist/index.d.ts). So this needs a changeset, not skip-changeset.

Ablation: one-time proof through the built @objectstack/rest dist

@objectstack/verify resolves @objectstack/rest through its exports (to dist/), with no source alias. So each leg went: mutate with scripts/ablation-replace.mjs (WRAP mode, trap-restored), run pnpm --filter @objectstack/rest build, run scripts/ablation-dist-preflight.mjs (marker in dist/), then run the pin file.

  • Leg A: the route's context becomes a system write. The mutation is anonymous: true, changed to isSystem: true, anonymous: Boolean('ablation-22301-system'),. The anchor went 1 to 0 and the blob went e1a572a19260 to dd6fcbf0550a, with the marker in dist. Result: 2 failed, 5 passed.
    • Pin 2 failed: expected { …(4) } to deeply equal { …(3) }, with + "isSystem": true.
    • Pin 3 failed: expected { userId: undefined, …(4) } to be undefined, because the hook received session: { isSystem: true, … }.
    • The others stayed green, as predicted.
  • Leg B: the field whitelist admits a key the form does not collect. The mutation is if (allowedFields.has(k)) filteredData[k] = v; changed to if (allowedFields.has(k) || (k === 'status' && 'ablation-22301-whitelist')) filteredData[k] = v;. Result: 1 failed, 6 passed. Pin 5 failed: the payload the engine was handed: expected [ [ 'email', 'status', 'subject' ] ] to deeply equal [ [ 'email', 'subject' ] ].
  • Restore leg.
    • Both legs restored the source: the blob equals HEAD e1a572a19260 and git diff HEAD is empty.
    • I then rebuilt @objectstack/rest. ablation-dist-preflight --absent passes for both markers ("marker absent from all 6 built files", "working tree clean against HEAD").
    • The pin file passes 7 of 7.
  • The first attempt at leg A was a no-op. Its replacement contained the anchor, so ablation-replace refused it ("the anchor count moved 1 -> 1") and restored the file. Leg A above is the corrected run.
  • The refusal pins (6, 7) were not ablated. Their subject is anonymousFormIntakeCandidates in @objectstack/metadata-core, which is outside this diff's surface. Each pin also asserts that the engine was never handed a write.

Tests and gates

These results are from the final head 5bf0b5ab5, which merges origin/main faf634850 into the two commits above. The build state was refreshed after the merge (pnpm install --frozen-lockfile, then pnpm --filter '@objectstack/verify...' build, the package plus its dependency closure).

  • pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2: Test Files 25 passed (25), Tests 203 passed (203). This is the package in full: 25 test files on disk, the new one included. The same suite at the pre-merge head 19465fae3 also passed 25/25 files and 203/203 tests.
  • pnpm --filter @objectstack/verify typecheck: tsc --noEmit exit 0, and check:test-typecheck: OK — @objectstack/verify's test layer compiles under packages/verify/tsconfig.test.json; 0 file(s) / 0 error(s). The new test file is in that program: it is listed once by tsc --noEmit -p tsconfig.test.json --listFiles.
  • The new pin file on its own: 7 passed (7).
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands at 5bf0b5ab5. All 63 were run, each exit code captured before any pipe, and every one is 0. --ran printed: Run reconciliation — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. Some of those gates' own lines:
    • check-nul-bytes: OK (scanned 10524 text file(s) ...; no raw ASCII control bytes)
    • ✓ doc authoring guard: 17989 customer-facing string(s) ... clean
    • check-test-source-alias OK — 73 packages with tests scanned
    • ✓ check:published-files — 69 publishable package(s) ...
    • check:cross-package-test-inputs OK: 30 package(s) read outside themselves, all declared
    • At the pre-merge head, check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (some packages had no dist/). Once those were built, it was re-run green (107 published require entry point(s) across 66 package(s) load), and it is green in the final-head run.
  • Lint, narrowed and proved. pnpm lint is CI's to run. I ran it on the two touched TypeScript files only: eslint --no-inline-config --format json packages/verify/src/handle.ts packages/verify/src/handle.public-form-door.test.ts returned 2 files linted, 0 errors, 0 warnings, exit 0. Why the narrowing excludes nothing:
    • Both files are in the population eslint.config.mjs declares (files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] and packages/**/*.{ts,tsx,mts,cts}).
    • The config never enables type-aware linting: no parserOptions.project and no projectService (0 hits), and its own docblock at lines 327-329 says the same. So this diff cannot change any untouched file's lint verdict.
  • CI: read on the PR head after opening (see the report on verify: the in-process handle boots a leaner stack than serve and has no door for eight things an app's tests need (requires[] capabilities, system/predicate update, the form door, user-less triggers, …), measured by hotcrm#2013 #22301).

For hotcrm (the repo:hotcrm seat)

hotcrm's matching local path is guestInsert in test/helpers/verify-stack.ts:239-242, read read-only at hotcrm f0afcbd. It has 20 call sites in 11 test files. hotcrm declares two public forms, /forms/contact-us (crm_lead, web_to_lead) and /forms/support (crm_case, web_to_case).

The door replaces that helper. Three differences matter when porting a call site:

  1. It takes a slug, not an object.
  2. It answers { id }. Read the row back with stack.rows(object, { id }).
  3. It keeps only the fields the form collects.
    • Call sites whose keys are all collected map one-for-one (for example case-assignment.test.ts:339).
    • Call sites that pass uncollected keys model a form that does not exist. Examples: status in flow-sla-ownerless-case.test.ts:104, rating in flow-record-change.test.ts:610, and the guest-strip fixtures in hooks-runtime-service.test.ts:132 / :1083. Through the real door those keys are dropped before any hook runs. Such a case either drops the key, or (for a hook-level strip test) states that the route's whitelist is now what it observes.

None of this needs a platform change. The hotcrm suite was not run.

Acceptance notes (not filed)

  • The engine's buildSession docblock says more than holds. It says "Every real transport resolves positions into the context, so an anonymous HTTP request still yields a session and stays gated." The public-form door is a real transport, and it yields no session (measured: the hook's session and user are both undefined). So a hook cannot tell a public-form submission from a bare programmatic call by session alone. A guest branch keyed on "no user and no system principal" (the hotcrm shape, and pin 3's) works.
    • I found no platform hook that gates on a missing session (git grep over packages/** for !ctx.session and similar spellings: 0 hook hits).
    • Read-only observation, so it is not filed. Carrier: none.
  • The README sentence "There is no way to run as "nobody"" (packages/verify/README.md) is about as on handle methods, and it is accurate for them. A pointer to the form door there would help a reader. The README is outside this item's claimed file surface, so it is left for the seat. Carrier: none.

Generated by Claude Code

@github-actions github-actions Bot added size/m 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/verify, touching 2 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 /api/v1/forms/:slug/submit (route, a path literal in a comment on a changed line), /forms/:slug/submit (route, a path literal in a comment in hooks; a path literal in a comment on a changed line))
  • content/docs/ui/public-data-collection.mdx (via /api/v1/forms/:slug/submit (route, a path literal in a comment on a changed line), /forms/:slug/submit (route, a path literal in a comment in hooks; 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 in hooks; 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 — 3 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 ce78ff7bcd850f440b2bacc25a35cedbcc847c56 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 ce78ff7bcd850f440b2bacc25a35cedbcc847c56 → 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

Temporal Conformance (live PG + MySQL) is red on 5bf0b5ab5 (job 114033905117), and the failure is not this PR's. It failed at "Initialize containers" with "Docker pull failed with exit code 1" after two back-off retries, before checkout. No repository code ran, and this PR touches no file that job reads (three packages/verify files).

domain:spec seat 2 (#18549) · session_01DhTqaEHqPVSVnAkjG3jywn · 2026-10-09T21:35Z. This seat has no re-run op. The relay's update-branch is a no-op while main is at this PR's base faf634850. The PR is re-synced once main moves, which re-runs CI. The seat's ACCEPT is 6089642892 on #22301.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 22:54
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 22:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 37c7114 Oct 9, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22301-form-door branch October 9, 2026 23:13
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.

2 participants