You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
Found by the repo:www.objectos.ai seat (#12224) while staging www.objectos.ai#272 on 2026-10-17. Measured on the published @objectstack/cli17.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/main570c7a2, 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.
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.
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/main747c8e9c4; line numbers move; stop on a breach and explain it in the report):
packages/rest/src/form-routes.ts: the anonymous submit route (POST /forms/:slug/submit), where it derives its allow-list from the form's sections[].fields. It reads the same entries' required and refuses before any write. Triage records that today it reads no form-field required (its only required hits are the slug checks at :211 and :416).
Pins: tests beside form-routes.ts under packages/rest/src/. One .changeset/24131-*.md.
content/docs/ui/public-data-collection.mdx: one sentence saying the submit route enforces the form's required, only if the page reads otherwise once the fix lands.
{
"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"
]
}
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-report6230265113) · 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/main6dede7a51; 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/coreminor (the new export), @objectstack/restpatch, 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/main747c8e9c4 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
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.
{
"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"
]
}
{
"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"
]
}
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
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.
Activity
objectstack-fleet commentedon Oct 17, 2026
Triage: first grade,
priority:p1·target:v18·domain:cli·area:api·bug·pm:queue· route (a) · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-17T03:59ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
origin/main462d02f00a,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-fieldrequired. Its onlyrequiredhits are the slug checks at:211and:416.required: true, and the rule is 「声明即强制」 and 「spec 声明 > 实现 > 文档面」. The submit route derives its allow-list from that same declaration, so it enforces the declaration'srequiredtoo, answering400with the field named, in the same envelope as the object-levelVALIDATION_FAILED. Route (b), "a rendering hint", would leave a declared constraint unenforced on the one door a bot uses.target:v18: it narrows what a public REST door accepts. Under protocol-first that is p1 before GA.400naming it, and with it answers201.Generated by Claude Code
objectstack-fleet commentedon Oct 17, 2026
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:v18cards, 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 declaresrequiredanswers400naming the field, and no row is stored · 2026-10-17T05:21ZSession:
session_01BVm1pAXVXQ3PjC42j1kPFfAccount:
os-sales(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24131-public-form-requiredWorktree:
objectstack-issue-24131Domain:
domain:cliSeat:
domain:cli#1(seat post #6024)File surface (read on
origin/main747c8e9c4; line numbers move; stop on a breach and explain it in the report):packages/rest/src/form-routes.ts: the anonymous submit route (POST /forms/:slug/submit), where it derives its allow-list from the form'ssections[].fields. It reads the same entries'requiredand refuses before any write. Triage records that today it reads no form-fieldrequired(its onlyrequiredhits are the slug checks at:211and:416).form-routes.tsunderpackages/rest/src/. One.changeset/24131-*.md.content/docs/ui/public-data-collection.mdx: one sentence saying the submit route enforces the form'srequired, only if the page reads otherwise once the fix lands.packages/specedit, including the form-field schema, its describe text and its liveness ledger row (thedomain:specseat's);GET /forms/:slug; the object-level required check in the engine (read and reused, not changed); the CRM example and the route docstring finding(forms): the CRM example and the submit route's docstring say theguest_portalprofile authorizes an anonymous form submit; measured on 17.7.0, it does not #24130 names (p3, its own card); the otherregister*Endpointsmodules ([maintainer] refactor(rest): splitpackages/rest/src/rest-server.ts(13,701 lines, 70 commits in 30 days) along itsregister*Endpointsseams, one move-only slice per PR,/metaafter #22877 stage 2 #23050's slices).Container & model:
M(one door, but a narrowing of a published accept set whose emptiness rule must match the object-level one),mode:subagent,model: TIER_DEFAULT(dispatch-gates --tier: no path-derived mandate)Clause-②: no
Basis for the Clause-② line: a NARROWING of a published REST door's accept set. A submit that omits a form-required field answered
201, and it answers400. Nothing refused becomes accepted. It carriesClause-②: no (narrowing)line-initial in the PR body and the changeset, and it owes oneCONTRACT_REVIEW_TIERreview of record before the queue (contract-review.md). No error code is minted: the refusal uses the envelope and code of the object-level400 VALIDATION_FAILED.Responsibility:
packages/rest's public form submit route (this lane) | the engine's object-level required check covers only fields the OBJECT requires; nothing on this door reads the form's ownrequired| an anonymous public-form submitter (a script or bot, without the rendered form), measured on@objectstack/cli17.7.0 withexamples/app-crm'sweb_to_leadform by therepo:www.objectos.aiseat (card body)Thread-read: 6229368836
Serial constraints cleared: the 7 open PRs read in this act; none edits
packages/rest/,view.zod.tsorpublic-data-collection.mdx. No openpm:dispatchedclaim declaresform-routes.ts; the only other lane card in flight is this seat's 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 (dispatcher-plugin.tsandplugin-sharing, in the merge queue).form-routes.tscame from [maintainer] refactor(rest): splitpackages/rest/src/rest-server.ts(13,701 lines, 70 commits in 30 days) along itsregister*Endpointsseams, one move-only slice per PR,/metaafter #22877 stage 2 #23050 slice 11 (PR refactor(rest): move registerFormEndpoints into form-routes.ts (#23050 slice 11) #24054,c40a0dd9c); [maintainer] refactor(rest): splitpackages/rest/src/rest-server.ts(13,701 lines, 70 commits in 30 days) along itsregister*Endpointsseams, one move-only slice per PR,/metaafter #22877 stage 2 #23050 is inpm:queueand has no slice in flight. finding(forms): the CRM example and the submit route's docstring say theguest_portalprofile authorizes an anonymous form submit; measured on 17.7.0, it does not #24130 (p3,pm:queue) is the same form's docs and example, not in flight, and held off above.objectstack-fleet commentedon Oct 17, 2026
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
objectstack-fleet commentedon Oct 17, 2026
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 toyes, on the seat's answer below to the dev'sneeds_decision(os-dev-report6230265113) · 2026-10-17T05:44ZSession:
session_01BVm1pAXVXQ3PjC42j1kPFfAccount:
os-sales(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24131-public-form-requiredWorktree:
objectstack-issue-24131Domain:
domain:cliSeat:
domain:cli#1(seat post #6024)Ruling-ref: 6227928430
Resume-from:
claude/issue-24131-public-form-required@747c8e9c48378bdf15b1ea6e406d535d815770bbFile surface (read on
origin/main6dede7a51; 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 isisEmptyForRequiredwith theisMissingit calls, moved from objectql with byte-identical semantics, typed on@objectstack/spec/data'sValueShapeFieldDef, and exported from core's.entry.packages/objectql/src/validation/record-validator.ts: its privateisMissing(about:304) andisEmptyForRequired(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 fromsections[].fields. It reads the same entries'required, judges them with core's predicate, and refuses before any write in the flat400 VALIDATION_FAILEDenvelope the object-level refusal answers on this route.@objectstack/coreminor(the new export),@objectstack/restpatch, and@objectstack/objectqlonly if its published bytes change.content/docs/ui/public-data-collection.mdx: at most one sentence, as before.packages/specedit (schema, describe text, liveness row);GET /forms/:slug; objectql's other validation arms;packages/services/service-automation/src/screen-input-contract.ts'sisPresent(noted by the dev, not this card); the CRM example and docstring finding(forms): the CRM example and the submit route's docstring say theguest_portalprofile authorizes an anonymous form submit; measured on 17.7.0, it does not #24130 names; the otherregister*Endpointsmodules.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 ruling6227928430declares 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 answered201, and it answers400. Nothing refused becomes accepted. No error code is minted. The PR owes oneCONTRACT_REVIEW_TIERreview of record over the whole diff except the prose faces before the queue, andneeds:contract-reviewon the PR until it is recorded (contract-review.md). It is not spec-lane work, because nopackages/specpath 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 intopackages/coreand the import swap inpackages/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 ownrequired| an anonymous public-form submitter (a script or bot), measured on 17.7.0 by therepo:www.objectos.aiseat and reproduced onorigin/main747c8e9c4through the real route by the dev (6230265113): absent,null,'', whitespace-only and[]all stored with201Thread-read: 6230265113
Serial constraints cleared: the 5 open PRs read in this act; none edits
packages/core/src/,packages/objectql/src/validation/orpackages/rest/src/form-routes.ts. No openpm:dispatchedclaim indomain:engineor 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@objectstack/runtime. Home it in@objectstack/core(a new export), keep five copies under a parity pin, or close #23982 was ruled A at6227928430(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),minoron@objectstack/core)", and "A door that intentionally differs keeps its difference as a named parameter of the one function, never as a copy".@objectstack/restlists@objectstack/objectqlas a devDependency only, andcheck: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/specforisMultiValueField.AGENTS.mdPrime Directive 2, "No business logic inpackages/spec… Runtime logic goes incore,runtime, orservices/*". 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'srequired) 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.code: "VALIDATION_FAILED"andfields[].fieldnaming the field (themapDataErrorarm). The pins assert that path, and cover whitespace-only and[]on a multi-value field, with a201control that stores the row.objectstack-fleet commentedon Oct 17, 2026
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
objectstack-fleet commentedon Oct 17, 2026
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
objectstack-fleet commentedon Oct 17, 2026
ACCEPT → PR #24161 comment 6231417228 · head
cf5965828· contract review of record6231404578(CONTRACT_REVIEW_TIER, PASS) ·domain:cliseat 1 · sessionsession_01BVm1pAXVXQ3PjC42j1kPFf· 2026-10-17T07:52Zobjectstack-fleet commentedon Oct 17, 2026
Landed: PR #24161 ·
domain:cliseat 1 · sessionsession_01BVm1pAXVXQ3PjC42j1kPFf· 2026-10-17T08:30Zorigin/main2ac061652"fix(rest, core)!: the anonymous public-form submit enforces the form's ownrequired; the emptiness rule moves to @objectstack/core (#24161)";git rev-list --parents -n1gives 1 parent, a squashgit ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-24161-*'gives 0 refsmerged: trueat 2026-10-17T08:29:12Zdomain:cliset lost #24131 only, so no other card was closed by a keywordState: closed
completedbyFixes #24131;pm:dispatchedstripped in this act. The contract review of record was6231404578(CONTRACT_REVIEW_TIER, PASS) and the ACCEPT6231417228. The liveness-row evidence pointer for the spec seat is6231428658on #6017.