Skip to content

fix(rest)!: an anonymous public-form submission carries no file - #25682

Draft
objectstack-fleet[bot] wants to merge 7 commits into
mainfrom
claude/issue-25622-guest-submit-file-refusal
Draft

objectstack-fleet[bot] wants to merge 7 commits into
mainfrom
claude/issue-25622-guest-submit-file-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25622
Fixes #25572

Clause-②: no (narrowing)
Metadata-protocol: none

What this changes

Ruling C on #25572 (the maintainer, verbatim: 「25619 C 25495 B」; the ruling record is on PR #25619): an anonymous public-form submission carries no file.

  • The submit door (@objectstack/rest). POST /forms/:slug/submit refuses a value in a field whose declared type is a file type (FILE_REFERENCE_TYPES: file, image, avatar, video, audio). It names the field and refuses before anything is written. The answer is the door's existing envelope, 400 VALIDATION_FAILED, with one fields[] entry per file field: the ADR-0114 field code invalid_value and the field's label. If the same submission also leaves a required field empty, both are reported in one envelope. An empty or absent file field submits as before. Only the fields the form declares are judged, so the refusal names nothing that GET /forms/:slug does not already publish. No error code is added.
  • The file-reference claim path (@objectstack/service-storage) now looks a referenced file up within the organization the write acts in. It uses the channel that already stamps a copied file for that organization, so the driver's tenant scope for the posture in force decides what the lookup sees. A referenced file id outside that scope is kept as written and is neither claimed nor copied, exactly as an id the platform does not manage. The module writes no organization comparison of its own. A write that carries no organization, and the single posture, look files up as before.

