Skip to content

fix(rest): a public form that cannot take intake on a walled posture is not offered; the admin read says why - #21580

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21476-intake-availability-one-predicate
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21476-intake-availability-one-predicate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #21476
Clause-②: no

This delivers the doors half and the administrator's read half of the triage ruling (5962758813). The publish half is left open on purpose: its contract-faithful channel sits behind packages/metadata-protocol/src/protocol.ts, which open PRs hold. The call sites are under "Not in this PR". The keyword is Part of, so the card stays open after this merges, while that half waits for a decision.

What

On a walled tenancy posture, a public form bound to an object walled by an organization column is no longer offered to anonymous visitors.

An anonymous submission carries no organization. On a walled posture the engine refuses an insert without one into such an object (resolveSystemInsertOrganization). So the form was served (GET /forms/contact-us 200), and then every submit answered 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED. This was reproduced on origin/main (6dd99b82c3) with bootStack(showcaseStack, { multiTenant: 'posture-only' }).

  • One predicate. anonymousFormIntakeUnavailability in packages/rest/src/rest-server.ts returns null when the form can take intake, otherwise why it cannot. It reads two facts and restates neither:
    • the tenancy service's in-force posture. This is the value SecurityPlugin hands the engine (setTenancyPostureProvider), so a degraded walled request reads single there, and the engine derives the install's organization.
    • the wall column, from @objectstack/metadata-core's existing resolveRecordWallOrganizationField, read over the served object schema (which carries the injected organization_id).
      The object is read only once a wall is in force, so single-posture deployments pay nothing new.
  • Both doors read it in one place. It is called inside resolveFormBySlug, the one resolution both GET /forms/:slug and POST /forms/:slug/submit already call. An unavailable form resolves to null, which is exactly the withdrawn form's 404 FORM_NOT_FOUND, byte for byte. Anonymous callers learn nothing about the tenancy, and there is no second check per door.
  • The administrator's read names why. GET /meta/view/:name puts one warning in item._diagnostics.warnings per unavailable open form. It sits on both arms (cached and uncached), is located at the form's sharing (config.sharing, formViews.KEY.sharing or form.sharing), and names the slug, the object, the wall column, the posture and the remedy (tenancy: { enabled: false } when the rows belong to no organization). The reason depends on facts the protocol's validator never hashes, so on the cached arm a view's If-None-Match is compared in REST against an ETag with the reason folded in (the ADR-0106 D3 shape). With no reason, the ETag and the 304 are byte-identical to before (pinned).

Placement: the predicate lives in rest, not metadata-core

The dispatch assumed metadata-core's anonymous-form-intake.ts. I measured that first: any new export there enlarges @objectstack/metadata-core's published index (export *), which is a Clause-②: yes (widening) change by #21566's own grading. The dispatch pins Clause-②: no.

Every reader of the predicate (two doors, one admin read) is in rest-server.ts. The predicate composes two rules that are already shared (postureEnforcesWall from spec, resolveRecordWallOrganizationField from metadata-core), so no rule gains a second spelling. It moves into metadata-core on the day a reader outside rest exists, for example the publish half's option A below.

Pins

  • packages/rest/src/public-form-intake-availability.test.ts (16 tests):
    • The doors are enumerated off the registered routes. The set under /forms/ must be exactly the two doors, and each door on each walled posture (isolated, group) is its own row: it answers the withdrawn form's answer byte for byte, and createData is never called.
    • Controls, all accepted: a tenancy: { enabled: false } object, the single posture (where the object is not even read), a degraded walled request, and no tenancy service.
    • Admin read on both arms: the located warning appears on the walled cases, and _diagnostics is untouched on the controls.
    • Validator: the bare protocol ETag revalidates into the reason, the folded ETag gives 304, and the control's ETag is unchanged and still gives 304.
  • packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts (real walled showcase boot):
    • both doors' raw answers equal the same form's answers once withdrawn env-wide on the same boot, and no showcase_inquiry row lands;
    • the admin read names the reason at config.sharing.
  • public-form-withdrawal-walled.dogfood.test.ts: the dogfood control. A tenancy-disabled object on the walled boot still accepts intake, and its admin read now also asserts that no intake warning is present.

Ablation (one-shot, not kept)

Each mutation was applied with scripts/ablation-replace.mjs against packages/rest/src/rest-server.ts, whose HEAD blob is 239ac2d6 at both 8a8839f9ab and the final 66ee5294a6. The direction was predicted before each run.

  • A, unit leg: the doors stop reading the predicate. return unavailable ? null : { ...match, organizationId }; became return { ...match, organizationId }; (anchor 1 to 0, blob 239ac2d6 to 43fdf709).
    • Predicted: exactly the 4 door rows red, everything else green.
    • Observed: 4 failed, 12 passed. The GET rows received 200 and the POST rows 201 where 404 was expected; the admin-read rows stayed green because they reach the predicate from their own call site.
    • Restore: blob equals HEAD and git diff HEAD is empty.
  • A, dist leg (dogfood).
    • The first attempt was a no-op. The same replacement left unavailable unused, and the DTS build refused it (noUnusedLocals) after the JS bundle had already been emitted, so no test ran. I rebuilt from the restored source, and ablation-dist-preflight confirmed the guard present in dist/index.js and dist/index.cjs with a clean tree.
    • Re-run with return unavailable && false ? null : { ...match, organizationId }; (blob 2b2e9c9f), rebuilt; the preflight found the plant marker in 2 built files.
    • Predicted: 1 red. Observed: 1 failed (expected 200 to be 404 on the doors row), 8 passed.
    • Restore: blob equals HEAD, rebuilt; the preflight found the guard in 2 files, the mutation absent from all 6, and a clean tree; the dogfood run went back to 9/9.
  • B: the predicate itself answers "available". return tenantField === null ? null : ... became return tenantField === null || true ? null : ... (blob 25ef3c8e).
    • Predicted: 7 red (4 door rows, plus the 3 walled admin-read rows: uncached, cached, validator) and 9 green.
    • Observed: 7 failed, 9 passed. Restore: blob equals HEAD and the tree is clean.

