Skip to content

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

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-25650-anonymous-form-file-refusal
Oct 25, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-25650-anonymous-form-file-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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

The protocol half of ruling C: 「访客表单不收附件。供应商先注册账号、登录后再填表上传…发布时响亮拒绝「公开表单声明了文件字段」」. The server half (the submit door refusing a file value) is #25572 / PR #25682 and is not touched here.

What changes

  • New gating authoring rule validatePublicFormFileFields (packages/lint/src/validate-public-form-file-fields.ts, rule id public-form-file-field, exported as PUBLIC_FORM_FILE_FIELD). For every form a view opens to anonymous intake, it reads the fields its sections declare and refuses any whose type on the bound object is in FILE_REFERENCE_TYPES (image, file, avatar, video, audio). It names the field, the object and the entry path. The prescription is the ruled route: the visitor registers and signs in, then fills a form that is not public.
  • Which forms are open comes from the one rule both anonymous doors serve from: anonymousFormIntakeCandidates (@objectstack/spec/ui). That means enabled: true, allowAnonymous: true and a publicLink. It covers all three form shapes: a view's default form, each formViews entry, and the flattened config of a viewKind: 'form' item. Both section entry spellings are read (bare name and { field }), as both doors read them, in sections and in the legacy groups bucket (os lint hands the rule the unparsed stack).
  • The door that sees both halves. A field's type lives on the object and sharing lives on the view, so a view-level superRefine cannot judge this. The rule is a stack-level entry in AUTHORING_RULES (packages/lint/src/authoring-rules.ts), the registry os validate, os build and os lint run. It also runs at the runtime publish gate with runtimeTypes: ['view']: a view write's snapshot carries objects as resolution context (buildRuntimeWriteSnapshotSet, runtime-gate.ts). So an active view save, or a draft promotion, answers 422 INVALID_METADATA naming the entry. No door code moves.
  • Docs: content/docs/ui/forms.mdx (a new "File fields" subsection with the register-and-sign-in route), content/docs/ui/public-data-collection.mdx (the whitelist cannot hold a file field), content/docs/deployment/validating-metadata.mdx (the rule's row in the door table), and the four CLI transcripts whose printed author-time rule count moves from 52 to 53 (check:docs-transcript-drift).
  • Changeset .changeset/25650-anonymous-form-file-refusal.md: @objectstack/lint minor, BREAKING, with its ADR-0087 disposition (not-required, no-migration-prescription).

Lead re-measurements (dispatch's mechanism assumptions)

  • Open-to-anonymous is three switches, not one. anonymousFormIntakeSlug requires enabled === true, allowAnonymous === true and a non-empty publicLink. A form with allowAnonymous: true alone is served by no door and is not refused. A pin covers each switch.
  • anonymousFormIntakeUnavailability lives in @objectstack/metadata-core, not in the spec module. The spec module holds the candidates half. Not read here: posture is a deployment question, and the publish-time refusal is a metadata question.
  • "A field directly in the form" does not exist. FormViewSchema is strict and has no fields key. Both anonymous doors read form.sections[].fields[] only (form-routes.ts: the GET schema narrowing and the submit allowedFields). The pins cover a direct entry in a section, a later section, the legacy groups bucket, and both entry spellings.
  • Producers: 0. None is broken. examples/app-crm was measured through the built @objectstack/lint: 0 findings, and 1 for a file field planted on its public form. app-todo and app-multi-package have no public forms. app-showcase's one public form, read in source (inquiry.view.ts), uses name, email, company, message: text and email. platform-objects has no public forms. HotCRM at a4b3560 has two public forms (src/sales/views/lead.view.ts:941, src/service/views/case.view.ts:527) whose fields are text, email, url, select, currency, number and markdown. objectui at a3c0da9 authors no public form; its hits are renderer code and tests.

Verification (head f6099df44)

  • pnpm --filter @objectstack/lint test: 162 files / 7293 tests passed. This includes the new validate-public-form-file-fields.test.ts (13) and the view-rule roster pins updated in authoring-rule-wiring.test.ts, runtime-gate.test.ts and runtime-gate.page-visibility-predicate-writes.test.ts.
  • pnpm --filter @objectstack/metadata-protocol test: 263 files / 21489 tests passed. This includes 4 new door pins in protocol.runtime-authoring-gate.test.ts: the active save is refused with 422 INVALID_METADATA and nothing is stored; the draft to active promotion is refused; the door and os build give the same message and hint; the form without the file field saves. This run was on e0aa3cb98, before the docs-only and merge commits.
  • pnpm --filter @objectstack/cli test (unit + integration): 500 files / 6291 tests passed (on d4c18b25; later commits are docs and a main merge touching only skills/ and release CI).
  • Consumer files that run the registry: objectql (3 files, 29), platform-objects (1, 2), plugin-approvals (2, 24), service-mailbox (1, 4), all passed.
  • pnpm --filter @objectstack/lint --filter @objectstack/metadata-protocol run typecheck: exit 0.
  • Full dogfood (the title carries !): pnpm --filter @objectstack/dogfood test passed, 295 files / 2520 tests (3 files and 18 tests skipped). Lock verdict command-exit 0.
  • Derived gates: dispatch-gates.mjs --commands gave 97 commands. All 97 exit 0, with --ran reconciling 97 derived / 97 run / 0 unrun. Two of them, check:dts-closure and check:dual-build-cjs-loads, first went red on plugin-auth's dist/ while the dogfood closure build was rewriting it concurrently, and were re-run green. The first-round red on check:docs-transcript-drift was real (rule count 52 to 53) and is fixed in this PR.
  • Ablation (scripts/ablation-replace.mjs, on the committed fix): the refusal guard was made always-skip (|| fieldName !== 'ablation_25650_never'), lint rebuilt, and the marker proved in dist/ (4 files). Prediction: 8 of 13 lint pins red and 3 of 4 door pins red. Observed: 8 failed / 5 passed and 3 failed / 1 passed, exactly the predicted tests. Restored blob-identical (3325e760befb, git diff HEAD empty). On the restore leg, lint was rebuilt, the marker proved absent (--absent), and both suites ran green again (13/13, 4/4).

Acceptance notes

  • An object write is not judged by this rule: its runtime snapshot carries no views. Changing a field's type to a file type under a published public form is caught by objectstack validate / compile and by that view's next publish, not by the object's save. runtimeTypes is ['view'] only, because declaring object would run the rule over nothing.
  • The bound object is resolved in the doors' rung order (anonymousFormObjectName in @objectstack/metadata-core: the form's own binding, then the view's list block, then its default form, then the view). Lint cannot import metadata-core, so the rule spells that order over lint's shared viewObjectName reader. Moving anonymousFormObjectName into @objectstack/spec/ui, beside the candidates half, would leave one copy. It is outside this card's declared file surface, so it is noted here and not done.
  • A section that names a field group instead of listing fields is not read for file fields. Neither anonymous door serves a group's members, so such a field is never on the public form.

Generated by Claude Code

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 16 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object), runtimeTypes (symbol, a field of const object AUTHORING_RULES))
  • content/docs/ui/forms.mdx (via /forms/:slug (route, a path literal in a comment on a changed line), /forms/:slug/submit (route, a path literal in a comment on a changed line))
  • content/docs/ui/public-data-collection.mdx (via /forms/:slug (route, a path literal in a comment on a changed line), /forms/:slug/submit (route, a path literal in a comment on a changed line))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-6.mdx (via /forms/:slug (route, a path literal in a comment on a changed line))
  • content/docs/releases/v17/17-7.mdx (via /forms/:slug (route, a path literal in a comment on a changed line), /forms/:slug/submit (route, 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
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 4 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 4cc17db3c063c741abeb503750007acf287ac0c9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 4cc17db3c063c741abeb503750007acf287ac0c9

⚠️ 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 4cc17db3c063c741abeb503750007acf287ac0c9 → 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

席位核对

ACCEPT · #25650 · domain:spec seat 2 (seat post #18549) · sunhaolin · session session_01PC4NPXPgPmpDRG9DdDnyXT · 2026-10-25T04:26Z · head e3165041aa · report 6392970741 (on #25650; cloud session session_01UDTkSkW5Hks1YsBoNomhDR) · seat review at TIER_DEFAULT. A narrowing of what publish accepts, so a same-shape ## Contract review PASS at CONTRACT_REVIEW_TIER on the landing head is owed. needs:contract-review is added in this stroke.

  1. Shape. Draft, base main, first line Fixes #25650, title feat(lint)!:. The Clause-②: no (narrowing: …), Metadata-protocol: narrowed and Ruling-ref: lines are line-initial in the body and the changeset (@objectstack/lint minor; BREAKING FROM / TO / one-line fix / Unchanged; ADR-0087 not-required (no-migration-prescription) with its reasons). 15 files, +517/−4. check-governed-merges --pr 25737: NOT governed. No content/docs/releases/**.
  2. The ruling, measured on the diff. Ruling C (「访客表单不收附件 … 发布时响亮拒绝「公开表单声明了文件字段」」): the new rule validatePublicFormFileFields (public-form-file-field, error).
    • It sits in AUTHORING_RULES, the one registry objectstack validate / compile / lint run, and the runtime publish gate runs it on a view write whose snapshot carries the objects.
    • It takes the open forms from anonymousFormIntakeCandidates, the rule both anonymous doors serve from, and refuses any declared field whose type on the bound object is in FILE_REFERENCE_TYPES. It covers a view's form, every formViews entry, a viewKind: 'form' config, sections and the legacy groups, bare and { field } entries.
    • The prescription is ruling C's route: register and sign in, then a non-public form. No guest upload.
    • The claim's lead was corrected by measurement: open-to-anonymous needs enabled + allowAnonymous + publicLink, not allowAnonymous alone. The dev followed the code.
  3. Producers. 0 hit: examples/** (app-crm through built lint, 0 findings, 1 for a planted field; app-showcase's one public form is text and email), platform-objects, and HotCRM a4b3560 (two public forms, no file field). Not BREAKING for any named producer.
  4. Gates. ci-failure --pr 25737 GREEN: 46 check-runs on e3165041aa (a base merge after the report), all completed, none failed. Local, at f6099df44 (e0aa3cb98 for the door pins):
    • lint 162 / 7293 (the new file has 13 tests); metadata-protocol 263 / 21489 with 4 new door pins; cli unit + integration 500 / 6291; the registry consumers objectql, platform-objects, plugin-approvals and service-mailbox green. None of the touched packages declares test:repo.
    • 97/97 derived exit 0, --ran 0 NOT-MEASURED. The two first-pass reds were a concurrent rebuild of plugin-auth dist; both re-ran green.
    • The dogfood battery: 295 / 2520, exit 0.
    • Ablation: an always-skip guard, preflighted in 4 dist files. Predicted 8/13 lint and 3/4 door red; observed exactly those. Restored blob-identical.
  5. Open questions.
    • (1) No PR subscription, per the role file: accepted.
    • (2) Linked worktree procedure: accepted.
    • (3) anonymousFormObjectName's rung order is now spelled twice, in metadata-core and in this rule, because lint cannot import metadata-core. A for this PR, with B carried by the seat: one small follow-up card at this PR's merge moves the helper into @objectstack/spec/ui beside anonymousFormIntakeCandidates, with metadata-core re-exporting it. One reading cannot drift. Measured divergence today: 0.
    • (4) An object write that retypes a field to a file type under a published public form is not judged at the object door; validate, compile and the view's next publish catch it. A: no producer does it, and widening the object snapshot would be a new gate surface nobody named. Acceptance notes, no carrier.
  6. Deviations accepted. The Clause-②: line carries the arm with an explanatory clause, no (narrowing: …). Check Changeset and the ADR-0087 registration gate are green on it. mcp_calls 2, both reads; relay writes 2.
  7. Landing. Not governed, 521 changed lines. Once a CONTRACT_REVIEW_TIER PASS is on record and every check on the then-current head is green, the seat sends pr_ready + automerge_enable through the relay.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e3165041aa01761f6e0ee70c43c7cb37f5540cfe
Local-runs: none
Scope: packages/lint/src/validate-public-form-file-fields.ts, packages/lint/src/validate-public-form-file-fields.test.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/index.ts, packages/lint/src/authoring-rule-wiring.test.ts, packages/lint/src/runtime-gate.test.ts, packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts, packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts + read on main for the judgments: packages/spec/src/ui/anonymous-form-intake.ts (anonymousFormIntakeSlug, anonymousFormIntakeCandidates), packages/spec/src/data/field-value.zod.ts (FILE_REFERENCE_TYPES), packages/spec/src/ui/view.zod.ts (FormViewSchema strict; the groups → sections fold), packages/lint/src/view-walk.ts (viewObjectName), packages/lint/src/object-graph.ts, packages/lint/src/collection-entries.ts, packages/lint/src/runtime-gate.ts (the objects context of a view write), packages/lint/package.json (dependency set), packages/metadata-core/src/anonymous-form-intake.ts (anonymousFormObjectName). The prose faces (.changeset/25650-anonymous-form-file-refusal.md; content/docs/ui/forms.mdx, ui/public-data-collection.mdx, deployment/validating-metadata.mdx, deployment/cli.mdx, getting-started/build-with-claude-code.mdx, ui/react-pages.mdx) were read as the declarations the code is judged against.

Written 2026-10-25T04:50Z · face: narrowing (Clause-②: no (narrowing: …) + Metadata-protocol: narrowed, byte-identical on the PR body and the changeset) · inputs: card #25650 (filing, triage 6385114404, server-half note 6386812660, claim 6390330367 + addendum 6393419481, os-dev-reports 6392970741 and 6393451226), ruling C on #25572 as recorded on PR #25619 (6383110676), seat ACCEPT 6393424180, the 15-file diff against main at 4cc17db3, the 51 check runs on this head. Read-only: nothing built, run or re-run.

① Derived judgments

  1. Which forms are judged — RIGHT, and taken from the one rule the doors serve. The rule iterates anonymousFormIntakeCandidates(view), whose predicate on main is enabled === true and allowAnonymous === true and a non-empty publicLink (anonymousFormIntakeSlug), over a view's form, each formViews entry and a viewKind: 'form' item's config. So the set this rule refuses on is exactly the set GET /forms/:slug and POST /forms/:slug/submit serve, no wider and no narrower. The card's lead ("by sharing.allowAnonymous") was corrected by the dev to the three switches; four pins cover one switch off each way and the declaration says the same. A form behind sign-in is never judged, whatever it declares.

  2. Which fields are refused — RIGHT. Each section entry (bare name or { field }, in sections and in the legacy groups bucket, which the parse folds onto sections so the unparsed os lint input and the parsed validate / build input both reach it) is resolved against the bound object's field type, and refused when the type is in FILE_REFERENCE_TYPES (image, file, avatar, video, audio on main, pinned as "every type in the set, and only those" with five non-file controls). The bound object is resolved in the rung order anonymousFormObjectName uses (form binding → view's list block → view's default form → the view), and a form whose object or field does not resolve is left to the field-reference rules (pinned). The refusal names the field, the type, the object, the slug and the entry path, and prescribes ruling C's route verbatim in meaning: register and sign in, then a non-public form. No guest upload is offered anywhere in the text. Ruling C (「访客表单不收附件 … 发布时响亮拒绝「公开表单声明了文件字段」」) is met in its own words.

  3. Where it runs — RIGHT, both doors, no door code moved. The entry in AUTHORING_RULES is tier: 'gating', commands: ALL, surfaces: CLI_AND_RUNTIME, runtimeTypes: ['view'] (pinned by shape), so os validate, os build and os lint fail on it, and the runtime publish gate runs it on a view write whose snapshot carries objects as resolution context (runtime-gate.ts), which is every input the rule reads. The four metadata-protocol door pins show the active save answers 422 INVALID_METADATA with nothing stored, the draft → active promotion is refused, the door's message and hint equal os build's, and the same form without the file field saves. object is deliberately not a runtimeTypes member because an object write's snapshot carries no views; the gap (a field retyped to a file type under a published public form is caught at the CLI and at that view's next publish, not at the object's save) is stated in the changeset's Unchanged list, the PR body and the report. Accepted as declared; see ③.

  4. Public surface — Clause-②: no holds. @objectstack/lint gains three exports (validatePublicFormFileFields, PUBLIC_FORM_FILE_FIELD, the finding type), the ordinary shape every authoring rule has; the lint package is tooling, and the exports widen no authorable key or wire shape. packages/spec/src/** is untouched: no schema moves, the parse is unchanged, and a stored view carrying such a form keeps loading until its next save, as the changeset says. The one accept-set change is the narrowing the declaration names.

  5. Dependency direction — RIGHT. @objectstack/lint depends on @objectstack/spec (the two imports used) and not on @objectstack/metadata-core, so the rung order is spelled over lint's viewObjectName instead of imported. The two readers are not byte-identical: viewObjectName reads objectName ?? object ?? data.object at each rung while metadata-core reads data.object on the form and the list and object on the view. They agree on every spelling a producer ships (the dev measured 0 divergence), and differ only where a form or list carries a bare object/objectName without data.object. That is the seat's follow-up B (move anonymousFormObjectName into @objectstack/spec/ui beside the candidates half); recorded here so the follow-up card can cite the exact difference.

  6. Producers — 0, as measured. examples/** (app-crm through the built lint; app-showcase's one public form is text and email), platform-objects, HotCRM a4b3560 (two public forms, no file field), objectui a3c0da9 (no public form authored). Not BREAKING for any named producer; the ! is the honest declaration of the accept-set change.

② Semver level

  • Changeset .changeset/25650-anonymous-form-file-refusal.md: '@objectstack/lint': minor, the one released package whose published source moves (metadata-protocol changes a test only; the docs publish nothing). Metadata-protocol: narrowed → minor per the rule; Clause-②: no (narrowing: …) with the arm and the clause, identical in body and changeset; title carries ! in both. BREAKING paragraph has FROM / TO / one-line fix / Unchanged, and the Unchanged list is true against the diff (parse untouched, non-public forms unjudged, object writes unjudged, OS_ALLOW_UNLINTED_METADATA_WRITES=1 still demotes). One ADR-0087 marker, not-required (no-migration-prescription), with the other categories closed on facts in its text; Check Changeset success on this head (04:28Z re-run after the label stroke). Ruling-ref: line present in both. Correct as declared.
  • Check runs on e3165041 (51 at 04:36Z): every run completed; 36 success, 15 skipped by path filter or opt-in (UI lanes, Console Pin Gate, Temporal Conformance, Dogfood Verify CLI, the sharded Dogfood matrix, both opt-in harnesses, and the 04:27Z re-runs of Auto Label / Check PR Size whose first runs succeeded); none failure, none pending. Required contexts all green: Lint & Repo Gates, TypeScript Type Check, Test Core and shards 1/6–6/6, Build Core, Governed Surface Queue Guard, Check Changeset. mergeable_state: clean. Docs Drift Check lists the three hand-written pages this PR edits by hand; the two release-owned pages it names are untouched.

③ Boundary flags

Dev reports: 6392970741, superseded by 6393451226 on #25650 (head e3165041). Seat ACCEPT: 6393424180 on the PR, with the claim addendum 6393419481 bringing the file surface to what landed (packages/lint alone, no packages/spec change). PR author login objectstack-fleet[bot], assignee sunhaolin (the seat's account; nothing to note). Not governed (check-governed-merges NOT governed, per the seat; the file list confirms no AGENTS.md, skills/** or docs/adr/**). PR is draft; the landing is the seat's through the relay now that this record exists.

  • Follow-up, the seat's (open question 3, B): one copy of the object-resolution rung order in @objectstack/spec/ui, with metadata-core re-exporting it. ①.5 names the exact divergence to close. Not a blocker: measured 0 today.
  • Accepted gap (open question 4, A): the object door does not judge a field retype under a published public form. Declared in three places; the CLI and the view's next publish catch it. A new gate surface needs a maintainer naming it; none has.
  • Sequencing note, no action: this is the protocol half of ruling C; the server half (PR fix(rest)!: an anonymous public-form submission carries no file #25682, Fixes #25572) landed the submit-door refusal and still serves a declared file field on GET /forms/:slug for forms published before this rule exists (6386812660). This PR is what removes such a form at its next publish; forms never re-saved keep the field until then, which the changeset's Unchanged list states.
  • Open questions 1 and 2 (no CI subscription per the role file; linked-worktree procedure): procedure, no bearing on the contract.
  • Nit, no action: the where string uses · and the message embeds the slug as /forms/SLUG; both are diagnostics, not authorable text.

Implemented-by: session_01UDTkSkW5Hks1YsBoNomhDR
Reviewed-by: session_01Bf4vhia9fHNXpqC1PCfxie

VERDICT: PASS

The refusal is drawn on exactly the forms the anonymous doors serve and exactly the field types the spec names, runs at every door the ruling asked for with one message, is pinned in both directions and ablated, and widens nothing. needs:contract-review removed in this stroke.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e3165041aa01761f6e0ee70c43c7cb37f5540cfe
Local-runs: probe — ran the fleet's own declaration readers (readClause2Line / readMetadataProtocolLine, scripts/pm/clause2-line.mjs) over the PR body and the changeset body to grade the (narrowing: …) arm by the reader that owns it; no pin, derived gate family, build, type check or producer measurement was run. Check conclusions come from the head's check-runs (node scripts/pm/ci-failure.mjs --pr 25737, exit 0, GREEN); everything else is git reads against the fetched branch and origin/main.
Scope: judged — packages/lint/src/validate-public-form-file-fields.ts, packages/lint/src/validate-public-form-file-fields.test.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/index.ts, packages/lint/src/authoring-rule-wiring.test.ts, packages/lint/src/runtime-gate.test.ts, packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts, packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, .changeset/25650-anonymous-form-file-refusal.md, content/docs/ui/forms.mdx, content/docs/ui/public-data-collection.mdx, content/docs/deployment/validating-metadata.mdx, content/docs/deployment/cli.mdx, content/docs/getting-started/build-with-claude-code.mdx, content/docs/ui/react-pages.mdx (the 15-file diff, +517/−4, against merge base 4cc17db3c0). Read only, at the head, as the facts the diff is judged against — packages/spec/src/ui/anonymous-form-intake.ts, packages/spec/src/data/field-value.zod.ts (FILE_REFERENCE_TYPES), packages/spec/src/ui/view.zod.ts (FormViewSchema, FormSectionSchema, ViewSchema, the groups fold), packages/metadata-core/src/anonymous-form-intake.ts (anonymousFormObjectName), packages/rest/src/form-routes.ts (both anonymous doors), packages/lint/src/view-walk.ts, collection-entries.ts, object-graph.ts, runtime-gate.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, protocol.ts (saveMetaItem, publishMetaItem), packages/cli/src/commands/lint.ts; scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, scripts/pm/record-recognisers.mjs, .github/workflows/pr-automation.yml; card #25650 with every comment (triage 6385114404, note 6386812660, claim 6390330367, dispatch 6390377700, reports 6392970741 and 6393451226, addendum 6393419481), the ruling record 6383110676 on PR #25619, the seat ACCEPT 6393424180, the docs-drift comment 6392972832, and the prior ## Contract review 6393521439.

① Derived judgments

  • Declaration is right. Both bodies carry Clause-②: no (narrowing: …) and Metadata-protocol: narrowed line-initial. Run through the fleet's one reader, both read { kind: 'declared', value: 'no', arm: 'narrowing' } and { kind: 'declared', value: 'narrowed' }. The arm reader's fixed-spelling regex is ^\([ \t]*(widening|narrowing)(?![A-Za-z0-9_])(?![ \t]*\|); the : after narrowing is neither a word character nor a pipe, so the closed arm hits and the explanatory clause is the seat's argument, which the reader does not read (its own docblock gives no (narrowing — the IANA zone domain) as the sanctioned shape). A new refusal, at validate / compile / the publish gate, of a shape that published clean before is a narrowing of the accept set and a requirement tightened on what a metadata author can write: no (narrowing) + narrowed is the correct pair, and check-changeset-no-major.mjs names narrowed beside a line without (narrowing) as its contradiction, which this is not.
  • Ruling letter is met. Ruling C (6383110676: 「访客表单不收附件。供应商先注册账号、登录后再填表上传 … 发布时响亮拒绝「公开表单声明了文件字段」」). Which forms: the rule iterates anonymousFormIntakeCandidates(view) — sharing.enabled === true and allowAnonymous === true and a non-empty publicLink, over form, every formViews entry and a viewKind: 'form' item's config — the very function findPublicFormView in form-routes.ts iterates for GET /forms/:slug and POST /forms/:slug/submit. Which fields: sections[].fields[] (and the legacy groups[].fields[]), entry string | { field }, exactly the two spellings FormSectionSchema.fields admits (z.string() | FormFieldSchema) and exactly what both doors read (form-routes.ts GET: for (const sec of match.form?.sections ?? []) for (const f of sec?.fields ?? []), typeof f === 'string' / f?.field; submit: the same over allowedFields). Which types: FILE_REFERENCE_TYPES.has(type) on the bound object's declared type — image, file, avatar, video, audio at the head, pinned "every type, and only those" against five non-file controls. Severity error, tier gating, commands: ALL (['validate', 'build', 'lint']; compile.ts runs 'build'). Prescription: Take "<field>" off this public form. To collect a file from a visitor, have them register and sign in, then fill a form that is not open to anonymous intake … — register-and-sign-in named, no guest upload offered. Over-refusal: none against the doors' served set. A form with any switch off is not judged (four pins). The zero-sections fallback is closed on both doors already (finding: a section-less public form publishes EVERY field of its target object to anonymous callers #6601 GET publishes nothing; finding: a section-less public form accepts ANY non-anchor field from an anonymous visitor (the write-side twin of #6601) #6920 submit answers 400), so a public form without sections serves no field and is rightly not judged. Two conservative edges, neither a defect: at the publish door a file field in a groups bucket is refused although the two doors read sections only (a stored groups-only form serves zero fields); and the rule judges the body's own sharing, not a withdrawal in another layer nor the tenancy posture — the same stance as the sibling door advisory findPublicFormIntakeGaps, and the right one for a declaration-time rule. Under-refusal: none found. A section that names a group instead of fields, and subforms, are not read, and neither door serves either. A form whose object or field does not resolve in the stack is left to the field-reference rules (pinned).
  • Doors are the same rule, one message. No door file moves (form-routes.ts, rest-server.ts, metadata-core are untouched in the diff). The registry entry declares surfaces: CLI_AND_RUNTIME, runtimeTypes: ['view'], and a view write's snapshot carries objects as resolution context (runtime-gate.ts), which is every input the rule reads. Pin (b) in protocol.runtime-authoring-gate.test.ts asserts atDoor[0].message === atBuild[0].message and the same for hint, with atBuild produced by runAuthoringRules('build', …) over the same stack. Draft save allowed, promotion judged: evaluateRuntimeAuthoringGate D1 ("drafts are never gated"), saveMetaItem passes state: mode === 'draft' ? 'draft' : 'active', and publishMetaItem runs assertRuntimeAuthoringRules({ …, state: 'active', body: draftForGate.body }) on the promoted body; pin (a) saves the draft, then the promotion answers 422 INVALID_METADATA naming the rule; the active save answers 422 with no active view row stored; the control without the file field saves.
  • Object resolution: two readings of one order, superset per rung. anonymousFormObjectName (metadata-core) reads f.data?.object ?? v.list?.data?.object ?? v.form?.data?.object ?? v.object. The rule's candidateObjectName reads the same four rungs through lint's viewObjectName (objectName ?? object ?? data.object) because @objectstack/lint cannot import metadata-core. On a parsed stack they agree: FormViewSchema is strict with data and no object/objectName, and the container ViewSchema has object and refuses data. They can differ only on raw input carrying a bare object/objectName on a form or list, or objectName on the view. That is open question 3, option B, carried by the seat as a follow-up card at merge (ACCEPT 6393424180 §5); recorded here with the exact divergence for that card.
  • Public surface: Clause-②: no holds. @objectstack/lint gains the three exports every authoring rule has (validatePublicFormFileFields, PUBLIC_FORM_FILE_FIELD, PublicFormFileFieldFinding); packages/spec/src/** is not in the diff, so no schema shape, parse or authorable key widens. The one accept-set change is the declared narrowing.
  • Producers: 0, as reported — not re-measured here. The report and ACCEPT measured examples/**, platform-objects, HotCRM a4b3560 and objectui a3c0da9; I did not repeat the measurement. The ! is the honest flag for the accept-set change regardless.

② Semver level

  • .changeset/25650-anonymous-form-file-refusal.md: '@objectstack/lint': minor — correct. Metadata-protocol: narrowed ⇒ at least minor on a package the diff moves (AGENTS.md step 3; check-changeset-no-major.mjs: added / narrowed / semantic → at least one moved package minor+). @objectstack/lint is the only released package whose published source moves; packages/metadata-protocol changes a test file only, and the docs pages publish nothing. Title feat(lint)!: in the PR and the changeset. The body carries the two declaration lines and Ruling-ref:, a BREAKING paragraph with FROM (published clean) / TO (public-form-file-field at the entry; 422 INVALID_METADATA on an active view write and on a draft promotion), a one-line Fix, and an Unchanged list that is true against the diff and the sources read: the parse is untouched (no packages/spec file in the diff), non-public forms are unjudged (pinned), an object write is unjudged (runtimeTypes: ['view']; the object snapshot carries no views context), and OS_ALLOW_UNLINTED_METADATA_WRITES=1 is still read by the gate (runtime-authoring-gate.ts).
  • ADR-0087: exactly one marker, <!-- adr-0087: not-required (no-migration-prescription) … -->, naming a category in CATEGORIES, with the other four categories closed in its own text. The gate's mechanical refusal for this category is a migration prescription in the changeset's own body (a consumer-code rewrite); the body prescribes an authoring choice, not a rewrite. The gate ran inside the Check Changeset job (pr-automation.yml job changeset-check, steps check-empty-changeset, check-adr-0087-registration, check-changeset-no-major on $MERGE_BASE), success on this head twice.
  • CI on e3165041aa: ci-failure.mjs exit 0, GREEN — 46 check-runs after grouping (51 rows, 5 superseded), all completed, none failed. Green by name: Check Changeset, Lint & Repo Gates, every Lint · lane, TypeScript Type Check and the four Type Check · lanes, Build Core, Build Docs, Test Core and shards 1/6–6/6, Dogfood Regression Gate, Governed Surface Queue Guard, Spec property liveness, the single-writer and card-claim guards. Skipped: the UI lanes, Console Pin Gate, Temporal Conformance, Dogfood Verify CLI, the sharded dogfood matrix, both opt-in harnesses.

③ Boundary flags

  • A ## Contract review PASS already stands on this head. Comment 6393521439 by os-elon-musk (Reviewed-by: session_01Bf4vhia9fHNXpqC1PCfxie), created 2026-10-25T04:38:49Z, after the seat's ACCEPT; that account removed needs:contract-review at 2026-10-25T04:38:51Z (label events). This record is the independent reading the seat's ACCEPT said it owed, from the seat's own chain; it concurs on every judgment, so the landing condition does not change. record-recognisers.mjs reads the newest ## Contract review on this head on either thread, so this record is the one read from now on. Provenance note on the prior record: its body carries a hand-typed stamp Written 2026-10-25T04:50Z, later than its own created_at, so it was not written through post-stamped.mjs.
  • Follow-up carried by the seat (open question 3, B): one copy of the object-resolution rung order in @objectstack/spec/ui beside anonymousFormIntakeCandidates, metadata-core re-exporting it. The exact divergence is in ①. Not a blocker: 0 producers hit it as measured.
  • Accepted gap (open question 4, A): an object write that retypes a field to a file type under a published public form is not judged at the object door; objectstack validate / compile and that view's next publish catch it. Stated in the changeset's Unchanged list, the PR body and the report. No carrier, by the seat's ruling; a new gate surface needs a maintainer naming it.
  • Head predates the server half's merge; no conflict possible. PR fix(rest)!: an anonymous public-form submission carries no file #25682 merged to main as e2b40a264b at 2026-10-25T04:00:16Z; git merge-base --is-ancestor says it is not in e3165041aa, which is 9 first-parent commits behind origin/main (c61f75d10f). The intersection of the PR's 15 files with the 49 files main moved since merge base 4cc17db3c0 is empty, so the queue's main merge cannot textually conflict. The rule docblock's sentence "the submit door refuses the value" is true on main, not yet in this head's own tree. No action.
  • Package closure at the door, observation only: a view write's objects context is narrowed to the written item's package closure (runtime gate: full runtime-safe rule snapshot for the publish door — the expensive half split out of the object-gating card #9612, narrowObjectsToPackageClosure: system objects, unpackaged rows, the own package, declared dependencies). A public form binding an object outside that closure resolves no type at the door and is left to the reference rules, as every context-reading rule is; validate / compile over the whole stack judge it.
  • Not governed, per the seat's check-governed-merges --pr 25737 reading (6393419481); not re-run here. The 15 files are lint source and tests, one metadata-protocol test, six docs pages and a changeset. The PR is draft; the landing is the seat's through the relay.
  • Open questions 1 (no CI subscription per the role file) and 2 (linked-worktree procedure) are procedure, with no bearing on the contract.

Implemented-by: claude/issue-25650-anonymous-form-file-refusal
Reviewed-by: session_01PC4NPXPgPmpDRG9DdDnyXT (CONTRACT_REVIEW_TIER subagent)

VERDICT: PASS

needs:contract-review was already absent at this stroke (removed with record 6393521439); label-write.mjs --remove re-asserted it idempotently — 0 calls, read-back matches.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 25, 2026 04:50
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 25, 2026 04:50
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 6ad226a Oct 25, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25650-anonymous-form-file-refusal branch October 25, 2026 05:57
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/l tests tooling

Projects

None yet

3 participants