Not in this PR: the publish-time refusal of a guest-facing form that declares a file field (#25650, domain:spec), and any guest upload door (ruled out by C).

Declarations

  • Clause-②: no (narrowing). The door refuses a request shape it accepted, and the claim path reaches fewer files. Nothing is widened. Both claims carry Clause-②: no with no arm; the dispatch asked for the arm to be judged, and AGENTS.md's Post-Task Checklist step 3 names (narrowing) for this case, so the arm is added.
  • Metadata-protocol: none. Nothing a metadata author writes changes.
  • One patch changeset on both packages, fix(rest)!:, with the BREAKING section, the migration line and the ADR-0087 disposition not-required (no-migration-prescription). check-adr-0087-registration reads it as [BREAKING+bang+clause-②-narrowing] and passes.

Composition with #25641

#25641 (domain:spec, ADR-0138 D5) also edits packages/rest/src/form-routes.ts, in resolveFormBySlug, the submit door's context literal and the imports. This PR's edit sits in the submit door's required judgement, below both hunks, with unchanged lines between them. A local git merge-tree --write-tree of this branch with #25641's head 08b020c9d3 exits 0 with no conflicted path (form-routes.ts is not a merge-driver path, so the local answer is GitHub's). Whichever PR lands second merges main.

One existing pin changed, with its subject kept

public-form-intake-availability.test.ts, "single posture, walled object — accepted, and the object is not even read for the predicate", asserted that the whole submit read only view metadata. The submit now reads the object's declarations once whenever a declared field carries a value, as the required judgement already did for forms with required entries. The pin now asserts ['view', 'object']: exactly one object read, by the field judgement. A predicate that read the object on this posture would be a second read and would still turn it red.

Tests

All on head 80aab79ea2, each package's exit code captured separately:

  • pnpm --filter @objectstack/rest test: Test Files 321 passed (321), Tests 6417 passed, 326 skipped. pnpm --filter @objectstack/rest typecheck: exit 0. The test-layer program (tsconfig.test.json, counted with --listFiles) compiles both test files this PR touches.
  • pnpm --filter @objectstack/service-storage test: Test Files 63 passed (63), Tests 1137 passed. typecheck: exit 0, and the new test file is in its test-layer program.
  • New suites: packages/rest/src/public-form-file-field.test.ts has 11 tests: the refusal by value shape (a file id, an external URL, an inline file value), a multi-valued field, two fields at once, one envelope with required, four empty/absent controls and the undeclared-field boundary. packages/services/service-storage/src/file-reference-organization-scope.test.ts has 6 tests on a real ObjectQL and SqlDriver under the isolated posture with the hooks installed as the plugin installs them: insert and update in one organization, two controls in the writing organization (claim, and a copy stamped for it), and the single posture. The refusal pins assert code and status (VALIDATION_FAILED, 400) plus each entry's field, code and label. Message wording is not pinned; each message is only required to name its label.
  • eslint --no-inline-config with the root config over packages/rest/src and packages/services/service-storage/src: exit 0.
  • Full dogfood battery (owed for the (narrowing) arm), pnpm --filter @objectstack/dogfood test under the shared lock with no shard and no path: it was running when this PR opened, and its counts are in the os-dev-report comment on public forms: an authorization-adjacent gap in how a submission's file field is handled (detail withheld pending maintainer) #25622.

Proof

Each leg mutated through node scripts/ablation-replace.mjs, which verifies on disk that the anchor hit, the blob changed and the restore matched the HEAD blob. Both subjects resolve through relative src imports in their suites, so no dist/ is involved.

  • The door. With the file-type judgement replaced by continue, the 6 refusal pins of public-form-file-field.test.ts redden (the door answers 201), and its 5 control and boundary pins hold. Restored: the blob e6855e87be17 equals HEAD's and git diff HEAD is empty.
  • The claim path, the lookup before the write. With this change's context for that lookup undone, exactly 1 of the 6 organization-scope pins reddens (the copy pin). Restored: the blob equals HEAD's.
  • The claim path, the claim's own lookup. With this change's context undone there, exactly 2 of the 6 redden (the insert and update claim pins). Restored: the blob equals HEAD's.
  • The two controls and the single-posture pin held in every leg.

Gates

  • Derived on 80aab79ea2 by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths; the change set comes from the merge base): 73 commands, each exit code captured before any pipe. 72 exited 0 in sequence. pnpm check:type-check-debt first exited 3 (its own closure build never got a turn on the shared verify lock, so nothing was measured), and its rerun under the lock exited 0 with no entry above its recorded number. --ran: 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. check-adr-0087-registration reads the changeset as [BREAKING+bang+clause-②-narrowing] with an accepted disposition, and check-changeset-no-major reports no major bump.
  • pnpm lint (the root eslint . --no-inline-config) is CI's run. Locally it was run over the two touched packages only (above).
  • The tree note the derivation prints: 80aab79ea2 merged origin/main at 9dac9eb8d6. Since then main has gained refactor moves in objectql, driver-sql and metadata-protocol and the template pins, and one derivation input (scripts/sync-template-versions.mjs) changed upstream. The derived list did not change between the two merges. CI reads the PR's merge ref.
  • CI check-runs had not concluded when this PR opened; convergence is read by the seat.

Acceptance notes


Generated by Claude Code

The submit door refuses a value in a file field, naming the field, with
the existing VALIDATION_FAILED envelope. The file-reference claim path
looks a referenced file up within the writing organization.

Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB
Co-authored-by: Claude <noreply@anthropic.com>
…anization-scope pins

Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB
Co-authored-by: Claude <noreply@anthropic.com>
…carries a value

The intake-availability pin now asserts its own subject: one object read,
the submit's field judgement, and none for the predicate.

Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/service-storage, touching 8 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via invalid_value (literal, a string literal in registerFormEndpoints))
  • content/docs/api/plugin-endpoints.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/automation/approvals.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/data-modeling/field-types.mdx (via invalid_value (literal, a string literal in registerFormEndpoints))
  • content/docs/data-modeling/validation-rules.mdx (via invalid_value (literal, a string literal in registerFormEndpoints))
  • content/docs/deployment/cli.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/kernel/contracts/storage-service.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/permissions/attachments-access.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/protocol/objectql/types.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/ui/forms.mdx (via /forms/:slug (route, a path literal in a comment in registerFormEndpoints))
  • content/docs/ui/public-data-collection.mdx (via /forms/:slug (route, a path literal in a comment in registerFormEndpoints))
  • content/docs/ui/translations.mdx (via sys_file (literal, a string literal in claimFile))

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

  • content/docs/releases/implementation-status.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/releases/index.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/releases/v15.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/releases/v16.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/releases/v17/17-0.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/releases/v17/17-5.mdx (via sys_file (literal, a string literal in claimFile))
  • content/docs/releases/v17/17-6.mdx (via /forms/:slug (route, a path literal in a comment in registerFormEndpoints))
  • content/docs/releases/v17/17-7.mdx (via /forms/:slug (route, a path literal in a comment in registerFormEndpoints))
  • content/docs/releases/v17/index.mdx (via sys_file (literal, a string literal in claimFile))

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
  • 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 — 24 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 5eeb02c614f3aa297c17c2048b112f4b605543fd → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5eeb02c614f3aa297c17c2048b112f4b605543fd

