Skip to content

fix(metadata-protocol,metadata-core,rest): saving or publishing a public form a walled posture cannot take intake for says why - #21608

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21476-publish-intake-advisory
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21476-publish-intake-advisory

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21476
Clause-②: yes (widening)

This is part 2 of 2 of #21476, the publish half. Part 1 (PR #21580, a7ab047cf) delivered the anonymous doors and the administrator's read. This PR delivers the seat's answer A (ACCEPT 5968878000) to part 1's open question:

  • a gate-local warning advisory in runtime-authoring-gate.ts;
  • the predicate moved into @objectstack/metadata-core;
  • the gate fed the posture in force from protocol.ts.

With this, every surface triage's ruling 5962758813 names is delivered: both doors, the administrator's read, and the administrator's publish.

What

On a walled posture in force (group or isolated), saving a view (PUT /meta/view/:name) or publishing its draft (POST /meta/view/:name/publish, and the package batch publish) can carry an open public form whose object is walled by an organization column. That write now answers success with one warning advisory per such form under advisories:

  • rule public-form-intake-unavailable;
  • located at the form's sharing, under the write's root (views[0].formViews.contact.sharing, views[0].config.sharing or views[0].form.sharing);
  • message is the administrator's read's reason, byte for byte;
  • hint is the remedy: declare tenancy: { enabled: false } if the rows belong to no organization.

It never blocks and never 422s. It is omitted-when-empty as before, and a draft save is not judged (#4463 D1).

  • One predicate, moved. anonymousFormIntakeUnavailability(object, posture, readObjectSchema) now lives in @objectstack/metadata-core (anonymous-form-intake.ts). Next to it are:

    • its posture reader anonymousFormIntakePosture(tenancy);
    • the reason, anonymousFormIntakeUnavailableMessage, built from anonymousFormIntakeUnavailableRemedy;
    • the location, anonymousFormSharingPath;
    • the target object, anonymousFormObjectName;
    • the type AnonymousFormIntakeUnavailable.

    Three readers call those exports: both anonymous doors, the admin read (rest-server.ts), and the gate rule. No copy is left in rest. The reason text is the same bytes as part 1's, proven by evaluating part 1's function from $BASE against the export over 8 inputs: byte-identical.

  • The gate rule is findPublicFormIntakeGaps, beside findPlatformScheduleOrgGaps. It is pure, and it reads only what the gate already holds:

    • the object universe assertRuntimeAuthoringRules already gathers (registry plus stored rows), folded with this batch's pending drafts, now computed once and shared with the shared rules;
    • one new pure input, tenancyPostureInForce.

    It adds no network or engine call.

  • The posture input is tenancyPostureInForce() in protocol.ts. It reads anonymousFormIntakePosture(this.getServicesRegistry().get('tenancy')), the same service and the same reader the doors use, and the same channel anonymousFormIntakeOrgScopeRefusal already reads tenancy through.

Two deviations from the dispatch's mechanism hypotheses, each measured

  1. The predicate judges the object's EFFECTIVE schema. It now applies metadata-core's applyInjectedSystemColumns before resolving the wall column. The doors read served object documents, which already carry the injected organization_id, so for them this is the same reference and their answers are unchanged. The rest suite is 4883 / 4883 before and after, the same count as part 1.

    The gate's universe is different. Its stored-row winners and a batch's pending drafts are raw bodies, because foldStoredCollection does not apply the read exits' governServedItem. Judged raw, a Studio-authored object reads as unwalled, and the advisory would disagree with the doors.

  2. The predicate is synchronous for a synchronous reader. The gate is pure and synchronous. The doors need the object read to stay lazy: part 1's pin asserts that the single posture reads no object. So the export has two overloads:

    • a synchronous reader gets a synchronous answer;
    • a reader returning a promise gets a promise, or null without reading when no wall is in force.

    The doors' call sites are unchanged.

orgWallEnforced() is NOT aligned (Zone 2 #3: measured, then left as is)

The advisory reads the posture IN FORCE. The #6285 schedule refusal keeps reading the REQUESTED posture through orgWallEnforced().

I measured the alternative with a one-off mutation: orgWallEnforced() reading the in-force posture, its throw arm kept. My prediction was that every #6285 refusal row driven through saveMetaItem with no tenancy service would turn red. Observed: 6 red / 28 passed, across protocol.platform-schedule-org-gate.test.ts and protocol.bracketed-refusal-opener-absence.test.ts:

  • refuses the publish …;
  • … under the group posture;
  • … refuses the publish that promotes it;
  • OS_ALLOW_UNLINTED … loud log;
  • [#6710] DOES gate an unscoped kernel;
  • survives a deployment whose OS_TENANCY_POSTURE is unparseable ….

So aligning would narrow a refusal that the docblock and ADR-0105 defend (the unparseable-posture row). It would also contradict the #6155 Q3=A ruling, which names postureEnforcesWall(resolveTenancyPosture()) as that input verbatim. Per the dispatch, it stays. The split is documented on both inputs, and it is reported as a finding below. The mutation was restored, proven by blob == HEAD and an empty git diff HEAD.

Pins

  • packages/metadata-core/src/anonymous-form-intake.test.ts, +15 cases (13 → 28):
    • both walled postures;
    • the effective schema;
    • a declared tenancy.tenantField;
    • controls: tenancy-disabled, absent object, no wall column;
    • single and no tenancy service, with zero object reads;
    • the asynchronous reader;
    • posture-in-force reading (degraded reads single, legacy multi);
    • sharing path across all three form shapes;
    • object name;
    • message ending with the remedy.
  • packages/metadata-protocol/src/runtime-authoring-gate.public-form-intake.test.ts (new, pure), 12 cases:
    • per walled posture, exactly one advisory, compared with toEqual against the metadata-core reason and remedy;
    • each form shape's path;
    • a pending raw object in the batch;
    • controls: tenancy-disabled, single, no posture, a withdrawn form, a draft, a non-view write;
    • orgWallEnforced: true with single in force raises nothing;
    • a refused view write (422 INVALID_METADATA) discloses the rule in rulesRun.
  • packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, +8 end-to-end rows through saveMetaItem and publishMetaItem, with a real tenancy service in the services table:
    • isolated and group × PUT and publish: success, exactly one advisory, the reason equal to anonymousFormIntakeUnavailableMessage, and the row landed active;
    • controls on PUT and publish: tenancy-disabled object, single, no tenancy service, and a degraded deployment (OS_TENANCY_POSTURE=isolated, service in force single), where the doors serve the form and nothing is raised.
  • Part 1's packages/rest/src/public-form-intake-availability.test.ts is unchanged and green (16/16). It now exercises the moved export through dist/.
  • Real-boot measurement (a one-off file, not committed): bootStack(showcaseStack, { multiTenant: 'posture-only' }), posture isolated.
    • The admin read warning is config.sharing with the reason.
    • PUT /meta/view/showcase_inquiry.contact answered 200 with exactly one advisory: path: "views[0].config.sharing" and message identical (toBe) to the admin read's warning.
    • PUT ?mode=draft answered 200 with no advisories.
    • POST …/publish answered 200 with the same advisory.

Ablations, direction predicted before each run

Ablation Predicted Observed
A: the gate rule's findings removed from the verdict (runtime-authoring-gate.ts, src) only the advisory rows: 8 red (4 pure, 4 end-to-end) full metadata-protocol suite 8 failed / 3204 passed, exactly those 8
B: the predicate answers "available" everywhere (metadata-core, rebuilt) 20 red: metadata-core 5, the rest door and admin-read rows 7, advisory rows 8; every control green 5 + 7 + 8 = 20 red, every control green

Both mutations went through scripts/ablation-replace.mjs in WRAP mode: the anchor hit 1 → 0, and the restore was proven by blob == HEAD and an empty git diff HEAD.

B is dist-mediated, so it used a type-valid mutation carrying a string-literal marker (part 1 measured that a DTS refusal leaves the mutated JS in dist/):

  • ablation-dist-preflight found the marker in 2 built files before the run.
  • After restore and rebuild, --absent reported the marker absent from all 12 built files and the tree clean against HEAD.
  • Positive control: the restored guard is present in dist/index.js and dist/index.cjs.

Tests and gates

The code is final at dc0a93d4c4. 0687a7f17e adds only docs and the changeset (git diff dc0a93d4c4 0687a7f17e touches no packages/ path).

  • metadata-core test: 17 files, 326 passed.
  • metadata-protocol full suite: 208 files, 3212 passed, 19 skipped (at dc0a93d4c4).
  • rest test (--project local): 258 files, 4883 passed, 326 skipped. test:repo: 5 files, 177 passed.
  • typecheck: metadata-core, metadata-protocol and rest all clean. rest's includes check:test-typecheck.
  • Five public-form dogfood files (walled intake, walled withdrawal, showcase withdrawal, showcase public form, read-back masking): 5 files / 20 passed.
  • dispatch-gates --repo objectstack-ai/objectstack --commands at 0687a7f17e derived 95 commands. All 95 exit 0.
    • Two first answered exit 3 PREREQUISITE NOT MET: check:skill-examples (client-react unbuilt) and check:dual-build-cjs-loads (8 packages unbuilt). Both were re-run green after building those packages, so they are measured, not skipped.
    • --ran: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN.
  • eslint, narrowed: eslint --no-inline-config --format json on the 7 changed .ts files gave 7 files, 0 errors, 0 warnings, none ignored. eslint.config.mjs never enables type-aware linting (no parserOptions.project), so the narrowing cannot move an untouched file's verdict. Full pnpm lint is CI's.
  • main moved 4 commits past $BASE (6c5697dffb). The only overlap with these packages is one new metadata-protocol test file (the spec-validation 422 face inventory), which does not touch the authoring gate. The branch is not merged; CI runs the merge ref.

Docs

  • content/docs/deployment/validating-metadata.mdx:
    • adds the runtime-only row "Public-form anonymous intake on this deployment's tenancy posture — advisory only" (✓ᵛ);
    • rewrites the sentence that called the platform-schedule row "the one deliberate exception". There are now two deployment-fact rows.
  • content/docs/ui/forms.mdx: the "wires the anonymous REST endpoints automatically" rule list gains the walled-posture rule. The form is not offered, the admin read and the save/publish response say why, and the remedy is given.
  • skills/** is governed and not edited. Two published skill sentences are already false, made so by the sharing.enabled rule and by part 1, not by this PR:
    • skills/objectstack-api/SKILL.md "Any FormView declared with sharing.allowAnonymous: true and a publicLink slug is auto-mounted";
    • skills/objectstack-ui/SKILL.md's "Public / anonymous form" row.

Acceptance notes


Generated by Claude Code

claude added 3 commits October 3, 2026 15:25
…e predicate; gate-local advisory

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…e save/publish intake advisory

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 3 package(s): @objectstack/metadata-core, @objectstack/metadata-protocol, @objectstack/rest, touching 21 documentable anchor(s).

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx (via tenantField (symbol, a field of interface AnonymousFormIntakeUnavailable))
  • content/docs/deployment/tenancy-modes.mdx (via TenancyPosture (symbol, a top-level type))
  • content/docs/protocol/objectql/schema.mdx (via tenantField (symbol, a field of interface AnonymousFormIntakeUnavailable))

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

  • content/docs/releases/implementation-status.mdx (via tenantField (symbol, a field of interface AnonymousFormIntakeUnavailable))
  • content/docs/releases/v15.mdx (via tenantField (symbol, a field of interface AnonymousFormIntakeUnavailable))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via tenantField (symbol, a field of interface AnonymousFormIntakeUnavailable))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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 — 25 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 9a4182a752fd53b24a14bbcd2e1b4270e1174e7a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9a4182a752fd53b24a14bbcd2e1b4270e1174e7a

⚠️ 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 9a4182a752fd53b24a14bbcd2e1b4270e1174e7a → 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 17:01
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 17:01
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 83b3d32 Oct 3, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21476-publish-intake-advisory branch October 3, 2026 17:40
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

2 participants