Skip to content

finding(forms): a public form's field-level required is not enforced on the anonymous submit; a POST without the field answers 201 with null #24131

Closed

Description

@objectstack-fleet

Found by the repo:www.objectos.ai seat (#12224) while staging www.objectos.ai#272 on 2026-10-17. Measured on the published @objectstack/cli 17.7.0 against examples/app-crm taken from tag @objectstack/cli@17.7.0. Class c: a declared constraint that has no consumer on one path.

What happens

  • The form declares email required. The web_to_lead form in examples/app-crm/src/views/lead.view.ts:117 (tag) declares { field: 'email', required: true }. content/docs/ui/public-data-collection.mdx:23-27 shows the same declaration shape.
  • The form's required is ignored. An anonymous POST /api/v1/forms/contact-us/submit without email answers 201, and the row is stored with email: null.
  • The object's required is enforced. A POST without name, which is required on the object, answers 400 VALIDATION_FAILED "Lead Name is required".
  • Where it comes from. rest-server.ts:11024-11093 (tag) builds the allow-list from sections[].fields and never reads required. The same is true on origin/main 570c7a2, rest-server.ts:9408-9473.

The docs do not promise that the form-level required is enforced on submit, so this is a trap rather than a contract breach.

Why it matters

An author, human or agent, who marks a field required on the public form sees the Console form enforce it, and believes the server does too. The public endpoint is exactly the path a script or bot uses without the rendered form.

Done looks like

One of these, and the docs say which:

  • (a) The submit route enforces the form's required and answers 400 naming the field.
  • (b) The docs and the spec's field description state that form-level required is a rendering hint, and that server-side requiredness belongs on the object.

Either way, add a pin for a public submit that omits a form-required field.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, priority:p1 · target:v18 · domain:cli · area:api · bug · pm:queue · route (a) · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-17T03:59Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.

    • Still on the 18 line: measured on 17.7.0; on origin/main 462d02f00a, packages/rest/src/form-routes.ts (where PR refactor(rest): move registerFormEndpoints into form-routes.ts (#23050 slice 11) #24054 moved the submit route) reads no form-field required. Its only required hits are the slug checks at :211 and :416.
    • Route (a), by the governing rule, not a decision: the form declares required: true, and the rule is 「声明即强制」 and 「spec 声明 > 实现 > 文档面」. The submit route derives its allow-list from that same declaration, so it enforces the declaration's required too, answering 400 with the field named, in the same envelope as the object-level VALIDATION_FAILED. Route (b), "a rendering hint", would leave a declared constraint unenforced on the one door a bot uses.
    • Why p1 + target:v18: it narrows what a public REST door accepts. Under protocol-first that is p1 before GA.
    • Pin: an anonymous submit without a form-required field answers 400 naming it, and with it answers 201.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1, serial, p1/p2 only (the maintainer to this seat, verbatim 「串行派发,只处理 p1 p2」; #24024's PR #24149 waits only in the merge queue). Label order read in this act: among the target:v18 cards, the p1 bugs rank first, and #24131 is older than #24137. #24131: an anonymous submit to a public form that omits a field the form declares required answers 400 naming the field, and no row is stored · 2026-10-17T05:21Z
    Session: session_01BVm1pAXVXQ3PjC42j1kPFf
    Account: os-sales (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24131-public-form-required
    Worktree: objectstack-issue-24131
    Domain: domain:cli
    Seat: domain:cli#1 (seat post #6024)
    File surface (read on origin/main 747c8e9c4; line numbers move; stop on a breach and explain it in the report):

  3. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24131,
      "status": "needs_decision",
      "branch": "claude/issue-24131-public-form-required",
      "pr": null,
      "session": "session_01BVm1pAXVXQ3PjC42j1kPFf — subagent run under the PM session (parent's id); claim 6230121735 names this branch, verified newest Claim: before any work",
      "premise_still_valid": true,
      "summary": "Premise reproduced on origin/main 747c8e9c4 through the real route (RestServer + ObjectStackProtocolImplementation + ObjectQL + SqlDriver sqlite): a form-required field that is absent, null, '' or whitespace-only answers 201 and is stored (null / '' / '   '), and a form-required multi-value field absent or [] answers 201; the object-required field answers 400 VALIDATION_FAILED. No code written, no PR: PM mechanism assumption 3 forks here. The engine's emptiness predicate (record-validator.ts isEmptyForRequired :321 over isMissing :304) is module-private, @objectstack/rest holds @objectstack/objectql as a devDependency only, and check:undeclared-dep-imports' shrink-only LEDGER records that rest is deliberately not coupled to the engine, so reuse needs a change outside this lane; per the dispatch ('report it instead of copying it') this returns the placement question with options. The remote branch exists at 747c8e9c4 with zero commits (landing marker only).",
      "tests": "No committed test (no diff). One scratch probe, run and deleted, never committed (copy kept in the scratchpad as zz-probe-24131.test.ts.txt): `NODE_OPTIONS=--max-old-space-size=4096 bash scripts/pm/os-verify-lock.sh -c \"pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/zz-probe-24131.test.ts\"` -> VERDICT command-exit 0, Test Files 1 passed, Tests 1 passed, after building the closure `pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build` (VERDICT command-exit 0, 198s). Fixture: object name(text, required, label 'Lead Name') / company / email(email) / tags(tags); form sections fields [{field:name,required:true},{field:company},{field:email,required:true},{field:tags,required:true}] with sharing.allowAnonymous, slug probe. Readings, sent -> status -> stored: email absent -> 201 email null; email null -> 201 null; email '' -> 201 ''; email '   ' -> 201 '   '; tags absent -> 201 tags null; tags [] -> 201 []; name absent -> 400; name '' -> 400; all present -> 201. The object-level refusal body on THIS route, measured: {\"error\":\"Lead Name is required\",\"code\":\"VALIDATION_FAILED\",\"fields\":[{\"field\":\"name\",\"code\":\"required\",\"message\":\"Lead Name is required\",\"label\":\"Lead Name\"}]} — the code is TOP-LEVEL `code` (flat mapDataError arm, packages/types/src/data-error-classification.ts:1183), not a nested error.code; the refusal pins the follow-up owes should assert that path. No ablation: no behaviour changed.",
      "gates": "`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final (clean) tree -> exit 2: 'this branch changes nothing against origin/main (merge base 747c8e9c4) — nothing to derive'; --repo holds. No local family owed, no --ran reconciliation possible, no eslint run (no changed file), no PR so no check-runs. NOT MEASURED: every family, reason: no diff.",
      "mcp_calls": "0",
      "api_writes": "1 — the os-dev-report comment on #24131 via scripts/pm/post-stamped.mjs (relay route as the tool selects it). git push of the empty branch is not a REST write. No pr_create, no label-write.",
      "line_budget": "n/a — no file changed",
      "files_changed": [],
      "deviations": [
        "No PR, no changeset, no pins: the dispatch's assumption-3 instruction for exactly this fork ('If reusing it needs a change outside this lane, report it instead of copying it'), and the os-dev rule against speculative code.",
        "A scratch probe test file was written under packages/rest/src in the worktree, run once through the verify lock, and deleted before the gate derivation; git status --porcelain empty afterwards.",
        "Built the @objectstack/rest dependency closure for the probe (no diff of mine reached any package)."
      ],
      "mechanism_assumptions": [
        "1 CONFIRMED: packages/rest/src/form-routes.ts submit route builds allowedFields at :434-439 from sections[].fields (string or .field) and reads no form-field required; the only `required` hits are the slug checks at :211 and :416.",
        "2 CONFIRMED, slot named: FormFieldBaseSchema `required: z.boolean().optional().describe('Required override')` at packages/spec/src/ui/view.zod.ts:3439, reached through FormSectionSchema (:3703) fields = union of string and FormFieldSchema (:3667, the .strict() extension of the base). The route does not re-parse: it reads match.form.sections from the registered/stored view body. Liveness: packages/spec/liveness/view.json row props.form.children.sections — status live, verifiedAt 2026-08-11, evidenceScope cross-repo, a BLANKET verdict for the whole FormField subtree (undrilled inheritance, no per-key `required` row); its server-side evidence names the submit route for allowedFields only, so `required` rides a live verdict no server read point backs. Not edited (spec seat).",
        "3 LOCATION CONFIRMED, REUSE FALSIFIED: the object-level check is validateOne's required arm, packages/objectql/src/validation/record-validator.ts:1037 (`!skipRequired && def.required && isEmptyForRequired(def, value) && def.type !== 'autonumber'`), code 'required', thrown as ValidationError (code VALIDATION_FAILED_CODE). The predicate: isMissing :304 (undefined, null, string whose trim() is '') plus, in isEmptyForRequired :321, [] on a field spec's isMultiValueField calls multi-value. Neither is exported: objectql index.ts:425 and core.ts:90 export only ValidationError, validateRecord, VALIDATION_FAILED_CODE (buildFieldError / resolveFieldLabel, the entry constructor and label chain, are not exported either). packages/rest/package.json lists @objectstack/objectql under devDependencies only; scripts/check-undeclared-dep-imports.mjs LEDGER (keyed pkg+dep+file, shrink-only) has one rest->objectql row, rest-server.ts optional-runtime-probe, whose reason reads '`@objectstack/rest` is deliberately not coupled to the engine'. A new import of objectql in form-routes.ts, static or dynamic, is an unledgered finding."
      ],
      "open_questions": [
        {
          "question": "Where does the one required-emptiness predicate live so the public form submit route and the engine's object-level check read the same rule? (Route (a) itself is ruled; this is only the reuse seam the dispatch said to report.)",
          "options": [
            "A — hoist isEmptyForRequired (with its isMissing) into packages/spec/src/data/field-value.zod.ts beside isMultiValueField, typed on ValueShapeFieldDef, exported from @objectstack/spec/data; record-validator.ts imports it and deletes its private copies (byte-identical semantics); the submit route imports it from @objectstack/spec/data (already a declared dependency), reads required off the same entries the allow-list reads, and throws validationFailure(...) from @objectstack/types with entries rendered through renderValidationMessage (@objectstack/spec/system), so the catch's mapDataError arm answers the identical flat 400. Cost: one new spec export (gen:api-surface, spec seat), an import-only edit in objectql (engine suite reruns), then this card resumes small (route + pins + changesets). Work S-M, two lanes in sequence. Residual: the entry constructor (buildFieldError + resolveFieldLabel) stays objectql-private, so REST builds {field, code:'required', message, label} through the shared renderer.",
            "B — the same hoist into @objectstack/types (depends on spec only; both rest and objectql already depend on it; validation-failure.ts there already owns the VALIDATION_FAILED envelope constructor). No spec edit and no spec seat; but a value-semantics predicate lands in an HTTP-boundary utility package, away from isMultiValueField / isEmptyFilterValue, the established home of value-level predicates. Work S-M.",
            "C — the engine judges it: the submit route passes the form's required field names on the create context it already builds (beside publicFormGrant, which ADR-0056 defines as authorization derived from the declared form) and the insert overlays required:true on those defs for that write. Reuses everything (predicate, entry constructor, label + i18n, one merged fields[] with object-level refusals, judged after normalization). Cost: engine insert-path change (a dispatch hold-off), a new context contract (spec-typed), M-L; and a semantic shift: an object default or a beforeInsert stamp would satisfy the form's required, where the triage pin asks for 400 on the visitor's omission.",
            "D — rest takes @objectstack/objectql at runtime (declared dependency, or a second optional-runtime-probe LEDGER row for form-routes.ts) and asks the exported validateRecord, keeping only code:'required' entries and rethrowing them as ValidationError. Reverses the stance the LEDGER records, grows a shrink-only ledger, judges un-normalized values (so non-required verdicts must be discarded), and has to pick fail-open or fail-closed when the engine is absent. Not recommended."
          ],
          "axes": {
            "actual_business_need": "Real and measured: examples/app-crm web_to_lead declares {field:'email', required:true}, content/docs/ui/public-data-collection.mdx:23-27 teaches the same shape, the www seat measured it on 17.7.0, and the probe reproduces it on main. The emptiness rule has three live arms on this door (absent/null, whitespace-only string, [] on a multi-value field) — the probe stored '   ' and [] with 201 — which is exactly what a hand-written REST check drops. A, B and C all serve it with the engine's own rule; D serves it through an oracle.",
            "long_term_soundness": "A repeats a landed precedent: isMultiValueField's docblock records the same move ('THE shared definition (was duplicated verbatim in record-validator + import-coerce)'). One predicate, two doors, no new coupling, no new contract. C is the deepest single source but widens the engine's write contract for one door. D reverses a recorded layering stance.",
            "prevent_ai_mistakes": "A/B make the rule importable, so the next door that asks 'is this required value empty?' imports it instead of writing another private copy (screen-input-contract.ts already carries one, isPresent, without the [] arm). C gives the strongest envelope agreement. D and a copy are the drift generators the dispatch excluded.",
            "startup_focus": "A and B move an existing private function and add no gate, no contract, no dependency edge; A adds one spec export. C adds an engine contract. D adds a dependency edge and a ledger row."
          },
          "recommendation": "A, because it is the precedent-matching single source (beside isMultiValueField, which the predicate already calls), keeps rest's protocol-only layering, adds no contract or gate, and leaves this card a small route + pins change once the spec seat lands the export; B is the fallback if the spec seat reads Prime Directive 2 against a predicate in spec. Either way the follow-up pins assert top-level code VALIDATION_FAILED, status 400, fields[].field naming the field, a whitespace-only string and a [] multi-value case, and a 201 control that stores the row."
        }
      ],
      "out_of_scope_findings": [
        "carrier: #24131 (this card's follow-up, spec seat half) · the view.json props.form.children.sections blanket live verdict covers FormField `required` with no server read point; when the fix lands the row's evidence owes the submit route's required read · noted, not filed",
        "carrier: none (承接者:无) · packages/services/service-automation/src/screen-input-contract.ts:56 isPresent is a further private emptiness copy (no [] arm); observation only, no reach measured · noted, not filed"
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1, serial, p1/p2 only — RE-DECLARED for #24131 by the same session, same branch: the file surface widens to the shared required-emptiness predicate and the Clause-② line moves to yes, on the seat's answer below to the dev's needs_decision (os-dev-report 6230265113) · 2026-10-17T05:44Z
    Session: session_01BVm1pAXVXQ3PjC42j1kPFf
    Account: os-sales (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24131-public-form-required
    Worktree: objectstack-issue-24131
    Domain: domain:cli
    Seat: domain:cli#1 (seat post #6024)
    Ruling-ref: 6227928430
    Resume-from: claude/issue-24131-public-form-required@747c8e9c48378bdf15b1ea6e406d535d815770bb
    File surface (read on origin/main 6dede7a51; line numbers move; stop on a breach and explain it in the report):

    • packages/core/src/**: one module, or one existing utility file, that holds the required-emptiness predicate. That is isEmptyForRequired with the isMissing it calls, moved from objectql with byte-identical semantics, typed on @objectstack/spec/data's ValueShapeFieldDef, and exported from core's . entry.
    • packages/objectql/src/validation/record-validator.ts: its private isMissing (about :304) and isEmptyForRequired (about :321) are deleted, and every read site imports core's. No behaviour changes.
    • packages/rest/src/form-routes.ts: the anonymous submit route (POST /forms/:slug/submit), whose allow-list (about :434-:439) comes from sections[].fields. It reads the same entries' required, judges them with core's predicate, and refuses before any write in the flat 400 VALIDATION_FAILED envelope the object-level refusal answers on this route.
    • Pins beside each of the three. Changesets: @objectstack/core minor (the new export), @objectstack/rest patch, and @objectstack/objectql only if its published bytes change.
    • content/docs/ui/public-data-collection.mdx: at most one sentence, as before.
    • ⛔ Hold-offs: every packages/spec edit (schema, describe text, liveness row); GET /forms/:slug; objectql's other validation arms; packages/services/service-automation/src/screen-input-contract.ts's isPresent (noted by the dev, not this card); the CRM example and docstring finding(forms): the CRM example and the submit route's docstring say the guest_portal profile authorizes an anonymous form submit; measured on 17.7.0, it does not #24130 names; the other register*Endpoints modules.
      Container & model: M (one moved predicate, one engine import swap, one door; the door's refusal is a published narrowing), mode:subagent, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate)
      Clause-②: yes
      Basis for the Clause-② line: a WIDENING of @objectstack/core's public face: one new export from its . entry, minor, exactly as the inherited ruling 6227928430 declares for its own core export. Riding with it is a NARROWING of a published REST door's accept set: a submit that omits a form-required field answered 201, and it answers 400. Nothing refused becomes accepted. No error code is minted. The PR owes one CONTRACT_REVIEW_TIER review of record over the whole diff except the prose faces before the queue, and needs:contract-review on the PR until it is recorded (contract-review.md). It is not spec-lane work, because no packages/spec path moves: the ruling homes the export in core, as runtime: one internal request-to-ExecutionContext resolver for the platform's five hand-rolled copies (rest, runtime, service-settings, service-storage, plugin-hono-server) — not a published export (ruling C′ on #23128, p3) #23217 executes its own.
      Responsibility: packages/rest's public form submit route (this lane), plus the shared predicate's move into packages/core and the import swap in packages/objectql (domain:engine, crossed under this claim and declared on its seat post) | the engine's object-level required check covers only fields the OBJECT requires; nothing on this door reads the form's own required | an anonymous public-form submitter (a script or bot), measured on 17.7.0 by the repo:www.objectos.ai seat and reproduced on origin/main 747c8e9c4 through the real route by the dev (6230265113): absent, null, '', whitespace-only and [] all stored with 201
      Thread-read: 6230265113
      Serial constraints cleared: the 5 open PRs read in this act; none edits packages/core/src/, packages/objectql/src/validation/ or packages/rest/src/form-routes.ts. No open pm:dispatched claim in domain:engine or this lane declares those files. runtime/plugin-sharing: on the standalone server, anonymous callers to unmounted /notifications and /share-links verbs and paths get 405 or 404 where the v18 floor promises 401 #24024 landed (6dede7a51).

    The seat's answer to the dev's needs_decision: an inherited ruling, ⛔ not a new decision

    • The mother ruling, verbatim in its binding parts. [Decision] #23217 cannot land as ruled: four of the five request-to-context doors cannot import an internal function of @objectstack/runtime. Home it in @objectstack/core (a new export), keep five copies under a parity pin, or close #23982 was ruled A at 6227928430 (batch [WIP] Update action step configuration based on job run #358 item 1, the maintainer's 「23982 同意」): "the one request-to-context function lives in @objectstack/core … and is exported from core's . entry (Clause-②: yes (widening), minor on @objectstack/core)", and "A door that intentionally differs keeps its difference as a named parameter of the one function, never as a copy".
    • Its reason holds on this branch, measured by the dev. One rule is needed by packages that cannot import the package that holds it. @objectstack/rest lists @objectstack/objectql as a devDependency only, and check:undeclared-dep-imports' LEDGER records that rest "is deliberately not coupled to the engine". Both rest and objectql already depend on @objectstack/core, and core already depends on @objectstack/spec for isMultiValueField.
    • Governing text: AGENTS.md Prime Directive 2, "No business logic in packages/spec … Runtime logic goes in core, runtime, or services/*". That rules out the report's option A (spec) on its own. Option B (@objectstack/types) is not the ruling's home. Option C (the engine overlays the form's required) contradicts triage's pin, because an object default would then satisfy a field the visitor omitted. Option D (rest imports the engine) reverses the recorded stance.
    • Carried into the pins from the dev's measurement: the refusal on this route is FLAT, with a top-level code: "VALIDATION_FAILED" and fields[].field naming the field (the mapDataError arm). The pins assert that path, and cover whitespace-only and [] on a multi-value field, with a 201 control that stores the row.
  5. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24131,
      "status": "done",
      "branch": "claude/issue-24131-public-form-required",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24161",
      "session": "session_01BVm1pAXVXQ3PjC42j1kPFf — subagent run under the PM session (parent's id); resumed on the re-declared claim 6230321666 (same session, same branch, Clause-②: yes), verified before any work",
      "premise_still_valid": true,
      "summary": "Built the seat's ruling end to end. (1) isMissing and isEmptyForRequired moved out of objectql's record-validator.ts into packages/core/src/utils/required-emptiness.ts, unchanged, typed on @objectstack/spec/data's ValueShapeFieldDef, and exported from core's `.` entry beside multi-value-storage-form.ts; the module is pinned per arm in core. (2) record-validator.ts deletes its private copies and imports core's at all five read sites; objectql's validation suites read 22 files / 998 passed before and after. (3) The submit route in form-routes.ts reads `required` off the same entries its allow-list reads. It judges each such field on the filtered payload with core's predicate, before any write, and refuses through validationFailure(...) into the flat mapDataError VALIDATION_FAILED envelope, field for field the object-level one. Entries that are hidden or gated by visibleWhen (on the entry or its section) are not judged, since nothing server-side evaluates a form-view visibleWhen; server-managed anchors are not judged either. Draft PR #24161 is open (assignee os-sales, base main, body read back byte-identical); CI was in_progress at report time.",
      "tests": "Head be605e655, merge base 6dede7a51. Core: `pnpm --filter @objectstack/core exec vitest run --project local --maxWorkers=2 src/utils/required-emptiness.test.ts` -> 26 passed. Full `pnpm --filter @objectstack/core test` -> Test Files 103 passed, Tests 2531 passed. objectql BEFORE the move (core dist at the base): `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 src/validation` -> 22 files / 998 passed. AFTER (core rebuilt; dist/index.js carries isEmptyForRequired): 22 files / 998 passed. Full `pnpm --filter @objectstack/objectql test` -> 437 files / 8236 passed. Rest pins, through the real route (RestServer, ObjectStackProtocolImplementation, ObjectQL, SqlDriver sqlite :memory:; only the metadata read is stubbed): `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/public-form-required.test.ts` -> 11 passed at be605e655. The 11 cases: absent / null / '' / whitespace on form-required email, and [] / absent on form-required tags, each 400 naming the field with the row count unchanged; two missing fields named together; a CONTROL that answers 201 and stores the row as sent; the object-only required field still refused by the engine with the same envelope keys; a both-levels field answering the exact pre-fix engine body; and the hidden/conditional boundary at 201. The seven public-form suites together gave 7 files / 100 passed at d4e51629a. Full `pnpm --filter @objectstack/rest test` at d4e51629a -> 291 files, 6007 passed, 326 skipped; the only later commit types the pins' engine reads. ABLATION, once, on the new refusal: the tested route resolves from src, so no rebuild. It ran through scripts/ablation-replace.mjs in WRAP mode with an EXIT/INT/TERM trap. Mutation leg: anchor `throw validationFailure(missing.map((f) => f.message).join('; '), missing);` x1 -> x0, replacement x0 -> x1, blob e5f50db0a754 -> 8107a5a036c7, result 8 failed | 3 passed (11). The direction matched the prediction: every form-only cell and the envelope test went red; the control, the boundary and the both-levels case stayed green, because the engine refuses that last one with the identical body. Restore leg: blob after restore == HEAD e5f50db0a754, git diff HEAD empty, marker count 0, then the full rest run above green. objectql published-bytes decision, measured on its built dist (files: dist): before the move `function isEmptyForRequired` appeared 1x in dist/index.js and 1x in dist/core.js (the positive control); after the rebuild 0x, and the read sites call `(0, import_core.isEmptyForRequired)(def, value)`. Bytes change, so objectql gets a patch changeset.",
      "gates": "Local, at be605e655: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) -> 98 commands, re-derived at the final head (list unchanged from d4e51629a). Every command ran with its exit captured before any pipe (`bash -c CMD > log 2>&1; e=$?`): 98 of 98 exit 0. `dispatch-gates --repo objectstack-ai/objectstack --ran ran.txt` with the `:: exit N` tails -> 'Run reconciliation — 98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN', 'a DERIVED zero — all 98 recorded an exit code and none of them is 3'. On the first pass at d4e51629a, three exited 3/2 PREREQUISITE NOT MET and were rerun after meeting it, not counted: check-engine-split-ratio (shallow clone; `git fetch --shallow-since=2026-07-12 origin main`, then ratio 98.7% exit 0), spec check:skill-examples (built the @objectstack/client-react closure; '263 prose examples type-check' exit 0), check:dual-build-cjs-loads (missing dists; green after that build: 'this run 111/69/723/1'). One real finding on the first pass: check:query-options-erasure 'test surface grew 236 -> 241', from this PR's own `as any` on the pins' engine reads. Fixed in be605e655 ('ratchet holds ... none new', exit 0). Lint blind spot: `pnpm exec eslint --no-inline-config` on the six changed .ts files -> exit 0, and again on the test file after be605e655 -> exit 0. `node scripts/check-nul-bytes.mjs` -> OK, plus the control-byte grep over every changed file -> no match. Typecheck: `pnpm --filter @objectstack/core typecheck`, `... objectql typecheck`, `... rest typecheck` -> each VERDICT command-exit 0 (rest's at be605e655); `tsc -p tsconfig.test.json --listFilesOnly` shows both new test files in their package's test program. check-platform-checklist --changed-from not run: no exported symbol was renamed or deleted (only module-private functions moved; two exports added). CI: PR #24161 check-runs read once at report time: 24 total, 22 in_progress, 1 success, 1 skipped (Governed Surface Queue Guard in_progress). Not waited on, per the dispatch.",
      "mcp_calls": "0",
      "api_writes": "4 across both rounds, all through the fleet-write relay as objectstack-fleet[bot] (git pushes are not REST writes). Round 1: POST /repos/objectstack-ai/objectstack/issues/24131/comments (os-dev-report 6230265113). This round: one pr_create stroke = POST /repos/objectstack-ai/objectstack/pulls (draft, #24161) plus POST /repos/objectstack-ai/objectstack/issues/24161/assignees (os-sales); and POST /repos/objectstack-ai/objectstack/issues/24131/comments (this report). No label-write: the dispatch names no label for the dev, `needs:contract-review` is the dispatching seat's at ACCEPT (contract-review.md), and skip-changeset does not apply.",
      "line_budget": "531 changed lines (+507 / -24) over 10 files, against the 3000 human-merge threshold: under. No skills/** path, so no line ratchet applies.",
      "files_changed": [
        "packages/core/src/utils/required-emptiness.ts (new)",
        "packages/core/src/utils/required-emptiness.test.ts (new)",
        "packages/core/src/index.ts",
        "packages/objectql/src/validation/record-validator.ts",
        "packages/rest/src/form-routes.ts",
        "packages/rest/src/public-form-required.test.ts (new)",
        "content/docs/ui/public-data-collection.mdx (one sentence)",
        ".changeset/24131-core-required-emptiness.md (@objectstack/core minor)",
        ".changeset/24131-objectql-required-emptiness-import.md (@objectstack/objectql patch)",
        ".changeset/24131-rest-public-form-required.md (@objectstack/rest patch)"
      ],
      "deviations": [
        "Judged set narrower than 'every form-required entry'. Hidden entries, entries gated by visibleWhen (on the entry or its section) and server-managed anchors are not judged. The spec's own visibleWhen describe says nothing server-side evaluates it, metadata-core's form-predicate-root-policy documents the real `required: true, visibleWhen` authoring pattern, and an unconditional judgment would refuse submissions whose visitor never saw the field. The boundary is pinned (201) and stated in the PR body, the rest changeset and the docs sentence.",
        "Docs: the dispatch says at most one sentence, and only if the page reads otherwise once the fix lands. The page did not contradict the fix; it was silent. The card's done-condition asks that 'the docs say which', so the one sentence was added to §2. Flagged here rather than chosen silently.",
        "All three changesets carry the claim's line verbatim, `Clause-②: yes` (see open_questions on the arm).",
        "origin/main moved 6dede7a51 -> c40506aac during the run (4 commits, none in core/objectql/rest/types; dispatch-gates reports none of them touched what the derivation reads). The branch was not merged again; CI tests the merge ref.",
        "History deepened (`git fetch --shallow-since=2026-07-12 origin main`) to meet check-engine-split-ratio's prerequisite. The shallow file and objects are shared across worktrees; deepening is additive.",
        "Built the @objectstack/client-react closure (no diff there) to meet check:skill-examples' prerequisite.",
        "The first commit message carried a card relation (`Part of #24131.`). The pre-push hook refused it; it was amended while unpublished, so no force-push was needed.",
        "While locating the gate runner's PID I ran `pgrep -f` once as a read (output discarded). No process was signalled; the wait used the PID read from `ps` (9951), which this session started.",
        "Scratch probe from round 1 not reintroduced; its composition became the committed pin file."
      ],
      "open_questions": [
        {
          "question": "Should the PR body's and changesets' declaration be `Clause-②: yes (narrowing)` rather than the claim's bare `Clause-②: yes`? scripts/pm/clause2-line.mjs's closed set reads `yes (narrowing)` as 'a diff that widens one surface and narrows another. Both facts are true and both are read.' That is this PR: the core export widens, the submit door narrows.",
          "options": [
            "A — keep `Clause-②: yes`, as the claim declares. check:changeset-no-major is satisfied (core is minor), and no ADR-0087 disposition marker is demanded. The narrowing face is stated in prose (PR body and rest changeset) but is not machine-read as breaking.",
            "B — re-declare `Clause-②: yes (narrowing)` on the claim; the seat or this lane then edits the PR body line and the three changeset lines. check:adr-0087-registration will then read the arm and want exactly one ADR-0087 disposition marker in the rest changeset. One small follow-up commit plus a body edit by the seat."
          ],
          "recommendation": "B, because the narrowing is real (a 201 becomes a 400 for a caller omitting a form-required field) and the reader's closed set has a spelling built for exactly this two-faced shape; A leaves the breaking face visible only to a human reading prose. Not changed unilaterally: the claim's line was copied verbatim as the protocol requires."
        }
      ],
      "out_of_scope_findings": [
        "carrier: none (承接者:无) · packages/spec/liveness/view.json row props.form.children.sections: blanket `live` verdict over the FormField subtree, `required` included, no per-key row; its server evidence names the submit route only for allowedFields, and the route now also reads `required` · in the PR's Acceptance notes for the spec seat, not filed (spec hold-off)",
        "carrier: none (承接者:无) · packages/objectql/src/validation/rule-validator.ts:2864 keeps a byte-identical private isMissing for the requiredWhen arm (dispatch hold-off: objectql's other validation arms) · in Acceptance notes, not filed",
        "carrier: none (承接者:无) · packages/services/service-automation/src/screen-input-contract.ts:56 isPresent, a further private copy without the [] arm (claim hold-off) · in Acceptance notes, not filed",
        "carrier: none (承接者:无) · boundary new with this change: a form entry declaring a lookup / master_detail / user field `required: true` makes the rendered page unsubmittable, since the resolve route strips those types from the anonymous rendering and the submit now refuses them. No producer in the tree declares one and no public door reading was taken, so class (c) reach is unmet · in Acceptance notes, not filed · dedupe words: public form required lookup, anonymous form stripped field required, form-required unsatisfiable entry"
      ]
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24131,
      "status": "done",
      "branch": "claude/issue-24131-public-form-required",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24161",
      "session": "session_01BVm1pAXVXQ3PjC42j1kPFf — subagent run under the PM session (parent's id); patch round 1 on claim 6230321666, same branch",
      "premise_still_valid": true,
      "summary": "Patch round 1 changed declarations and semver only, in one commit (cf5965828) on top of be605e655 that touches only the three changesets. core: `Clause-②: yes (widening)`, `minor`, not read as breaking. objectql: `Clause-②: no` for the pure refactor, `patch`, not read as breaking. rest: `Clause-②: no (narrowing)`, summary `fix(rest)!:`, a visible `**BREAKING —` bullet, and exactly one ADR-0087 disposition in the ADR's comment spelling, category no-migration-prescription, stays `patch`. Both gates were run locally against the seat-edited PR body. check-adr-0087-registration finds exactly one declared-breaking changeset, the rest one, with its disposition. check-changeset-no-major's level axis reads the body's `Clause-②: yes (narrowing)` and is green, with core's minor accounting for it. The PR body line needs no change; title and body were not touched.",
      "tests": "None owed: no code changed this round. The diff from be605e655 is the three .changeset/24131-*.md files only (`git diff be605e655 cf5965828 --stat`: 3 files). No ablation re-run, and no gate asked for one.",
      "gates": "Head cf5965828, merge base 6dede7a51, run after `pnpm install --frozen-lockfile` in the recreated worktree. The PR event (`--event`) is built from the seat-edited body as read via REST: title `fix(rest, core)!: …`, line `Clause-②: yes (narrowing)`. (1) `node scripts/check-adr-0087-registration.mjs --base origin/main` -> exit 0: '✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/24131-rest-public-form-required.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)'. core (`yes (widening)`) and objectql (`no`) are not read as breaking. The first attempt exited 1 with 'no parser-rot witness: … `tsx` is not installed' — a prerequisite, not a verdict — and was rerun after pnpm install. (2) `node scripts/check-changeset-no-major.mjs --base origin/main --event event.json` -> exit 0. Verdict lines: '✓ This diff introduces no `major` bump.'; '✓ LEVEL AXIS: this PR declares clause-② `yes (narrowing)`, and it grades a package whose published source … it moves at `minor` or above — the declared narrowing is accounted for: @objectstack/core: minor', with objectql patch and rest patch 'NOT refused'; '· direction arm: `narrowing` — a BREAKING change; during the launch window it ships `minor`'; '✓ Protocol lockstep: PROTOCOL_VERSION major 18, @objectstack/spec@18.0.0-next.2'. (3) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` over the three changed changeset paths -> 20 commands, all exit 0, captured before any pipe. Among them check-empty-changeset ('No empty-frontmatter changeset introduced by this diff (3 declaring changeset(s) added)'), the check:changeset-gate-self-tests family, check:pm-changeset-deadline-census, check:published-files and check:nul-bytes. The no-major command in that list ran without an event and printed 'LEVEL AXIS: NOT APPLICABLE … no pull_request', so the event run above is the axis reading. Plus `node scripts/check-changeset-fixed.mjs` -> exit 0, because its roster sits under .changeset/. `--ran` on the same paths, with `:: exit N` tails: 'Run reconciliation — 20 derived, 20 run, 0 NOT-MEASURED, 0 UNRUN' / 'a DERIVED zero'. CI: the previous head be605e655 shows `TypeScript Type Check` = failure. Its annotations say only that lanes `typecheck-workspace` and `typecheck-consumers` 'concluded `cancelled` -- expected `success`', and the cancellation came from this round's push superseding them; no type error. On cf5965828 at report time: 39 check-runs, 25 success, 5 skipped, 9 in_progress. Not waited on.",
      "mcp_calls": "0",
      "api_writes": "5 across all rounds, all through the fleet-write relay. This round adds 1: POST /repos/objectstack-ai/objectstack/issues/24131/comments (this report). Earlier: round 1 comment 6230265113; round 2 pr_create (POST /pulls + POST /issues/24161/assignees) and comment 6231149249. The PR title and body were not written this round (the seat edited them). No label-write. git push of cf5965828 is not a REST write.",
      "line_budget": "This round: 3 files, the changesets only, 7 insertions / 5 deletions (`git diff --numstat be605e655 cf5965828`: 1/1, 1/1, 5/3). The PR stays under the 3000-line human-merge threshold.",
      "files_changed": [
        ".changeset/24131-core-required-emptiness.md (`Clause-②: yes (widening)`, minor)",
        ".changeset/24131-objectql-required-emptiness-import.md (`Clause-②: no`, patch)",
        ".changeset/24131-rest-public-form-required.md (`fix(rest)!:`, `Clause-②: no (narrowing)`, BREAKING bullet, one ADR-0087 disposition no-migration-prescription, patch)"
      ],
      "semver_decision": "rest stays `patch`. Both gates agree. The no-major gate is green with rest graded patch, because the PR-scoped narrowing is accounted for by core's minor ('these packages … are NOT refused'). The pre-release line agrees too: `.changeset/pre.json` is `mode: pre, tag: next`, and the fixed group versions in lockstep, so the release is 18.0.0-next.N whatever rest's own line says. And pr-automation.yml's WHICH LEVEL ruling says that during the launch window 'breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the level'; the rest changeset now carries both. The gate's 'it ships `minor`' line describes the PR's release; it is not a refusal of rest's patch.",
      "disposition_decision": "no-migration-prescription is the honest category, and the gate admits it. The narrowing is a runtime REST door's accept set. No stored metadata changes shape and no ledger entry has anything for `objectstack migrate meta` to do. The body carries no rewrite prescription: no arrow, no FROM/TO label, no table. The one optional author-edit clause the changeset had ('or remove `required: true` … if the field is optional') was dropped, so the 'nothing must be rewritten' in the marker is literally true. The other categories do not fit. unpublished: rest is published. registered / already-registered: no ledger id. runtime-interface-only, type-surface-only and runtime-export-retired: nothing exported is retired or narrowed in type.",
      "deviations": [
        "Added a visible `**BREAKING — a narrowing, and who it affects.**` bullet and the `!` in the rest changeset's summary, beyond the arm the dispatch named. The WHICH LEVEL ruling names the BREAKING banner as the launch-window carrier, and the ADR-0087 marker is invisible in a published CHANGELOG. The gate reads all three signals ('BREAKING+bang+clause-②-narrowing') and still asks for exactly one disposition, which the file carries.",
        "The rest changeset's Clause line is `no (narrowing)`, not `yes (narrowing)`. Per package, rest widens nothing, so the value `no` is the truthful answer to 'does this widen'. Both spellings carry the arm the ADR-0087 gate reads, and the no-major gate reads only the PR body.",
        "The PR body's Acceptance-notes bullet 'The `Clause-②` spelling' still describes the pre-patch state ('This body copies the claim's line verbatim (`yes`) … If the seat re-declares …'). It is now stale. Not edited, per the dispatch; the seat may drop it or replace it with: 'The PR body reads `Clause-②: yes (narrowing)`; the rest changeset carries `no (narrowing)` and the ADR-0087 disposition `not-required (no-migration-prescription)`.'",
        "First commit of the round (88c4801d3) was amended before its first push to add the BREAKING bullet; the pushed head is cf5965828. No force-push.",
        "Worktree recreated from origin at be605e655 (main not merged: no conflict, and the only red on the previous head was the cancellation above, not a main-caused failure)."
      ],
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: none (承接者:无) · unchanged from round 2: the view.json liveness note, rule-validator.ts:2864's private isMissing, screen-input-contract.ts's isPresent, and the lookup-type required boundary all stay in the PR's Acceptance notes, not filed"
      ]
    }

    Generated by Claude Code

  7. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24161 comment 6231417228 · head cf5965828 · contract review of record 6231404578 (CONTRACT_REVIEW_TIER, PASS) · domain:cli seat 1 · session session_01BVm1pAXVXQ3PjC42j1kPFf · 2026-10-17T07:52Z

  8. objectstack-fleet commented on Oct 17, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #24161 · domain:cli seat 1 · session session_01BVm1pAXVXQ3PjC42j1kPFf · 2026-10-17T08:30Z

    Reading Result
    origin/main 2ac061652 "fix(rest, core)!: the anonymous public-form submit enforces the form's own required; the emptiness rule moves to @objectstack/core (#24161)"; git rev-list --parents -n1 gives 1 parent, a squash
    Merge queue git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-24161-*' gives 0 refs
    PR object merged: true at 2026-10-17T08:29:12Z
    Lane reconciliation the open domain:cli set lost #24131 only, so no other card was closed by a keyword

    State: closed completed by Fixes #24131; pm:dispatched stripped in this act. The contract review of record was 6231404578 (CONTRACT_REVIEW_TIER, PASS) and the ACCEPT 6231417228. The liveness-row evidence pointer for the spec seat is 6231428658 on #6017.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:clipriority:p1High: required for production / M2target:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions