Repository navigation
feat(forms): a public form binds to the organizations it is published to (ADR-0138 D5) - #25641
objectstack-fleet[bot] wants to merge 7 commits into
Conversation
…-0138 D5) A form's sharing.organizations lists the organizations it is published to, by slug; its link names one (?organization=SLUG) and a submission lands in that organization. Unbound forms keep ADR-0138 D4 unchanged. Claude-Session: https://claude.ai/code/session_01KzNShz7MFV7SFobNr9FWFf Co-authored-by: Claude <noreply@anthropic.com>
…nd both form doors Claude-Session: https://claude.ai/code/session_01KzNShz7MFV7SFobNr9FWFf Co-authored-by: Claude <noreply@anthropic.com>
…r the D5 binding Claude-Session: https://claude.ai/code/session_01KzNShz7MFV7SFobNr9FWFf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzNShz7MFV7SFobNr9FWFf Co-authored-by: Claude <noreply@anthropic.com>
…blic-form-organization
📓 Docs Drift CheckThis PR changes 5 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 153 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1cd6747cdc62e19ef97f486ca25cd2781ede626b && git checkout 1cd6747cdc62e19ef97f486ca25cd2781ede626b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 49653f91812815c72eb9194bf0a6cc451cf711f2 08b020c9d3757fcfa719256d02775d961cc906ed && git checkout -B drift-repro 49653f91812815c72eb9194bf0a6cc451cf711f2 && git merge --no-ff 08b020c9d3757fcfa719256d02775d961cc906ed
node scripts/docs-audit/affected-docs.mjs --json 49653f91812815c72eb9194bf0a6cc451cf711f2
|
… read (form-routes 0 -> 1, sum 4 -> 5) Claude-Session: https://claude.ai/code/session_01KzNShz7MFV7SFobNr9FWFf Co-authored-by: Claude <noreply@anthropic.com>
… document sharing.organizations Claude-Session: https://claude.ai/code/session_01KzNShz7MFV7SFobNr9FWFf Co-authored-by: Claude <noreply@anthropic.com>
席位核对ACCEPT · #25574 ·
Generated by Claude Code |
|
Ruling: #25574 re-ruled A · maintainer 「25447 A」 2026-10-24T16:06:19Z · record #25574 comment 6386464761 多组织托管环境不提供公共表单,维持 ADR-0138 D4;⛔ 不建包声明式的 Generated by Claude Code |
|
Closed unmerged · Generated by Claude Code |
Fixes #25574
Clause-②: yes (widening)
Metadata-protocol: added
Ruling-ref: 6377889413
Implements ADR-0138 D5 as ruling B on #25447 (
6377889413, the governing record per reconciliation6377958424) decides it, verbatim: 「公共表单发布时绑定一个组织(URL 携带组织,授予通道按 #23224 解析 D4 的方式解析它),访客锚点的绑定在该组织下求值,ADR-0138 其余不变」. One cross-domain PR under triage answer A (6382869387); the ADR-0138 dated note rides its own Tier H docs PR (claude/issue-25574-adr-0138-d5-note), so nodocs/adr/**path is in this diff.What changes
SharingConfigSchema(packages/spec/src/ui/sharing.zod.ts, carried byFormViewSchema.sharing, the one declaration of a form's public publication) gainsorganizations: a non-empty list of organization slugs the form is published to.organization/orgs/tenantsare refused and pointed at it.anonymousFormIntakeOrganizations(sharing)(packages/spec/src/ui/anonymous-form-intake.ts, re-exported by@objectstack/metadata-core) reads it, beside the candidates half of the intake rule.resolvePublicFormOrganization(packages/core/src/security/public-form-organization.ts, besideguest-organization.ts): unbound (the form lists none and the link names none: ADR-0138 D4 decides, unchanged), bound (the link names a listed slug and exactly one organization carries it), or refuse (every other case). The organization id is the server's own read of the listed slug, as the system; the client only selects among the slugs the author published to (D4 item 1).GET /forms/:slugandPOST /forms/:slug/submitread?organization=SLUGonce, inresolveFormBySlug. A refusal is answered exactly as an unpublished form,404 FORM_NOT_FOUND, so an anonymous caller learns nothing about which organizations exist or which a form lists. On a bound link the submit context carriestenantId= that organization: the engine's write target, so the row lands there and its autonumber counts there. The form grant (publicFormGrant,permissions: ['guest_portal']) is unchanged.anonymousFormIntakeUnavailabilitytakes the form's binding as an optional fourth argument: a bound form is never withheld on a walled posture, because the reason it states (an anonymous submission carries no organization) does not hold. Omitted, the form is judged unbound, which fails closed. Its three readers pass it: the door, the administrator's read of the view (rest-server.ts) and the save/publish advisory (metadata-protocolruntime-authoring-gate.ts), so none of them states that a bound form is not offered.Booted measurement (the card's acceptance)
examples/app-showcase, built at this branch, booted withOS_TENANCY_POSTURE=isolated OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai OS_AUTH_MEMBERSHIP_POLICY=invite-only pnpm dev -- --fresh -p 38517(the multi-organization runtime present, the wall standing). Measurement-only edits, not committed:showcase_inquirygainedinquiry_number: Field.autonumber({ autonumberFormat: 'INQ-{0000}' }), and thecontact-usform'ssharinggainedorganizations: ['acme', 'globex']. Signed in as the seeded admin, three organizations were created throughPOST /api/v1/auth/organization/create:acme,globex(listed) andinitech(not listed). Every form call below is anonymous (no cookie). Submissions were interleaved acme, acme, globex, acme, globex.Each organization's seed holds INQ-0001..0003; the anonymous submissions continued each organization's own sequence (acme INQ-0004..0006, globex INQ-0004..0005, interleaved), and nothing landed in
initechor the default organization.Tests
Read at head
c990aed37(after mergingorigin/main); the gate reconciliation below was taken on that tree.packages/spec:src/ui/sharing.test.ts,src/ui/anonymous-form-intake.test.ts(2 files, 46 tests);src/ui153 files / 5623 tests passed.packages/core: newsrc/security/public-form-organization.test.ts(15 tests: unbound, bound per slug, each refusal reason with "the database is never asked", unknown and duplicated slug, exact match, outage propagates);src/security55 files / 1118 tests passed.packages/metadata-core:anonymous-form-intake.test.ts(the bound form is available on both walled postures without reading the object; the binding omitted fails closed); package 19 files / 800 tests passed.packages/metadata-protocol: package 263 files / 21473 tests passed (3 files skipped).packages/rest: newsrc/public-form-organization.test.ts(15 tests: each listed organization's link writestenantId= that organization onisolatedandsingle; every refused link equals the unpublished form's answer byte for byte with nothing written and no organization read; the admin read raises no warning for a bound form; the unbound form keeps D4 and refuses a link naming an organization). Package 321 files / 6412 tests passed; after mergingorigin/main(which brought feat(spec,cli,lint): translate a form section entry's label, placeholder and helpText from _sections.SECTION.fields.FIELD #25620'spublic-form-section-i18n.test.tspin), the ninepublic-form*files 127/127 passed.packages/qa/dogfood: no public-form dogfood test readssharing.organizations; not run locally, declared to CI.spec,core,metadata-core,metadata-protocol,restalltsc --noEmitclean.scripts/ablation-replace.mjs, restored to a byte-identical blob): deleting the door'stenantIdthreading turns 4 of 15 rest tests red; deleting the door's refusal return turns 7 of 15 red.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatc990aed37derived 119 commands; all 119 run, each exit captured before any pipe, all exit 0, and--ranreconciles: "119 derived famil(ies) accounted for — 119 run, 0 NOT-MEASURED (a DERIVED zero)". One needed a second run:check-plugin-teardown-shape.mjs --self-testfirst exited 3 because this shallow clone could not reach its pinned positive-control commit; after fetching that one commit it exited 0.packages/speccheck:generated: all 14 generated artifacts up to date after the merge.check:dev-prereqs(local only): exit 0. The full farm is CI's.Acceptance notes
sys_organization.highlightFields), and they are globally unique. An organization whose slug changes stops matching its links, which fails closed (404)./_console/f/:slug(objectui) must forward?organization=to the API for a bound form to render there; that is objectui's side and not in this repository.req.query.organization; they are existing routes, and converting them torefuseUnknownQueryParamswould be a narrowing outside this card. An unbound form's link that names an organization is refused rather than ignored, so a stray parameter cannot land a row in an organization the link did not ask for.guest-organization.ts(that is the class 4/5 endpoint doors' refusal, unchanged here) but two others: on a walled posture, the door's 404 fromanonymousFormIntakeUnavailability; onsinglewith several organizations, the engine'sSystemWriteOrganizationRequiredError(ADR-0131 D9). A bound form clears both.metadata-core's predicate and its two non-door readers are therefore in this diff, beyond the claim's declared file surface; the alternative left the admin read and the save/publish advisory telling the author a bound form is not offered while the doors serve it.Generated by Claude Code