The pins asked for an ablation "on one door". There is no per-door read site to ablate: the predicate is read once, in the resolution both doors call. Ablation A removes that one read, and each door's own row goes red.

Tests (at 66ee5294a6, after merging origin/main 44072fc2b9)

  • @objectstack/rest full suite (--project local): 258 files passed; 4883 tests passed, 326 skipped. typecheck: tsc --noEmit clean, and check:test-typecheck OK.
  • @objectstack/metadata-core: 17 files / 311 passed; typecheck clean (it is unchanged).
  • @objectstack/dogfood typecheck clean. Public-form dogfood files (walled intake, walled withdrawal, showcase withdrawal, showcase public form, read-back masking): 5 files / 20 passed.
  • @objectstack/runtime /meta parity census, run because it reads rest-server.ts source and rest's dist/. The four files meta-list-projection-parity, meta-item-read-gate-parity, meta-read-org-scope-parity and meta-item-envelope gave 780 passed.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 68 commands; 67 exit 0. check:dual-build-cjs-loads answered PREREQUISITE NOT MET (it needs a full workspace build), so it is NOT MEASURED and left to CI. The --ran reconciliation accounted for 68 of 68: 67 run, 1 NOT MEASURED, 0 unrun.
  • eslint, narrowed to the 4 changed .ts files (--no-inline-config --format json): 4 files, 0 errors, 0 warnings, none ignored. The fifth changed path is a changeset .md. The narrowing is sound because eslint.config.mjs never enables type-aware linting (no parserOptions.project), so this diff cannot move any untouched file's verdict. The full pnpm lint is CI's.

Not in this PR: the publish half

The ruling asks the publish surfaces (PUT /meta/view/:name, POST /meta/view/:name/publish) to say why too. The only located, structured channel those responses carry is advisories, and both SaveMetaItemResponseSchema and PublishMetaItemResponseSchema declare the runtime authoring gate as its producer. The places that would have to change:

  • packages/metadata-protocol/src/runtime-authoring-gate.ts: a gate-local rule would sit beside findPlatformScheduleOrgGaps, around line 300.
  • packages/metadata-protocol/src/protocol.ts:5612: the gate is fed orgWallEnforced: this.orgWallEnforced().
  • protocol.ts:5938: orgWallEnforced() reads the requested posture (postureEnforcesWall(resolveTenancyPosture())), which disagrees with the doors' in-force reading on a degraded deployment.
  • protocol.ts:18673 and protocol.ts:19612: the attach sites.

protocol.ts is held by open PRs #21545 and #21512, so this PR stops there. The options and a recommendation are in the report on the card.

Acceptance notes

  • Boundary of the predicate. It reads declarations. The engine also passes a federated (external) object, a platform object its inventory has not admitted, and a row a beforeInsert hook stamped. A form bound to one of those that also carries a wall column is withheld here although the engine would accept it, which is the fail-closed direction. No shipped hook stamps organization_id (git grep over the CRM and showcase hooks: exit 1, with a beforeInsert control at exit 0).
  • New failure mode on walled postures. An object-metadata read failure now fails both doors closed (GET 500 FORM_RESOLVE_FAILED; submit through mapDataError). Single-posture deployments make no new read.
  • Cached arm. For every view read, If-None-Match is now compared in REST rather than in the protocol. Server work is unchanged, because getMetaItemCached already delegates to getMetaItem. Response bytes, the ETag and the 304 are identical when there is no reason.
  • Not stamped by this PR. The list read (GET /meta/view), /layers, and the runtime HTTP dispatcher's /meta item read. The dispatcher serves no /forms/* door, so a dispatcher-only composition has no intake that could be unavailable.
  • CRM. app-crm's lead form (/forms/contact-us) is the same class on a walled CRM deployment. Not booted here.
  • Console. Whether the console renders _diagnostics.warnings: NOT MEASURED (no objectui checkout in this container).

Generated by Claude Code

claude added 7 commits October 3, 2026 11:06
…doors and the admin read

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…form, and the admin read says why

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…the intake predicate's docs

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

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 22 documentable anchor(s).

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0bddffd55bdcffd23b7f39b2d009b614bcdc9b2a.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0bddffd55bdcffd23b7f39b2d009b614bcdc9b2a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 298c4edb2fdf6e062b6d9e1c4b484da5d7fb2b21 — the merge of head 66ee5294a6d0a5284f91185e17e57971a5114d9f into base 0bddffd55bdcffd23b7f39b2d009b614bcdc9b2a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 298c4edb2fdf6e062b6d9e1c4b484da5d7fb2b21 && git checkout 298c4edb2fdf6e062b6d9e1c4b484da5d7fb2b21
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0bddffd55bdcffd23b7f39b2d009b614bcdc9b2a 66ee5294a6d0a5284f91185e17e57971a5114d9f && git checkout -B drift-repro 0bddffd55bdcffd23b7f39b2d009b614bcdc9b2a && git merge --no-ff 66ee5294a6d0a5284f91185e17e57971a5114d9f

node scripts/docs-audit/affected-docs.mjs --json 0bddffd55bdcffd23b7f39b2d009b614bcdc9b2a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0bddffd55bdcffd23b7f39b2d009b614bcdc9b2a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 12:15
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 12:15
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit a7ab047 Oct 3, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21476-intake-availability-one-predicate branch October 3, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants