Skip to content

fix(plugin-security,spec)!: a non-system caller that carries a principal and resolves no permission set gets the deny baseline at object admission and at the row scope - #21217

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-21079-zero-set-deny-baseline
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-21079-zero-set-deny-baseline

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21079
Clause-②: yes (narrowing)

Ruling E (5933054144, maintainer 「同意E」) as written: this card lands the deny baseline alone. The anonymous public-form picker is retired on its own card, #21180. Execution parameters are seat 1's release 5934091842, and the claim is 5934758335. The claim revision 5937041708 records the seat's route B and adds two surfaces: the two out-of-surface picker pins and the in-place delegator-arm fix. Patch round 1 implements both. ⚠️ Disclosure discipline applies (security family). This body states classes, positions, codes and statuses only. Every reading is private, in the dev's scratch space.

What this changes

For a non-system caller that carries a principal (a position, a named permission set or a user id) and resolves no permission set, an empty set list is now the ADR-0056 D2 deny baseline. The answer is the same at every layer, and no layer has a door-specific copy of it:

  • Object admission. The engine middleware's step 2 CRUD gate is no longer guarded on a resolved set. The evaluator answers the empty list as it answers any list, so the caller is refused with the PERMISSION_DENIED / 403 any ungranted caller gets. canReadObject, canExport and canWriteObject answer false for it.
  • Row scope. getReadFilter answers the deny sentinel for it, as its failure paths already did.
  • The one exception. isPrincipalLessContext is the one exception an empty list has. The middleware hands a principal-less context through, as before, and the three probes admit only that context. getReadFilter now reads the same predicate instead of an inline copy. ADR-0096 stages that context separately, and it is unchanged.
  • Contract. The ISecurityService docblocks in packages/spec/src/contracts/security-service.ts narrow to match: canReadObject's arm list and its Fails CLOSED paragraph, canExport's Fails CLOSED paragraph, getMetadataReadableFields's "why the two differ" paragraph, and a deny-baseline paragraph on getReadFilter. No key, method, parameter, return type or export changes.

Positions that stay as they are (phase 0's N3, re-derived on this tree):

A bounded in-place fix in the same class (declared here, as the four conditions require). Three probes skipped the D10 delegator CRUD-grant arm when the delegator resolved no set: canReadObject arm 6, canWriteObject arm 8, and the canExport intersection. The middleware asks that arm over the delegator's empty list and refuses. Measured privately before the change: on an embedder with no baseline, an agent acting on behalf of a delegator who resolves no set was refused by the middleware (PERMISSION_DENIED / 403) while all three probes answered true. The fix drops the length guard on those three arms, so they read the same evaluator answer. The capability arms' delegator guard is left as it was, because the grant arm dominates it and no pin can observe it. The four conditions:

  1. It is the same defect class (an empty set list grants by absence).
  2. The fix is mechanical, and its shape is pinned by the middleware's own expression.
  3. The file is this claim's.
  4. It needs no new verification surface, because the same suites cover it.

The claim revision 5937041708 adds these three arms to the claim's file surface.

Measured (private; classes and positions only)

Measured on base 0b12b9ea and after the change. The engine-level probe drives the registered middleware and the three probes, and asks getReadFilter, for each member:

caller class before after
guest envelope, deployment with no guest set admitted (find, insert); probes true; no row scope refused 403 PERMISSION_DENIED; probes false; deny sentinel
picker-shaped context, its named set not registered admitted; probes true; no row scope refused 403; probes false; deny sentinel
signed-in user, baseline disabled, no grant admitted; probes true; no row scope refused 403; probes false; deny sentinel
context naming only unresolvable sets, no user id admitted; probes true; no row scope refused 403; probes false; deny sentinel
agent for a delegator who resolves no set middleware refused; probes true middleware refused; probes false
principal-less context (boundary) admitted admitted (unchanged)
signed-in member with a set (control) granted object admitted, ungranted refused unchanged
picker-shaped context, its named set registered (control) admitted by the set unchanged

Door-level consequences (N4). These are measured on real bootStack boots, with plugin-security built at each leg and the build checked by ablation-dist-preflight:

  • Stock showcase doors are unchanged. 26 dogfood files are green on both legs: the 13 shared-showcase files plus 13 isolated showcase and security files (persona matrix, authz conformance, public form, public-form read-back masking, the declared showcase endpoints, the default profiles, and others). The other 3 files in the set are the moving ones.
  • The app-declared anonymous endpoint goes from 200 to 403 PERMISSION_DENIED on a deployment with no matching set. It still passes the authentication gate, so it answers 403, not 401.
  • The public-form picker goes from 200 to 403 PERMISSION_DENIED on a deployment without the set it names.
  • A signed-in user on a baseline-disabled embedder loses the everything-default. The record read and every query answer 403 PERMISSION_DENIED.
  • The public form submit is unaffected. Its grant admits ahead of object admission.
  • Fork clause: not triggered. No stock-boot door was found that serves this class. The measurement is the dogfood showcase harness on both legs. A real pnpm dev -- --fresh boot was NOT MEASURED this round; phase 0 measured 24 anonymous doors on one.

Pins

New: zero-set-deny-baseline.test.ts (18 cases). It covers each measured member (the guest envelope, a picker-shaped context, and a signed-in user on a baseline-disabled embedder). For each member it pins the premise (zero sets), that all seven engine operations are refused at object admission before they run (code and status), that the three probes answer false, and that the row scope is the deny sentinel. The controls are a set-holding member (admitted to its granted object, refused the other) and a registered named set. The principal-less context is pinned as the boundary. The second-principal case is pinned with a set-holding delegator as its control. No pin title states a request.

Moved: 21 plugin-security cases in 5 files and 4 dogfood cases in 2 files, the same set phase 0 named and re-derived on this tree. Under route B there are also 5 picker cases in 2 more files (see the next section). Each now asserts the new answer's substance:

  • zero-set-masking.test.ts (9) and zero-set-capability-fold.test.ts (9). The field projections still exclude masked and capability-gated fields. Every query, read and write is now refused at object admission (PERMISSION_DENIED / 403), and the read's row scope is the deny sentinel. In the fold suite, explain agrees: object_crud denies, allowed is false, and fls still reports the field hidden. canWriteObject still equals the middleware, field for field. The masked-echo case was refused 400 at the echo gate and is now refused 403 at admission, ahead of it. No refusal assertion was weakened.
  • can-write-object-admission.test.ts (1). The org-bound zero-set twin is now denied by arm 4. The pin adds the middleware's own refusal (code and status).
  • get-writable-fields.test.ts (1). The field answer is unchanged. Every write the case names is refused at admission (code and status).
  • predicate-related-read-tenant-scope.test.ts (1). The write door's refusal is unchanged. The preview is now refused at its write gate, because canWriteObject answers the public-form grant context false (the grant is not one of its arms). The pin asserts that probe answer directly and keeps the "identical whatever the value" and "related columns never read" assertions.
  • Dogfood declarative-endpoint-policy (2). The explicit authRequired: false endpoint passes authentication, and its object read is refused 403 PERMISSION_DENIED. The declared cache directive is not on that refusal. The success side of that directive stays pinned in the runtime units and the showcase file.
  • Dogfood zero-set-masking (2). The picker door and the baseline-disabled record door are refused at admission. The record door is checked on the read and on a query that names an unmasked field.

The picker-door case is shared with #21180. That card deletes the picker door and this dogfood case. Measured: an untouched picker case turns red under this change, so I re-pinned it to the deny baseline. I did not restructure it, and its fixture is untouched. The ruling gives this case to whichever card lands second. Under route B this PR is expected to land first, and #21180 then takes its own deletion of the case.

Route B: the two picker pins from #21062, re-pinned

PR #21136 (#21062) landed after phase 0's measurement. It added two picker pins, and both assume the picker serves a visitor with no session on a deployment with no matching set. Ruling E fixes no landing order between this card and #21180, which retires the picker. The seat ruled route B in claim revision 5937041708: this card re-pins both files and lands on its own clock. The reasons it gives:

Each fixture's intent is kept, and no case is deleted. #21180 deletes both files when it lands.

  • packages/rest/src/public-form-lookup-picker-queryable-key.test.ts, 4 cases. The no-guest-set class moves out of the shared loop into its own describe.
    • The picker whose first display field is masked: its listing and its search each answer 403 with code: 'PERMISSION_DENIED', no row, and no stored value in the body.
    • The masked-only picker answers the same code and status as the engine's own refusal, and the engine is never asked. The pin also asks the engine for a query keyed on a field the caller MAY query on, which only object admission refuses. Without that line the case stayed green with the deny baseline put back; that was the first ablation run.
    • The no-masked-field control is refused too, and still keys on its first display field.
    • The premise and the "never keys on the masked field" cases are unchanged. The guest-set class and the lifted-rule class keep their pins as they were.
  • packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts, 1 case. For a visitor with no session and for a signed-in caller, the listing and the search answer 403 PERMISSION_DENIED, with nothing served and no stored value in the body. The masked-only picker answers the same.

Ablations (one leg per negative pin)

Each leg used scripts/ablation-replace.mjs in wrap mode, and its own trap … EXIT INT TERM restored the file from HEAD on an absolute path. Each leg landed (anchor count 1 to 0, blob changed). The final state was blob a4a95715 equal to HEAD, with an empty git diff HEAD and a clean git status. Every leg ran on the plugin-security test files as they stand at 3e49b1af; this round does not change them:

leg (forbidden behaviour put back) red
step 2 guard permissionSets.length > 0 && restored 23 of 139 (all 3 deny-baseline middleware pins, plus the moved middleware pins)
canReadObject arm 2 answers true 3 (the probe pins)
canWriteObject arm 4 returns through the organization wall 9
canExport arm answers true 3
getReadFilter zero-set line deleted 6 (the deny-sentinel pins)
delegator grant arm guarded on length, in canReadObject, canWriteObject and canExport (one leg each) 1 each (the second-principal pin)

The first attempt at the canExport delegator leg was a no-op: the replacement contained the anchor, and the tool refused it with exit 1 and nothing run. It was re-run with a non-overlapping anchor, and that run is the one in the table.

The picker leg (patch round 1). scripts/ablation-replace.mjs put back the base's step 2 guard. That guard is the only arm the picker door reads; the door asks getQueryableFields (unchanged) and then the engine. The wrapped child rebuilt plugin-security, and the preflight found the injected guard in 2 built files. Results:

  • public-form-lookup-picker-queryable-key.test.ts: 4 of 19 red, exactly the 4 re-pinned cases.
  • public-picker-queryable-key.dogfood.test.ts: 1 of 1 red.

The restore was proven: blob a4a95715 equals HEAD, git diff HEAD is empty, and after the rebuild the --absent preflight read the marker absent from all 6 built files and the tree clean. Both files then ran green again: 19/19 and 1/1.

The dist-level leg put base security-plugin.ts back (blob 970439f6, marker 3 to 0 in src), rebuilt, and ran the dogfood set. The 4 re-pinned dogfood cases were red, and the stock showcase files stayed green. It then restored and rebuilt, and the preflight found the marker in 2 built files again.

Gates and tests (at 03ae8604, after origin/main 097ef802 was merged as 2e1564f5)

  • Gate list. dispatch-gates --commands derived 109 families on this head; the change set now has 14 paths. All 109 exit 0, and --ran reconciled them with recorded exit codes: 109 run, 0 NOT-MEASURED. check:dual-build-cjs-loads read the 8 packages it needs after they were built.
  • plugin-security. 156 files, 3345 passed, 23 skipped. typecheck exits 0.
  • rest. The full local project: 256 files, 4844 passed, 310 skipped. typecheck exits 0, and tsconfig.test.json lists the re-pinned file.
  • dogfood. typecheck exits 0. The dogfood set from round 1 (30 files) is 314 passed and 0 failed, and that now includes the picker file.
  • Spec. Unchanged since round 1, when the spec contract tests were 31/31 and check:generated reported all 15 artifacts up to date.
  • Lint, narrowed.
    • The population comes from eslint.config.mjs (files: **/*.{ts,…} minus NEVER_LINTED). All 12 changed .ts files are in it, and --format json counts 12 files, 0 errors and 0 warnings.
    • The config enables no type-aware linting and this diff touches no lint configuration, so it cannot move the verdict on an untouched file. The repo-wide pnpm lint is CI's.

Docs (patch round 1)

The drift check (5936999532) listed 8 hand-written pages. I read all 8, then grepped content/docs/** (outside releases/ and the generated references/) and skills/** for sentences that say a caller with a principal and no resolved set is admitted, has no row scope, or falls through to sharing alone, or that say what the anonymous picker answers. Three passages in content/docs/ui/forms.mdx were made false by this PR, and they are rewritten. Only sentences about what the door answers changed; the text about the door's existence is #21180's.

  • Section 2, the guest set. It said the guest set is only needed for the back-compat path, and that it should be INSERT-only. Now: that holds for the submit. A form with a public picker needs a guest set that grants read on the picker's target object, because without it the picker answers 403 PERMISSION_DENIED under the deny baseline. Any read granted there is readable by every anonymous visitor through the picker.
  • The picker's errors table gains a 403 PERMISSION_DENIED row: no guest set grants read on the target object, so the search context resolves no set and is refused at object admission. The declaration bounds what is searched; it never grants the read.
  • "Auth model" said the lookup's result set "is bounded by the picker declaration instead". Now: the declaration bounds what is matched and projected, the guest set authorizes the search, and without one the search is refused at object admission.

No other sentence was false. The other 7 drift-check pages either name the probes in the system-context bypass table (unchanged) or say nothing about a set-less caller. skills/objectstack-api describes authRequired: false as an authentication opt-out only, which is still true. Release-owned pages were read but not edited; none of the 4 the drift check names states the old zero-set admission.

Acceptance notes (observations, not filed)


Generated by Claude Code

claude added 7 commits October 1, 2026 15:52
…ine at object admission and row scope

A non-system caller that carries a principal and resolves no permission set
is now refused at object admission (the engine middleware's CRUD gate, and
canReadObject / canWriteObject / canExport) and answered the deny sentinel
as its row scope (getReadFilter). The principal-less context keeps its
hand-off. A delegator who resolves no set is refused by the three probes
as the middleware already refused it. The ISecurityService docblocks that
stated the zero-set answers are narrowed to match.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…pin the zero-set field suites

A new suite pins, per measured member (the guest envelope, a context naming
only an unregistered set, a signed-in user on a baseline-disabled embedder),
that every engine operation is refused at object admission before it runs,
that the three probes answer no, and that the row scope is the deny
sentinel; with a set-holding member, a registered named set and a
set-holding delegator as controls, the principal-less hand-off as the
boundary, and a zero-set delegator refused by the probes as by the
middleware.

The 21 cases that read the old zero-set admission now assert the refusal
(code and status), the deny sentinel, or the probe's no; every field-level
answer they pinned is kept.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…ing grant arms

The capability arms' delegator stand-down is dominated by the grant arm
that now asks the delegator's empty list, so it stays as it was; only the
grant arms of canReadObject and canWriteObject (and canExport's) carry the
deny baseline for the second principal.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…o the deny baseline

An explicit authRequired:false endpoint still passes authentication on the
fixture, and its object read is now refused at admission (403
PERMISSION_DENIED) on a deployment that grants anonymous callers nothing;
the declared cache directive stays off that refusal. The picker door and the
baseline-disabled record door refuse at admission, the record door for the
read and for a query naming an unmasked field alike.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
… BREAKING, with the ruling's migration text

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…ndOne with the engine's own predicate check

The engine-double contract gate counts the fake; its findOne now routes
through assertEngineFindOnePredicate as ObjectQL.findOne does, and the
unused find stub is gone.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/spec, touching 6 documentable anchor(s).

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

  • content/docs/kernel/contracts/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/sharing-rules.mdx (via getReadFilter (symbol, a method of class SecurityPlugin))
  • content/docs/permissions/system-context.mdx (via canExport (symbol, a method of class SecurityPlugin), canReadObject (symbol, a method of class SecurityPlugin), canWriteObject (symbol, a method of class SecurityPlugin), getReadFilter (symbol, a method of class SecurityPlugin))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v15.mdx (via getReadFilter (symbol, a method of class SecurityPlugin))
  • content/docs/releases/v17/17-0.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-5.mdx (via ISecurityService (symbol, a top-level interface), canReadObject (symbol, a method of class SecurityPlugin), canWriteObject (symbol, a method of class SecurityPlugin))

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
  • 1 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 — 140 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 097ef802700e109aa98a89c2991041516476b6f9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 097ef802700e109aa98a89c2991041516476b6f9

⚠️ 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 097ef802700e109aa98a89c2991041516476b6f9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 3 commits October 1, 2026 17:51
…the guest set to the deny baseline

On a deployment that registers no guest set the picker's context resolves
no permission set, so the engine refuses it at object admission: every
listing, search and masked-only picker answers 403 PERMISSION_DENIED and
serves nothing, for a visitor and a signed-in caller alike. The key the
picker composes is unchanged and still never names the masked field; the
guest-set and lifted-rule classes keep their pins.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
… object admission, not the field guard

Under the field guard alone the masked-only picker was refused with the
same code and status, so the re-pinned case stayed green with the deny
baseline put back. It now also asks the engine for a query keyed on a field
the caller may query on, which only object admission refuses.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…t, and answers 403 PERMISSION_DENIED without one

Three passages said the picker's results were bounded by its declaration
alone and that the guest set was only a back-compat leftover. Under the
deny baseline a search context that resolves no permission set is refused
at object admission, so the picker needs a guest set that grants read on
its target object; the errors table gains that answer.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 03ae8604a894db1b7a3f36d34d9ad03904e30609
Local-runs: none

Inputs, and nothing else: card #21079 (body and all 12 comments through 5937906612), PR #21217 (body, the 14-file list, the net diff 097ef802...03ae8604, +722/−248), and the 42 check-runs on the head (38 success, 4 skipped — Auto Label and Check PR Size on one of their two runs, Console Pin Gate, the opt-in Packed-tarball smoke — 0 failure; Dogfood Regression Gate, Test Core, every Type Check job, Lint & Repo Gates, Check Changeset, Governed Surface Queue Guard, Spec property liveness and Build Docs all green). Ruling E 5933054144 (maintainer 「同意E」) is the governing text; the seat's route B revision 5937041708 and revision 2 5937093863 are the claim surface. Source positions below are git reads of the head, not runs. ⚠️ Security family: classes, positions, codes and statuses only.

① Derived judgments

Every accept-set change the diff implies, each named right or wrong against the head's source:

  1. Step 2 CRUD gate, security-plugin.ts ~2516 — RIGHT. The resolved-set guard is gone; the gate now runs for every non-system caller the hand-through did not admit, and a principal-carrying caller with an empty list is refused PERMISSION_DENIED / 403 by the evaluator's answer to that list. The principal-less hand-through (isPrincipalLessContext, ~2216, return next()) and the publicFormGrant branch (~2043–2113, await next() then return) both precede the gate, so the ADR-0096 context and the public-form submit never reach it. The referential FK-clear exception is unchanged, and on that path step 2.6 still stands down (the narrowest answer).
  2. canReadObject arm 2 (~5812), canExport (~6201), canWriteObject arm 4 (~6098) — RIGHT. Each answers isPrincipalLessContext(context) for an empty list. For the principal-less context the answer is unchanged (true; arm 4 already skipped its field arm for that context, and the organization wall is never asked without a user id). For a principal-carrying caller every answer narrows to false. No arm widens.
  3. getReadFilter (~5389, ~5422) — RIGHT. The inline predicate it replaced (positions.length === 0 && explicit.length === 0 && !context?.userId) is byte-for-byte the body of isPrincipalLessContext (~352), so the hand-off is unchanged; the new deny-sentinel return for an empty list sits after resolution, inside the existing fail-closed try, and agrees with the middleware's refusal.
  4. D10 delegator arms — canReadObject arm 6, canWriteObject arm 8, the canExport intersection — RIGHT, and in surface. The middleware's own D10 ask at ~2586 carries no length guard (if (delegatorSets && !checkObjectPermission(...))), so the three probes' length guard was a second spelling that admitted a delegator with no set while the engine refused. Dropping it narrows only. Claim revision 5937041708 records the three arms; same class, mechanical, pinned (the second-principal pair, a set-holding delegator as control), ablated one leg each.
  5. Door consequences — RIGHT, and each one the ruling ordered or its migration text names. The app-declared authRequired: false endpoint: 200 → 403 PERMISSION_DENIED (authentication passed, refused at admission; the cache directive is not on the refusal). The public-form picker on a deployment without the set it names: 200 → 403 (the 5 picker pins from security(forms): two public-form doors meet a field masking rule wrongly — the submit read-back serves masked fields stored to an anonymous submitter, and a picker whose first display field is masked answers 403 to every caller it applies to #21062 re-pinned under route B; the ruling's own reading gives publicPicker no producer and no first-party caller, so the window to spec(forms): retire publicPicker — anonymous public forms stop taking lookup / master_detail / user fields, and the anonymous picker route goes (ruling E on #21079) #21180 costs nothing). A signed-in user on a baseline-disabled embedder: every door 403. The public-form submit: unaffected (the grant branch admits ahead of step 2; 26 stock-showcase and security dogfood files green on both legs; Dogfood Regression Gate green on the head). Fork clause: not triggered.
  6. One flip the PR names but a reader could miss — RIGHT, with no public door. canWriteObject now answers the public-form grant context false (the grant is not one of its arms by its docblock's design: no wire caller presents the grant to the preview). The only consumer is the plugin's own write-preview hook (~1359); the public form exposes GET /forms/:slug, POST …/submit and GET …/lookup/:field and no validate door, so the flip reaches the unit pin in predicate-related-read-tenant-scope.test.ts and nothing on the wire. That pin stays a refusal (preview invalid, the related user's columns never read) and adds the probe's answer directly.
  7. Field-level answers — unchanged, as ordered. getReadableFields / getQueryableFields / getWritableFields / getMetadataReadableFields (security(plugin-security): for a caller who resolves no permission set, the field-projection answers say no masking rule reaches it, while maskingRule's describe and the result masker mask it — which one a public door serves is not measured #20995 / security(spec): a capability-gated field (requiredPermissions, no maskingRule) is served stored to a caller who resolves no permission set, while explain reports it hidden; narrowing it moves getReadableFields' contract answer #21063) keep their answers; the moved suites keep every field assertion and move only the door assertions to refusal at admission (the fold suite now also pins explain: object_crud denies, allowed false, fls still hidden). checkAuthoredRowWrite 'abstain', computeLayeredRlsFilter, step 2.6 and the step 1.5 capability gate are untouched.
  8. Public surface — packages/spec/src/contracts/security-service.ts, docblocks only — RIGHT. Every changed line in that file is inside a /** … */ block: canReadObject's arm list and Fails-CLOSED paragraph, canExport's Fails-CLOSED paragraph, getMetadataReadableFields's "why the two differ" paragraph, and a new deny-baseline paragraph on getReadFilter. No method, parameter, return type, key or export moves; the arm order the canReadObject docblock states matches the implementation's six arms. check:api-surface, check:generated, Build Docs and Spec property liveness are green on the head.
  9. Remaining zero-set spellings (the permissionSets.length guards at steps 1.5, 2.6, 2.7, 2.8, 3.6, the delegator resolution and the sharing-scope resolvers) are stand-downs now dominated by step 2 for a principal-carrying caller and by the hand-through for a principal-less one — not door-specific admission copies. The new isPrincipalLessContext docblock's "no second spelling" sentence reads them as absent rather than dominated; comment-only, not a contract matter.

No widening anywhere in the diff. Docs: the three content/docs/ui/forms.mdx passages are the ones the change falsified and now state the measured answers; #21180's seat has recorded the shared file (5937693582).

② Semver level

③ Boundary flags

Every dev flag (phase 0 5929705833, round 1 5936988829, patch round 5937906612, the PR body's acceptance notes) and every open_questions entry, answered or escalated:

Disclosure discipline held in the PR body, the changeset and the pins: classes, positions, codes and statuses; no pin title states a request.

Implemented-by: claude/issue-21079-zero-set-deny-baseline
Reviewed-by: session_01DiCSbmJrkzNhuEAier4VoJ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 18:43
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 18:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 62b90d7 Oct 1, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21079-zero-set-deny-baseline branch October 1, 2026 19:11
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…nonymous lookup route (objectstack-ai#21180) (objectstack-ai#21222)

Fixes objectstack-ai#21180

Clause-②: yes (narrowing)

Retires the form field's `publicPicker` block and deletes the anonymous
lookup route `GET /forms/:slug/lookup/:field`. This is the second half
of ruling E on objectstack-ai#21079 (record `5933054144`, maintainer 「同意E」,
2026-10-01). Anonymous public forms no longer take lookup,
`master_detail` or `user` fields. The ruling reverses the objectstack-ai#7467 model
("declare `publicPicker`"). It is an immediate retirement under ADR-0087
D2: no alias window and no dual spelling. No new gate: the resolve
route's existing strip becomes unconditional.

The ADR-0061 note rides a separate docs-only PR (Tier H), not this one.

## What changed

| surface | change |
| --- | --- |
| spec | `FormFieldBaseSchema.publicPicker` is a `retiredKey()`
tombstone carrying the card's prescription (FROM → TO below).
`FormFieldPublicPickerSchema`, `FormFieldPublicPicker` and
`FormFieldPublicPickerParsed` are deleted (no other reader). |
| ADR-0087 | D2 conversion `form-field-public-picker-removed` (protocol
18, `retiredFromLoadPath`, `retiredAfter: 17.5.0`, order 53). One
`STEP18_RATIONALE` fragment. D3 semantic entry
`form-field-public-picker-retired`. `RETIRED_KEYS_BY_MAJOR[18]` gets
`ui/FormField:publicPicker` and `RETIRED_DEFS_BY_MAJOR[18]` gets
`ui/FormFieldPublicPicker`. The registry is regenerated by
`gen:migration-registry`, never hand-typed inside its markers. |
| rest | The picker handler is deleted, with its literal `guest_portal`
picker context and its picker-only helper `view-filter-rule-lowering.ts`
(no other importer, not exported). The ledger row
(`rest-route-ledger.ts`) goes too. The resolve route's strip loses its
`publicPicker` condition: lookup / `master_detail` / `user` fields are
always left off the anonymous rendering. |
| error codes | `LOOKUP_NOT_PUBLIC` and `LOOKUP_TARGET_MISSING` leave
`error-code-ledger.zod.ts`. This follows the ledger's own retirement
rule ("A row whose last EMITTER is deleted comes out with it"), so the
row is deleted, with no graded tombstone. The picker handler was the
only producer of both codes. |
| lint | The `publicPicker` claiming reader in
`validate-preset-comparands.ts` is removed, with its cases. The
now-unused `graph` parameter of `boundObjectOf` / `bindAncestors` goes
too (`noUnusedParameters`). |
| tests | Deleted: `public-form-lookup-picker.test.ts`,
`public-form-lookup-picker-queryable-key.test.ts`,
`public-form-lookup-filter-lowering.test.ts`,
`view-public-picker.test.ts`,
`public-picker-queryable-key.dogfood.test.ts`. Re-pinned:
`public-form-routes.test.ts`, `public-form-routes.stored-row.test.ts`,
`rest-server-query-number-census.test.ts`,
`rest-server-canonical-query-ast.test.ts`,
`view-union-branch-focus.test.ts`, `protocol.save-union-issues.test.ts`,
and the picker door case of `zero-set-masking.dogfood.test.ts`. New:
`form-field-public-picker-retirement.test.ts` (tombstone at four doors,
the conversion, registration, and a tree-scoped absence pin over the
radius already declared for `@objectstack/spec`). |
| docs | The picker section of `content/docs/ui/forms.mdx` is replaced
by a short statement of current behaviour. The references regenerate.
The platform checklist item `access-security.public-form-intake` moves
to revision 2: its 403 clause now asserts the route's absence. |
| generated / ledgers | Regenerated: api-surface, export-origins,
declaration-map, json-schema manifest, authorable-surface (the def's
four lines deleted deliberately, the key marked `[RETIRED]`), strictness
counts and references. `dropped-refinements.baseline.json` is corrected
as the build printed it. `engine-double-contract.pinned.json` is
regenerated with `--write`: 6 losses, all from the two deleted test
files. The `check:route-envelope` pins are banked: 43 → 39 and 58 → 54,
the four `{ code, error }` answers the deleted handler carried. |
| changeset | `.changeset/21180-retire-public-picker.md`: BREAKING,
`Clause-②: yes (narrowing)`, the card's FROM → TO, and the ADR-0087
marker `registered form-field-public-picker-removed,
form-field-public-picker-retired`. |

**FROM → TO:** delete the `publicPicker` block; an anonymous public form
no longer offers record search. Use a `select` field with static
`options`, or put the form behind sign-in.

## The PM's hypotheses, measured

- **H1, the census.** At BASE `b9087d77e9`: 280 lines. By pattern:
`publicPicker` 129, `FormFieldPublicPicker` 43, `LOOKUP_NOT_PUBLIC` 19,
`/lookup/:field` 35, `guest_portal` 65. No producer exists outside spec,
tests, docs and the REST route; no example declares one. After, at
`dafa22868`: `publicPicker` 106, `FormFieldPublicPicker` 10,
`LOOKUP_NOT_PUBLIC` 0, `/lookup/:field` 15, `guest_portal` 59. The kit
accounts for the residue: the tombstone, the conversion and registry
entries, the retirement and union pins, the H2/H3 pins, and generated
artefacts (references, authorable-surface, and the base anchor, which
only its own generator writes). Untouched history also remains: two
dated audits, `releases/v15.mdx`, the ADR-0061 sentence, and the pending
`.changeset/21062-picker-queryable-key.md`. `guest_portal` remains as
permission-set names (examples, plugin-security tests, the published
skill's resolve/submit text) and as the submit route's context. In
`rest-server.ts` the picker's literal context was 1 of the 7 lines; the
other 6 are the submit route and its docblock. All of those are outside
this card.
- **H2, the strip depended on the key.** It did: `if (t !== 'lookup' &&
t !== 'master_detail' && t !== 'user') return true; return
!!cfg?.publicPicker;`. The condition is deleted; the function now
returns the type test, with no new branch. Pinned by a stored row
carrying the old block on a lookup, a `master_detail` and a `user`
field: only the text field `subject` renders.
- **H3, gone, not refused.** On the in-process `HonoHttpServer`, with
the unmatched-request seam installed as `HonoServerPlugin.start()`
installs it, `GET /api/v1/forms/test/lookup/owner_id` answers `404` with
`error.code` `ENDPOINT_NOT_FOUND`. That body is byte-identical (path
aside) to a never-registered sibling path, and `findData` is never
called. The registered resolve route on the same harness answers 200,
the lit control.
- **H4, the ledger rule.** Deletion, per the rule's own text. The
`objectui` consumer check: zero readers at the old pin `e420df310f` and
the new pin `31971ff1e2` (only an exemption reason string in a parity
test). The `cloud` consumer check is NOT MEASURED: code search returned
zero for both the codes and a lit control (`"@objectstack/spec"`), so it
cannot see that repository.
- **H5, `os validate` on a fixture.** Before, at BASE: the fixture
authoring `publicPicker: { displayFields: ['name'], maxResults: 10 }`
gave exit 0, `valid: true`. After: exit 1, `valid: false`. The text face
prints the prescription at
`views.0.formViews.contact.sections.0.fields.1.publicPicker`. The
control fixture, identical without the key, gives exit 0 both times.
- **Pin stop condition.** objectui at `.objectui-sha` imports neither
name at `e420df310f` nor, after objectstack-ai#21149 moved it, at `31971ff1e2`: 7
string mentions and 0 import lines, with a lit control of 392 files
importing `@objectstack/spec/ui`. Its spec-parity test enumerates
`FormFieldSchema.in.shape`, and the tombstone keeps the key there. No
pin bump rides this PR.

## Reverse verification (the fix committed first, then BASE's route code
restored)

Run against `rest-server.ts` and the lowering module restored from BASE
(blob `c673773e46`, verified on disk by hash). The pins import source,
so no rebuild was needed. All three new pins went red in the expected
direction:

- strip: `expected [ 'subject', 'contact_id', …(2) ] to deeply equal [
'subject' ]`
- route registered: `expected true to be false`
- route answer: `expected 403 to be 404`

The other 20 cases in the file stayed green. Restore: back to the HEAD
blob `b52e360ec3`, `git diff HEAD` empty, `git status` clean, with a
trap on the script.

## Tests and gates (head `dafa22868`, after merging `main` at
`5e5ce48ce`)

- `@objectstack/rest`, whole package: 252 files, 4776 passed. Typecheck
green, including `check:test-typecheck`.
- `@objectstack/lint`, whole package (pre-merge, untouched by `main`):
118 files, 5486 passed. Typecheck green.
- `@objectstack/spec`: the local project ran 593 files with one failure,
the ledger's header totals (fixed: 212 → 210 schemas, 617 → 613 sites),
then 27/27. The repo project: 48 files, 849 passed. After the merge:
`src/ui`, `src/conversions`, `src/migrations`, error-code ledger,
migrate-sentence, alias-integrity and the merge-shape scripts give 120
files, 4299 passed. `check:generated`: 15/15 current. Typecheck green,
including `check:scripts-typecheck` and `check:test-typecheck`, which
compiles the new test's `@ts-expect-error`.
- `@objectstack/metadata-protocol`: `protocol.save-union-issues.test.ts`
25/25. Typecheck green.
- `@objectstack/cli`: unit tier 242 files, 3435 passed. The integration
tier is declared to CI.
- `@objectstack/dogfood`: `zero-set-masking` and
`expression-conformance`, 8/8. Typecheck green.
- Derived gates: `dispatch-gates --commands` on this head gives 138
families. All 138 ran to exit 0. `--ran` reconciliation: 138 run, 0
NOT-MEASURED, a derived zero with every exit code recorded. Three
refusals were cleared by building their prerequisites, not by skipping:
`check:skill-examples` and `check:dual-build-cjs-loads` exited 3 until
seven packages outside this diff were built. `check:pm-dispatch-gates`
and `check:type-check-debt` were re-run without the runner's 480 s cap.
- Lint, as a proven narrowing: eslint's own config lints
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and enables no type-aware
linting. The 21 touched code files were all linted (`--format json`): 0
errors, 0 warnings. The repo-wide `pnpm lint` is CI's.
- Changeset gates, against BASE: `check:adr-0087-registration`
(registered, both ids new here), `check:empty-changeset`,
`check-changeset-no-major` and `check:doc-authoring` all pass. The level
axis needs this PR's payload, so CI judges it.

## Serial with objectstack-ai#21079 (PR objectstack-ai#21217)

At open time PR objectstack-ai#21217 is a draft and not merged, so this PR is first of
the pair. This branch deletes both of objectstack-ai#21062's picker pins and removes
the picker door case of `zero-set-masking.dogfood.test.ts`, along with
the public form and inquiry object only that case booted, since the
fixture can no longer carry the retired key. The record-door case is
byte-identical. If objectstack-ai#21217 lands first, `main` gets merged here and these
deletions are kept. `security-plugin.ts` and `security-service.ts` are
untouched.

## Acceptance notes (observed, not filed)

- `.changeset/21062-picker-queryable-key.md` is an unreleased changeset
describing a change to the route this PR deletes. If both ship in one
release, the compiled notes will describe a route that no longer exists.
Left for the release compiler; this PR does not edit another PR's
changeset.
- `objectSchema` from `GET /forms/:slug` still publishes the definitions
of declared lookup / `master_detail` / `user` fields. The ruled strip
covers the rendered sections only, and widening it would be a new gate.
- `ISecurityService.getQueryableFields` loses its only REST reader with
the picker (`objectstack-ai#20935`). The method stays: it lives in objectstack-ai#21079's surface.
- objectui's parity test carries an exemption reason that describes the
retired route. It stays accurate until objectui moves to the spec
release that carries this; then its prose is stale (the key stays in the
shape).
- Two dated audits (`docs/audits/2026-06-*`) and the ADR-0096 table name
the route as history and are left as written.

## Deviations

- Changeset grade is `minor`, not `major`: the skill and
`check-changeset-no-major` refuse `major` in the launch window. BREAKING
is carried by the banner, the `Clause-②` line and the ADR-0087 marker.
- `LOOKUP_TARGET_MISSING` also leaves the ledger. The card names only
`LOOKUP_NOT_PUBLIC`, but the ledger's rule applies to both, and the
deleted handler was the only producer of each.
- The claim's file surface did not name `view-filter-rule-lowering.ts`
(the picker-only helper), `retired-defs/18.ui__FormFieldPublicPicker.ts`
(required by the build's manifest-deletion gate),
`dropped-refinements.baseline.json`,
`engine-double-contract.pinned.json`, `scripts/check-route-envelope.mjs`
(ratchet banking), `metadata-protocol` (a stale comment and a test that
rode the key), or the platform checklist item. Each follows mechanically
from the deletion.
- `migrations/registry.ts`: the generated regions come from
`gen:migration-registry`. The one hand edit is the `STEP18_RATIONALE`
fragment outside the markers, which the retirement skill requires.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…bjectstack-ai#21290)

## What this is

A pre-cut draft of the curated release page for **17.6.0**,
`content/docs/releases/v17/17-6.mdx`, plus its entry in
`content/docs/releases/v17/meta.json` and in
`scripts/docs-audit/handwritten-docs.json`. It also appends one dated
correction to `content/docs/releases/v17/17-5.mdx` (details below).

Docs-only. Everything is under `content/docs/releases/` (release-owned)
or the docs-audit list, so this is the dedicated docs-only PR
`AGENTS.md` sanctions for that tree. It publishes nothing from any
package, hence `skip-changeset`.

Clause-②: no

It follows the 17.5.0 page's structure:

1. Highlights
2. What's new, including the "read this before treating the version
number as a safety guarantee" list of silent runtime changes
3. Breaking changes & migration (triaged, not exhaustive)
4. New capabilities
5. Notable fixes
6. New in Console (Studio)
7. Shipped in 17.5.0, listed again in 17.6.0's CHANGELOG
8. Upgrade checklist (every line marked *not exercised*)

## How it was compiled

- **Source:** all 338 changesets pending on `main` at `748b2407`. Every
changeset was read and triaged into breaking, feature, fix or internal.
PR numbers and short SHAs are taken from the commit that added each
changeset.
- **Already shipped in 17.5.0:** 16 of the 338 changesets (from 15
commits) describe code that 17.5.0 already published. I checked each
commit with `git merge-base --is-ancestor` against the version commit
`8c87d26a` and the publish commit `0f6dcac5`.
- Eight follow the version commit in first-parent order. The 17.5.0 page
already covers them.
- Seven are ancestors of the version commit but were not in the version
PR when it merged. The 17.5.0 page does not mention them. Two of them
are breaking (`e73ee2d` objectstack-ai#20567, `c876a74` objectstack-ai#20504), and `7a1faf1`
(objectstack-ai#20579) makes `os validate --strict` fail on a live conversion.
- The new section matches the list in
`.changeset/20622-release-aftercare-upgrade-and-unannounced-notes.md`.
- **Cross-check:** each of the nine ADR-0087 conversions added since
17.5.0 is covered by a migration entry on the page:
  - `action-block-endpoint-to-target`
  - `connector-sync-keys-removed`
  - `connector-triggers-removed`
  - `cube-refresh-key-removed`
  - `dataset-count-measure-empty-field-removed`
  - `form-field-public-picker-removed`
  - `form-view-subform-columns-canonicalized`
  - `page-header-breadcrumb-removed`
  - `time-default-utc-suffix-dropped`
- **Console section:** built from the three pin-bump changesets
(`dd3f7e1be356 → db11afd4967c → e420df310f5b → 31971ff1e28f`), which
carry 232 releasing objectui changesets, 23 of them declared breaking.
The range was also read with `scripts/objectui-range.mjs`.
- **Fact-check pass:** a second pass checked every cited SHA's changeset
against the page, in three slices. It found 31 claims that were wrong or
overstated, and the three follow-up commits correct them. Some examples:
- An RLS `contains` verdict that `d2bc644` (objectstack-ai#21253) later reversed in
this same release.
- The `manage_platform_settings` scope: reads of both types, but writes
of `datasource` only.
  - The dataset door's status after `434c6c7` (objectstack-ai#21240).
  - Filter positions that differ per change.
  - `7a1faf1`, which is not a breaking change.
- **Other corrections made while compiling:**
- The connector migration table does not claim that a `job` schedules a
`connectorSource` pull. `0efbdc3` says nothing schedules a pull until
the `job` stage lands, and none landed in this release.
- The public-form picker search-key change (`bafb8c9`, objectstack-ai#21136) is noted
as moot, because the route it changed is retired later in the same
release (`3dc33b2`, objectstack-ai#21222).

## Open items for the cut

These are recorded in the page's RELEASE-TIME TODO comment.

- **Anonymous endpoints.** Under the deny baseline (objectstack-ai#21217), an
app-declared anonymous endpoint (`authRequired: false`) can no longer
read or write objects. objectstack-ai#21158 is still open, and until it lands no
supported channel grants anonymous callers a permission set. The page
says so instead of prescribing a grant.
- **Console pin.** Three 17.6.0 server changes refuse a body that the
pinned Studio (`31971ff1e28f`) still sends. objectui fixed each one
after the pin:

| Studio action | Server change that refuses it | objectui fix (not in
the pin) |
|:--|:--|:--|
| A dataset count measure saved with the Field box blank (`field: ''`) |
objectstack-ai#21240 | `0858267e` |
| An External / Validate-only datasource saved without a credential |
objectstack-ai#21133 | `8001068b` |
| Republishing an html page that gained a plugin component | objectstack-ai#20852 |
`3ae91930` |

The page lists these under "Known console issues". If the pin moves
before the cut, the lines it fixes come out. Otherwise the
accepted-for-GA waiver is recorded.

## The correction to 17.5.0

One dated correction line is appended in place to the "Also shipped in
17.5.0" paragraph of `17-5.mdx`, in the form objectstack-ai#20985 used. It names the
seven commits that shipped in 17.5.0 with no CHANGELOG entry and links
to the 17.6.0 section. The original text is unchanged.

## Deliberately not in this PR

- `content/docs/releases/v17/index.mdx` is **untouched**. Its status
blockquote and per-release list may only claim 17.6.0 after the release
ships.
- An MDX comment at the top of `17-6.mdx` lists the release-time edits:
  1. the publish date;
  2. changesets landed after `748b2407`;
  3. the per-package CHANGELOG entry count;
  4. the console-pin outcome;
  5. objectstack-ai#21158 if it lands first;
  6. the `v17/index.mdx` update.

## Verification

Run locally on the head commit, with the workspace installed. All of
these pass:

- `check-issue-citations --base origin/main`: every added citation
resolves.
- `check:doc-anchors`, `check:role-word`, `check:docs-audit-scope`,
`check:nul-bytes`, `check:doc-authoring`, `check:docs-single-h1`,
`check:docs-redirects`, `check:docs-locale-catch-all`
- `check:corpus-claim-drift`, `check:docs-spec-enumerations`,
`check:published-readme-links`
- `check:release-notes`, `check:release-page-status`,
`check:release-index-currency-sync`, `check:release-body`
- `check-release-section-coverage` (plain and `--strict`),
`check-doc-frontmatter`, `check-docs-nav-label`,
`check-docs-section-name`, `check-section-landing-index`,
`check-doc-route-spelling --advisory`

Both pages compile as MDX with `@mdx-js/mdx` 3 + `remark-gfm`, and the
three tables parse with no ragged rows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL

---------

Co-authored-by: Claude <noreply@anthropic.com>
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