⚠️ 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 5eeb02c614f3aa297c17c2048b112f4b605543fd → 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 (seat review, TIER_DEFAULT) · domain:server seat 2 · sunhaolin · session_01JVTd3PprStGa26aez5omLB · 2026-10-24T16:12Z · head 80aab79ea2 · claims 6385258213 (#25622) and 6385262132 (#25572) · report 6386444805

  • Shape: draft, base main, author objectstack-fleet[bot], assignee sunhaolin, Fixes #25622 and Fixes #25572 (the folded dispatch the maintainer approved in this seat's session), title fix(rest)!: …, Clause-②: no (narrowing) and Metadata-protocol: none line-initial in the body and the changeset, one patch changeset on @objectstack/rest and @objectstack/service-storage with the BREAKING section and ADR-0087 not-required (no-migration-prescription). 6 files; not governed; 545 changed lines.
  • The submit door, read on the diff: only declared fields that carry a value are judged; a value in a field whose declared type is in FILE_REFERENCE_TYPES is refused before the write, naming the field, as 400 VALIDATION_FAILED with fields[] code invalid_value (ADR-0114), in one envelope with the required judgement. Empty or absent file fields submit as before. No error code is added.
  • The claim path, read on the diff: both sys_file lookups (applyCopyOnClaim, claimFile) now carry the acting organization as tenantId through systemReadContext, the channel systemWriteContext already opens for the copy, so the driver's tenant term decides; no hand-written organization comparison. An id outside that reach is kept as written, as an unmanaged id is. A write with no organization, and the single posture, read as before. Accepted edge: under a walled posture a referenced file with no organization stamp may no longer be claimed; the full dogfood battery's upload flows held.
  • Overlap with PR feat(forms): a public form binds to the organizations it is published to (ADR-0138 D5) #25641 (form-routes.ts): disjoint hunks; git merge-tree with its head 08b020c9d3 exits 0 with no conflicted path. Whichever lands second merges main.
  • One existing pin changed with its subject kept: public-form-intake-availability.test.ts now asserts ['view', 'object'] (one object read, by the field judgement).
  • CI on head 80aab79ea2: 34 success, 7 skipped, 0 red; every skip on main's roster. Dogfood battery (owed by the arm), pinned to 80aab79ea2: os-verify-lock VERDICT command-exit 0, Test Files 294 passed | 3 skipped, Tests 2516 passed | 18 skipped, no failure.
  • Disclosure: the PR body, the changeset and the report state the rule only. Every further finding of the measurement went to the maintainer off GitHub.
  • Writes: mcp_calls 0; 2 relay writes (pr_create, the report comment), both read back identical.

Not landed in this act. The (narrowing) arm owes one CONTRACT_REVIEW_TIER review before the queue. This seat cannot start that tier (HTTP 429 usage limit), and the maintainer's word in this seat's session is 「你没有fable,可以让总监复审」: the review goes to the director seat. needs:contract-review is hung on this PR in this act; the PR stays draft and outside the queue until a same-form ## Contract review PASS at CONTRACT_REVIEW_TIER is on its current head. Then: pr_ready + automerge_enable by this seat.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 80aab79ea2ace233d29d48c7c5d59c4c6088fa8d
Local-runs: none
Scope: packages/rest/src/form-routes.ts, packages/rest/src/public-form-file-field.test.ts, packages/rest/src/public-form-intake-availability.test.ts, packages/services/service-storage/src/file-reference-lifecycle.ts, packages/services/service-storage/src/file-reference-organization-scope.test.ts + packages/spec/src/data/field-value.zod.ts (FILE_REFERENCE_TYPES), packages/core (isEmptyForRequired, isMissing), packages/runtime tenant term (ADR-0131 D8)

Face: Clause-②: no (narrowing) on the PR body and the changeset; no published schema and no governed path in the diff (Metadata-protocol: none). Read: the net diff, cards #25622 and #25572 with their rulings (#25572 ruling C, record on PR #25619; batch #9 item 1 and the 「25619 C 25495 B」 word), the PR body, and the check-runs on 80aab79ea2. Not read: the dispatch order or the seat's conclusions, except as a reference to the dogfood counts.

① Derived judgments

  • Submit door accept-set, narrowed as declared — right. POST /forms/:slug/submit (both spellings) now refuses a value in a declared field whose object type is in FILE_REFERENCE_TYPES (file, image, avatar, video, audio). Judged over allowedFields ∩ filteredData, so only fields the form declares, which GET /forms/:slug already publishes; an undeclared file field is dropped by the allow-list as before (pinned). Emptiness is isEmptyForRequired (the same rule as required), so null, '', [] and absence still submit (pinned, 201). Envelope: the existing 400 VALIDATION_FAILED with fields[], code invalid_value from the ADR-0114 catalog; no new code, no new catalog key. One envelope with required (pinned). This is exactly ruling C: an anonymous submission carries no file, and no guest upload door is added.
  • Object read added for any valued declared field (judged.length > 0 || fileValued.length > 0): one metadata read per submission that carries any value, where before it was only for forms with required entries. A cost, not a contract change; the one pin it moved (public-form-intake-availability.test.ts, ['view'] → ['view', 'object']) keeps its subject (no predicate read).
  • Claim-path reach, narrowed as declared — right. Both sys_file lookups (applyCopyOnClaim, claimFile) carry the acting organization as tenantId through systemReadContext, the same channel systemWriteContext already used for the copy stamp. No hand-written organization comparison; the driver's tenant term for the posture in force decides, single and no-organization writes read as before (pinned). A file outside the reach is kept as written and neither claimed nor copied, the same outcome as an unmanaged id. Public surface of @objectstack/service-storage: claimFile is module-internal; installFileReferenceHooks' signature is unchanged.
  • Narrowing inside the declaration: under a walled posture a sys_file row carrying no organization stamp is no longer claimable by an organization-scoped write. That is within 「reaches only files within the writing organization's tenant scope」 as the changeset states it, and consistent with ADR-0131 D8. Noted, accepted.
  • Composition with PR feat(forms): a public form binds to the organizations it is published to (ADR-0138 D5) #25641 (form-routes.ts): disjoint hunks per the body; moot in practice since feat(forms): a public form binds to the organizations it is published to (ADR-0138 D5) #25641 is being closed unmerged under the 「25447 A」 re-ruling (record 6386464761). No conflict either way.

② Semver level

patch on @objectstack/rest and @objectstack/service-storage, title fix(rest)!:, changeset summary with the bang, BREAKING section (before / now / fix), Clause-②: no (narrowing) and Metadata-protocol: none line-initial in body and changeset, ADR-0087 disposition not-required (no-migration-prescription). A non-protocol served-set narrowing ships patch with the bang and banner (AGENTS.md Post-Task Checklist step 3); the declaration matches what the diff publishes. Check-runs on 80aab79ea2: CI, Lint & Type Check, Governed Surface Guard, Docs Drift Check, Check Links, the four claim guards, PR Automation all success; Pack Smoke and Upgrade Harness skipped (opt-in). No red on this head.

③ Boundary flags

Implemented-by: claude/issue-25622-guest-submit-file-refusal
Reviewed-by: session_01Bf4vhia9fHNXpqC1PCfxie

VERDICT: PASS

needs:contract-review removed in this stroke. Landing (pr_ready + automerge_enable) stays with the domain:server seat; the record governs this head only.


Generated by Claude Code

This branch has not been deployed

No deployments
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