Skip to content
27 changes: 27 additions & 0 deletions .changeset/25650-anonymous-form-file-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/lint': minor
---

feat(lint)!: `objectstack validate`, `compile` and the publish gate refuse a form open to anonymous intake that declares a file field (#25650)

Clause-②: no (narrowing: a form view open to anonymous intake whose sections declare a field of a `FILE_REFERENCE_TYPES` type is refused at `objectstack validate`, `compile` and the publish gate)
Metadata-protocol: narrowed
Ruling-ref: ruling C on #25572 (the maintainer, 「25619 C 25495 B」, recorded on PR #25619 with the seat record `6383110676`); triage `6385114404`

A public form takes no attachment. A form view is open to anonymous intake when its `sharing` declares `enabled: true`, `allowAnonymous: true` and a `publicLink` (`anonymousFormIntakeCandidates`, `@objectstack/spec/ui`, the rule both anonymous doors serve from). Such a form collects the fields its sections declare. A visitor has no route to upload a file, so a declared `file`, `image`, `avatar`, `video` or `audio` field can never be filled. The new authoring rule `validatePublicFormFileFields` (rule id `public-form-file-field`, exported as `PUBLIC_FORM_FILE_FIELD`) refuses it, naming the field and the object.

**BREAKING — what moves for authors.**

- FROM: a public form whose `sections[].fields` (or legacy `groups[].fields`) name a file-typed field of the bound object published clean. TO: an error. `objectstack validate`, `compile` and `lint` fail with `public-form-file-field` at the field's entry (`views[0].formViews.apply.sections[1].fields[2]`), and an active `view` write answers `422 INVALID_METADATA` with that issue. The item is not stored. This covers `saveMetaItem` in publish mode (Studio, REST `/meta`, MCP) and the promotion of a draft (`publishMetaItem`). All three form shapes are judged: a view's default `form`, each `formViews` entry, and the flattened `config` of a `viewKind: 'form'` item. A bare field name and a `{ field }` entry are both read.

**Fix (one line).** Take the file field off the public form. To collect a file from someone outside the organization, have them register and sign in, then fill a form that is not open to anonymous intake and declares the field. There is no guest upload.

**Unchanged.**

- The parse. No schema shape moves, and nothing is added to `@objectstack/spec`. A stored view carrying such a form keeps loading and serving until it is next saved, and that save is judged. Saving as a draft is still allowed; publishing the draft is judged.
- A form that is not open to anonymous intake (either switch off, or no `publicLink`) is not judged, whatever fields it declares.
- An `object` write is not judged by this rule: its snapshot carries no views. Changing a field's type to a file type under a published public form is caught by `objectstack validate` and `compile`, and by that view's next publish.
- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write.
- Measured before crossing: 0 producers declare such a form. `examples/app-crm` (through the built `@objectstack/lint`: 0 findings, 1 for a planted file field on its public form), `app-todo` and `app-multi-package` (0 public forms); `app-showcase`'s one public form, read in source (`inquiry.view.ts`, fields `name`, `email`, `company`, `message`: text and email); `@objectstack/platform-objects` (0 public forms); HotCRM at `a4b3560`, whose two public forms (`src/sales/views/lead.view.ts`, `src/service/views/case.view.ts`) declare text, email, url, select, currency, number and markdown fields only.

<!-- adr-0087: not-required (no-migration-prescription) a refusal at `objectstack validate`, `compile` and the publish gate of a form open to anonymous intake that declares a file-typed field: no authorable key, spelling or stored shape moves, the parse is unchanged, and no stored row is read, rewritten or converted. A stored view carrying such a form keeps loading until it is next saved, and the repair is the author's choice of where the file is collected (off the public form, onto a signed-in form), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ os compile --json # JSON output for CI pipelines
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (52)...
→ Running author-time rules (53)...
→ Checking that every required capability has a provider installable in this edition...
→ Collecting package docs (ADR-0046)... 0 collected
→ Writing artifact...
Expand Down
3 changes: 2 additions & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
| Membership key declarations — `userField` / `valueField` columns, one key on one object, a key the registered resolver also owns (save door only) (#25234) | ✓ | ✓ | ✓ | ✓ᵒ |
| Platform-schedule `create_record` organization (#6285) | — | — | — | ✓ᶠ |
| Public-form anonymous intake on this deployment's tenancy posture — advisory only (#21476) | — | — | — | ✓ᵛ |
| Public-form file fields — a form open to anonymous intake whose sections declare a `file`, `image`, `avatar`, `video` or `audio` field (#25650) | ✓ | ✓ | ✓ | ✓ᵛ |
| Autonumber `{field}` interpolation | ✓ | ✓ | ✓ | ✓ᵒ |
| Email template HTML hole placements — a hole in a placement the mail renderer's encoding does not suit (#25189) | ✓ | ✓ | ✓ | ✓ᵉ |
| View references — form targets, view-key collisions (#2554) | ✓ | ✓ | ✓ | — |
Expand Down Expand Up @@ -741,7 +742,7 @@ A clean run walks the registry and reports timing:
Config: /path/to/support-desk/objectstack.config.ts
Load time: 21ms
→ Validating against ObjectStack Protocol...
→ Running author-time rules (52)...
→ Running author-time rules (53)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ visible: 'status != "resolved"'
◆ Validate
────────────────────────────────────────
→ Validating against ObjectStack Protocol...
→ Running author-time rules (52)...
→ Running author-time rules (53)...

✗ Author-time rules failed (1 issue)
• stack · action 'resolve_ticket' visible: bare reference `status` — a
Expand Down
13 changes: 13 additions & 0 deletions content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,19 @@ lookup route.
- To let a visitor pick an existing record, put the form behind sign-in — an
internal form (section 6) renders lookup fields with the signed-in user's access.

### File fields

A public form takes no attachment. There is no anonymous upload route, so a
visitor has no way to fill a file field (`file`, `image`, `avatar`, `video` or
`audio`). A form open to anonymous intake
whose sections declare such a field is therefore refused where it is written:
`os validate`, `os build` and `os lint` fail with `public-form-file-field`, and
publishing the view through Studio, REST `/meta` or MCP answers
`422 INVALID_METADATA` naming the field's entry.

To collect a file from someone outside the organization, have them register and
sign in, then fill an internal form (section 6) that declares the file field.

### Auth model

- Neither route calls `enforceAuth`, so both work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17).
Expand Down
2 changes: 2 additions & 0 deletions content/docs/ui/public-data-collection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry

Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. The submit route also enforces each entry's `required: true` before anything is written — a submission that leaves such a field absent, `null`, blank, or an empty list on a multi-value field is refused with `400 VALIDATION_FAILED` naming the field — except on an entry the form hides (`hidden: true`) or shows only conditionally (`visibleWhen` on the entry or its section), which nothing server-side evaluates. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else.

The whitelist cannot hold a file field (`file`, `image`, `avatar`, `video`, `audio`): a visitor has no upload route, so a public form declaring one is refused at `os validate` / `os build` and at publish (`public-form-file-field`). Collect files from registered, signed-in users on an internal form instead.

The submit's execution context also names a `guest_portal` permission set. That name authorizes nothing: the grant is decided before any permission set resolves, so the form works the same whether or not a set of that name exists, and such a set's object permissions (`allowCreate`, `allowRead`, …) are not read on this path. Where a deployment registers one, only its field permissions, and the capabilities it holds against a field's `requiredPermissions`, are read, to mask the created record the server hands back to the route. The visitor never sees that record: the submit answers with the new record's `id` only.

System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are **never** accepted from a public submission — not even when a section declares them, and not through the zero-sections all-fields fallback. The route filter and the data layer enforce the same set (`PUBLIC_FORM_SERVER_MANAGED_FIELDS` from `@objectstack/spec/security`), so an anonymous visitor cannot forge record ownership or tenant placement regardless of how the form is declared (#3022).
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ objectstack validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (52)...
→ Running author-time rules (53)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/authoring-rule-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,8 @@ describe('authoring-rule registry wiring (#4409)', () => {
'validateStackExpressions',
'validatePresetComparands',
'validateReferenceIntegrity',
// #25650 — a form open to anonymous intake declaring a file field.
'validatePublicFormFileFields',
...FAMILY,
]);
});
Expand Down
21 changes: 21 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import { validateRecordTitle } from './validate-record-title.js';
import { validateFieldConsumers } from './validate-field-consumers.js';
import { validateSemanticRoles } from './validate-semantic-roles.js';
import { validateFormLayout } from './validate-form-layout.js';
import { validatePublicFormFileFields } from './validate-public-form-file-fields.js';
import { validateSeedReplaySafety } from './validate-seed-replay-safety.js';
import { validateSeedStateMachine } from './validate-seed-state-machine.js';
import { validateVisibilityPredicates } from './validate-visibility-predicates.js';
Expand Down Expand Up @@ -1569,6 +1570,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
run: (stack) => validateFormLayout(stack),
},
// Ruling C on #25572 (#25650) — a form open to anonymous intake that
// declares a file field (a `FILE_REFERENCE_TYPES` type on the bound object)
// is refused: no visitor can upload a file, so the field can never be
// filled. The prescription is the ruled route: register and sign in, then
// fill a form that is not public. Gating, so all three commands run it.
//
// Runtime publish gate: a `view` write's snapshot carries `objects` as
// context, which is every input this rule reads, so the view's publish is
// refused at the key. `object` is deliberately not declared: an object
// write's snapshot carries no views, so the rule would judge nothing there.
{
name: 'validatePublicFormFileFields',
tier: 'gating',
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/validate-public-form-file-fields.ts',
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['view'],
run: (stack) => validatePublicFormFileFields(stack),
},
// ADR-0078 Phase 3 (Tier-A `action-locations`) — an action that declares no
// `locations` and that no view places by name renders on no surface at all.
// objectui#3142 made that measurable: four renderers used to show an
Expand Down
4 changes: 4 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ export {
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';

// A form open to anonymous intake that declares a file field (ruling C on #25572).
export { validatePublicFormFileFields, PUBLIC_FORM_FILE_FIELD } from './validate-public-form-file-fields.js';
export type { PublicFormFileFieldFinding } from './validate-public-form-file-fields.js';

export {
validateVisibilityPredicates,
VISIBILITY_ROOT_MISLAYERED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi
'validateStackExpressions',
'validatePresetComparands',
'validateReferenceIntegrity',
'validatePublicFormFileFields',
...FAMILY,
]);
const view = (visibleWhen: string, data?: unknown) => ({
Expand Down
5 changes: 5 additions & 0 deletions packages/lint/src/runtime-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@ describe('the views[] visibility-predicate family at the runtime publish gate (#
// (`validateSearchableFields` / `validateSortableFields`) on the
// per-write snapshot — see `runtime-gate.view-writes.test.ts`.
'validateReferenceIntegrity',
// #25650 — a form open to anonymous intake declaring a file field;
// the snapshot's `objects` context carries the field types it reads.
'validatePublicFormFileFields',
'validateVisibilityPredicates',
'validatePredicatePathRefs',
]);
Expand Down Expand Up @@ -539,6 +542,7 @@ describe('the views[] visibility-predicate family at the runtime publish gate (#
'validateStackExpressions', // #24798 — its bulk `confirmText` pass alone, clean here
'validatePresetComparands', // #8793 — dispatched for `view`, clean here
'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here
'validatePublicFormFileFields', // #25650 — a public form's file fields, clean here
'validateVisibilityPredicates',
'validatePredicatePathRefs',
]);
Expand Down Expand Up @@ -634,6 +638,7 @@ describe('the publish gate judges a schema-bound form at its own layer (#7815)',
'validateStackExpressions', // #24798 — its bulk `confirmText` pass alone, clean here
'validatePresetComparands', // #8793 — dispatched for `view`, clean here
'validateReferenceIntegrity', // #9313 — its two list-view field members, clean here
'validatePublicFormFileFields', // #25650 — a public form's file fields, clean here
'validateVisibilityPredicates',
'validatePredicatePathRefs',
]);
Expand Down
Loading
Loading