fix(rest): a public form that cannot take intake on a walled posture is not offered; the admin read says why - #21580
Conversation
…doors and the admin read Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…he admin read Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…form, and the admin read says why Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…the intake predicate's docs Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ture Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…dicate Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 16 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 298c4edb2fdf6e062b6d9e1c4b484da5d7fb2b21 && git checkout 298c4edb2fdf6e062b6d9e1c4b484da5d7fb2b21
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0bddffd55bdcffd23b7f39b2d009b614bcdc9b2a 66ee5294a6d0a5284f91185e17e57971a5114d9f && git checkout -B drift-repro 0bddffd55bdcffd23b7f39b2d009b614bcdc9b2a && git merge --no-ff 66ee5294a6d0a5284f91185e17e57971a5114d9f
node scripts/docs-audit/affected-docs.mjs --json 0bddffd55bdcffd23b7f39b2d009b614bcdc9b2a
|
Part of #21476
Clause-②: no
This delivers the doors half and the administrator's read half of the triage ruling (
5962758813). The publish half is left open on purpose: its contract-faithful channel sits behindpackages/metadata-protocol/src/protocol.ts, which open PRs hold. The call sites are under "Not in this PR". The keyword isPart of, so the card stays open after this merges, while that half waits for a decision.What
On a walled tenancy posture, a public form bound to an object walled by an organization column is no longer offered to anonymous visitors.
An anonymous submission carries no organization. On a walled posture the engine refuses an insert without one into such an object (
resolveSystemInsertOrganization). So the form was served (GET /forms/contact-us200), and then every submit answered500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED. This was reproduced onorigin/main(6dd99b82c3) withbootStack(showcaseStack, { multiTenant: 'posture-only' }).anonymousFormIntakeUnavailabilityinpackages/rest/src/rest-server.tsreturns null when the form can take intake, otherwise why it cannot. It reads two facts and restates neither:posture. This is the value SecurityPlugin hands the engine (setTenancyPostureProvider), so a degraded walled request readssinglethere, and the engine derives the install's organization.@objectstack/metadata-core's existingresolveRecordWallOrganizationField, read over the served object schema (which carries the injectedorganization_id).The object is read only once a wall is in force, so single-posture deployments pay nothing new.
resolveFormBySlug, the one resolution bothGET /forms/:slugandPOST /forms/:slug/submitalready call. An unavailable form resolves tonull, which is exactly the withdrawn form's404 FORM_NOT_FOUND, byte for byte. Anonymous callers learn nothing about the tenancy, and there is no second check per door.GET /meta/view/:nameputs one warning initem._diagnostics.warningsper unavailable open form. It sits on both arms (cached and uncached), is located at the form'ssharing(config.sharing,formViews.KEY.sharingorform.sharing), and names the slug, the object, the wall column, the posture and the remedy (tenancy: { enabled: false }when the rows belong to no organization). The reason depends on facts the protocol's validator never hashes, so on the cached arm aview's If-None-Match is compared in REST against an ETag with the reason folded in (the ADR-0106 D3 shape). With no reason, the ETag and the 304 are byte-identical to before (pinned).Placement: the predicate lives in
rest, notmetadata-coreThe dispatch assumed
metadata-core'sanonymous-form-intake.ts. I measured that first: any new export there enlarges@objectstack/metadata-core's published index (export *), which is aClause-②: yes (widening)change by #21566's own grading. The dispatch pinsClause-②: no.Every reader of the predicate (two doors, one admin read) is in
rest-server.ts. The predicate composes two rules that are already shared (postureEnforcesWallfrom spec,resolveRecordWallOrganizationFieldfrom metadata-core), so no rule gains a second spelling. It moves intometadata-coreon the day a reader outsiderestexists, for example the publish half's option A below.Pins
packages/rest/src/public-form-intake-availability.test.ts(16 tests):/forms/must be exactly the two doors, and each door on each walled posture (isolated,group) is its own row: it answers the withdrawn form's answer byte for byte, andcreateDatais never called.tenancy: { enabled: false }object, the single posture (where the object is not even read), a degraded walled request, and no tenancy service._diagnosticsis untouched on the controls.packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts(real walled showcase boot):showcase_inquiryrow lands;config.sharing.public-form-withdrawal-walled.dogfood.test.ts: the dogfood control. A tenancy-disabled object on the walled boot still accepts intake, and its admin read now also asserts that no intake warning is present.Ablation (one-shot, not kept)
Each mutation was applied with
scripts/ablation-replace.mjsagainstpackages/rest/src/rest-server.ts, whose HEAD blob is239ac2d6at both8a8839f9aband the final66ee5294a6. The direction was predicted before each run.return unavailable ? null : { ...match, organizationId };becamereturn { ...match, organizationId };(anchor 1 to 0, blob239ac2d6to43fdf709).git diff HEADis empty.unavailableunused, and the DTS build refused it (noUnusedLocals) after the JS bundle had already been emitted, so no test ran. I rebuilt from the restored source, andablation-dist-preflightconfirmed the guard present indist/index.jsanddist/index.cjswith a clean tree.return unavailable && false ? null : { ...match, organizationId };(blob2b2e9c9f), rebuilt; the preflight found the plant marker in 2 built files.expected 200 to be 404on the doors row), 8 passed.return tenantField === null ? null : ...becamereturn tenantField === null || true ? null : ...(blob25ef3c8e).The pins asked for an ablation "on one door". There is no per-door read site to ablate: the predicate is read once, in the resolution both doors call. Ablation A removes that one read, and each door's own row goes red.
Tests (at
66ee5294a6, after mergingorigin/main44072fc2b9)@objectstack/restfull suite (--project local): 258 files passed; 4883 tests passed, 326 skipped.typecheck:tsc --noEmitclean, andcheck:test-typecheckOK.@objectstack/metadata-core: 17 files / 311 passed;typecheckclean (it is unchanged).@objectstack/dogfoodtypecheckclean. Public-form dogfood files (walled intake, walled withdrawal, showcase withdrawal, showcase public form, read-back masking): 5 files / 20 passed.@objectstack/runtime/metaparity census, run because it readsrest-server.tssource andrest'sdist/. The four filesmeta-list-projection-parity,meta-item-read-gate-parity,meta-read-org-scope-parityandmeta-item-envelopegave 780 passed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 68 commands; 67 exit 0.check:dual-build-cjs-loadsanswered PREREQUISITE NOT MET (it needs a full workspace build), so it is NOT MEASURED and left to CI. The--ranreconciliation accounted for 68 of 68: 67 run, 1 NOT MEASURED, 0 unrun..tsfiles (--no-inline-config --format json): 4 files, 0 errors, 0 warnings, none ignored. The fifth changed path is a changeset.md. The narrowing is sound becauseeslint.config.mjsnever enables type-aware linting (noparserOptions.project), so this diff cannot move any untouched file's verdict. The fullpnpm lintis CI's.Not in this PR: the publish half
The ruling asks the publish surfaces (
PUT /meta/view/:name,POST /meta/view/:name/publish) to say why too. The only located, structured channel those responses carry isadvisories, and bothSaveMetaItemResponseSchemaandPublishMetaItemResponseSchemadeclare the runtime authoring gate as its producer. The places that would have to change:packages/metadata-protocol/src/runtime-authoring-gate.ts: a gate-local rule would sit besidefindPlatformScheduleOrgGaps, around line 300.packages/metadata-protocol/src/protocol.ts:5612: the gate is fedorgWallEnforced: this.orgWallEnforced().protocol.ts:5938:orgWallEnforced()reads the requested posture (postureEnforcesWall(resolveTenancyPosture())), which disagrees with the doors' in-force reading on a degraded deployment.protocol.ts:18673andprotocol.ts:19612: the attach sites.protocol.tsis held by open PRs #21545 and #21512, so this PR stops there. The options and a recommendation are in the report on the card.Acceptance notes
external) object, a platform object its inventory has not admitted, and a row abeforeInserthook stamped. A form bound to one of those that also carries a wall column is withheld here although the engine would accept it, which is the fail-closed direction. No shipped hook stampsorganization_id(git grepover the CRM and showcase hooks: exit 1, with abeforeInsertcontrol at exit 0).500 FORM_RESOLVE_FAILED; submit throughmapDataError). Single-posture deployments make no new read.viewread, If-None-Match is now compared in REST rather than in the protocol. Server work is unchanged, becausegetMetaItemCachedalready delegates togetMetaItem. Response bytes, the ETag and the 304 are identical when there is no reason.GET /meta/view),/layers, and the runtime HTTP dispatcher's/metaitem read. The dispatcher serves no/forms/*door, so a dispatcher-only composition has no intake that could be unavailable.app-crm's lead form (/forms/contact-us) is the same class on a walled CRM deployment. Not booted here._diagnostics.warnings: NOT MEASURED (noobjectuicheckout in this container).Generated by Claude Code