Repository navigation
fix(rest): the public lookup picker searches and sorts by the first display field the caller may query - #21136
Conversation
…t the caller's queryable fields The picker route keys its search and its order on one display field. These pins state that key by caller class: for a caller whose masking rule applies to the first display field, rows are sorted and searched on the next display field the security service answers queryable, the masked field is still served masked, and no request the engine receives names it; a picker with no queryable display field answers the engine's refusal (403 PERMISSION_DENIED) without asking the engine; a picker with no masked display field and a caller the rule is lifted for keep the first display field; and a security service that cannot give the query answer passes over every display field declaring a masking rule. One real-boot pin in the dogfood suite drives the same door. Red on this commit: the route still keys on the first display field. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…isplay field the caller may query The picker route (GET /forms/:slug/lookup/:field) keyed its search predicate and its order on the first entry of publicPicker.displayFields. A field whose masking rule applies to the caller is served masked and is not queryable, so the engine refused that key with 403 PERMISSION_DENIED and a picker whose first display field declares a masking rule answered 403 to every caller the rule applies to. The key is now the first display field in the security service's published query answer (ISecurityService.getQueryableFields), read through the service the handler already resolves. No masking rule is derived in rest. A security service that cannot give the answer gets the contract's fallback: every display field whose declaration carries a maskingRule is passed over. No display field queryable answers the engine's refusal for those fields, in the same envelope (403 PERMISSION_DENIED), before the engine is asked. With no security service the first display field stays the key. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…cker-queryable-key
…cker-queryable-key
📓 Docs Drift CheckThis PR changes 1 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0fdc9e9778fe4ce9961df9765c9e63ab00c7f265 && git checkout 0fdc9e9778fe4ce9961df9765c9e63ab00c7f265
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f20f669e17a860a00c74a7a9495b8d3a56957675 207eb98a975ea45f9fc6808ddce1a4b4ee645a35 && git checkout -B drift-repro f20f669e17a860a00c74a7a9495b8d3a56957675 && git merge --no-ff 207eb98a975ea45f9fc6808ddce1a4b4ee645a35
node scripts/docs-audit/affected-docs.mjs --json f20f669e17a860a00c74a7a9495b8d3a56957675
|
|
CI on
Patch round 1 is sent to the same dev:
The PR stays a draft until the patch head is green. Generated by Claude Code |
…rder key as the queryable key The census's by-name presence pin still asserted the picker's literal ordered by the first display field. The handler now orders by its one key, the first display field the caller may query, so the pin names that literal and the line that derives the key. The census's purpose is unchanged: the literal is still present and still in the canonical QueryAST spelling. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…cker-queryable-key
Contract reviewServed-tier: PR #21136 (card #21062, position 2, Part of), ① Derived judgmentsThe one key, and both readers of it — right. In the The queryable set, by branch — each judged:
Accept-set changes the diff implies, each judged:
Security posture — no stop. The route never derives who a rule is lifted for: it reads the service's answer, or the declaration's presence of a rule. The fallback fails closed; an unreadable declaration refuses; a service that throws falls to the outer Patch round 1 ( Check-runs on the head: 42 — 37 success, 5 skipped, 0 failure, over two triggers (the head push, and the PR-body edit that re-ran the PR-automation set). Skipped: ② Semver levelAs carried. The value, read against the fleet's own criteria (
⇒ The value is The arm. The diff narrows the door for a composition the published contract names as valid: ⇒ The arm is Ruling: the spelling is wrong. The PR must carry the declaration ③ Boundary flags
Deviations declared by the dev, each answered:
Findings, each asked whether it is a data-exposure class needing a card now:
One item owed with the re-declaration, not a new finding: the narrowing the changeset will declare — fallback service, lifted caller, every display field declaring a rule → What the PR must change:
Nothing escalated. No Implemented-by: VERDICT: FAIL |
…cker-queryable-key
…ning with one narrowing, and pin the narrowing The changeset now grades the change minor with the breaking signal, declares it yes with the narrowing arm, and carries one ADR-0087 disposition marker (not-required, no migration prescription). The narrowing it names: with a security service that cannot give the query answer, every display field declaring a masking rule is passed over for every caller, so a picker whose display fields all declare one is refused 403 PERMISSION_DENIED where it was served. One pin drives that case for a caller the rule is lifted for, with the answering service as its control, and asserts the engine is never asked. The route is unchanged. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
|
CI on
The fix is patch round 2, already with the same dev: the Generated by Claude Code |
Contract reviewServed-tier: PR #21136 (card #21062, position 2, Part of), ① Derived judgmentsThe route is unchanged since the head the previous record judged — verified three ways.
The new pin bites — judged on the diff, not on the dev's readings. The appended describe boots the deployment whose guest set holds the capability the rule names (the rule lifted for the picker's context), takes the control first — the answering service serves the all-masked picker to that caller, Check-runs on the head: 42 — 37 success, 5 skipped, 0 failure, over two triggers (the head push and the PR-body edit). Skipped: ② Semver levelThe previous record's four items, each judged on the head:
The value and the arm, re-weighed on the head (fleet criteria as the previous record cited them; AGENTS.md Post-Task §3; parity with #20986's record 5925735766): the same input — a masked-first picker, a caller its rule applies to — refused on Clause-②: yes (narrowing) — right, as carried. The changeset prose, for disclosure and for accuracy against the diff:
③ Boundary flags
Deviations declared by the dev in round 2 (5930061042), each answered:
Deviations from rounds 0 and 1 (5928441490, 5929268108) stand as the previous record answered them; nothing on this head reopens one. Findings: round 2 reports none. The previous record's findings — (b) the The one item the previous record owed with the re-declaration — the pin on the declared narrowing — is carried; nothing new is owed. Nothing escalated. No Implemented-by: VERDICT: PASS |
…pal and resolves no permission set gets the deny baseline at object admission and at the row scope (objectstack-ai#21217) Fixes objectstack-ai#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, objectstack-ai#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): - step 1.5 (the capability gate), step 2.6 (the depth stash), steps 2.7, 2.8 and 3.6, and the delegator resolution all stay guarded on a resolved set. Ungated, the depth stash would answer `'org'` for an unmatched list, which widens. - `checkAuthoredRowWrite` still answers `'abstain'` for the empty list. - `computeLayeredRlsFilter` is unchanged. - Every field-level answer (objectstack-ai#20995 / objectstack-ai#21063) is unchanged. The comments that described the old admission are corrected. **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 objectstack-ai#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 objectstack-ai#21180 then takes its own deletion of the case. ## Route B: the two picker pins from objectstack-ai#21062, re-pinned PR objectstack-ai#21136 (objectstack-ai#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 objectstack-ai#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: - the picker has no producer outside spec, tests, docs and its own route, so the window in which the picker answers 403 costs nothing; - objectstack-ai#21180 has no PR yet, so waiting for it has no stated end. Each fixture's intent is kept, and no case is deleted. objectstack-ai#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 objectstack-ai#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) - **ADR-0087 marker.** The changeset carries `not-required (no-migration-prescription)`, and the gate accepts it. The ruling's migration text is a grant decision, not a rewrite of anything an author wrote, so `objectstack migrate meta` has nothing to rewrite. Phase 0 expected this category to be wrong under route D. This is the same reading as the objectstack-ai#21061 and objectstack-ai#21063 changesets. - **A stale comment in `packages/rest/src/rest-server.ts`** (the export and explain fail-stance docblock). It still says the middleware skips its CRUD gate for an empty resolution. Carrier: objectstack-ai#21180, which edits that file. It is a comment only. - **A misleading preview message.** When the write-gate probe refuses a caller, a traversing validation rule in the preview fails with a message that tells the author to fix the rule's condition. It does not say that the related read was withheld. This is existing behaviour for every refused caller, and this PR only adds callers to that class. Carrier: none. - **API-key principal:** NOT MEASURED (as in phase 0). It carries a user id, so the baseline applies on a stock deployment. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…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>
Part of #21062
Clause-②: yes (narrowing)
Position 2 of #21062 only. Position 1 (the public-form submit read-back, in
plugin-security) is a separate PR on its own branch, so this PR does not close the card.What this changes
GET /forms/:slug/lookup/:field, the public form's lookup picker, matches the visitor's search and orders its rows by one key. The handler took that key blindly from the first entry ofpublicPicker.displayFields. A field whose masking rule applies to the caller is served to it masked and is not queryable, and the engine refuses a search or an order on it with403 PERMISSION_DENIED. Since PR #21051 that includes the caller who resolves no permission set. So a picker whose first display field declares a masking rule answered403to every caller the rule applies to, on every request.The key is now the first display field the caller may query on, by the security service's published answer (
ISecurityService.getQueryableFields, #20935). The handler reads it through the security service it already resolves (resolveSecurityService), for the context it already builds. No masking rule is derived inrest.403 PERMISSION_DENIED, the flat bodymapDataErroralready produces for this route) and with the engine's sentence, before any query runs.undefined): the fallback the contract prescribes ongetQueryableFields. Every display field whose declaration carries amaskingRuleis passed over, whoever the caller is. A declaration that cannot be read admits no display field.File surface:
packages/rest/src/rest-server.ts, the picker handler's key selection only (the context moved above it, unchanged). Pins beside it, one dogfood pin, and.changeset/21062-picker-queryable-key.md.Per case, by class (measured on real boots)
bootStackwith the real security, REST, auth and SQL layers, before atd9b2e34fac(the pins commit, the route as onmain) and after at2fa3a86e9b. Three deployments: no guest set registered; a guest set without the capability the rule names; a guest set holding it. The readings stay in the seat's private scratch.PERMISSION_DENIEDon every request; the masked fieldPERMISSION_DENIEDon every request; the masked fieldPERMISSION_DENIED, from the enginePERMISSION_DENIED, from the route before the engine is asked; same refusal textThe signed-in caller reads the same as the visitor in every row, before and after: the door builds its own context and does not read the session.
What a search finds (H4). For a picker whose first display field is masked, every search was refused before. After, a search matches the next queryable display field, so a caller finds rows by that field. A search for the masked field's value finds no row. In every other row of the table the search matches the same field as before, and what a caller finds is unchanged.
Pins, red then green
packages/rest/src/public-form-lookup-picker-queryable-key.test.ts(new). The realSecurityPluginover a realObjectQLon a realSqlDriver, and the real protocolfindData, so the request the route composes crosses the real ingress and the engine's own field guards. Two masked caller classes, each with: the premise (the security service answers the masked field readable and not queryable, and the engine refuses it); rows sorted on the next queryable display field with the masked field served masked; the search matching that field; no request the engine receives naming the masked field; and a picker with no queryable display field answering the engine's refusal (code, status and text equal to the engine's own for the same fields and context) with the engine never asked. Controls: a picker with no masked display field, and a caller the rule is lifted for. The fallback: a security service without the method, and one answeringundefined, for a caller the rule applies to and for one it is lifted for. The declared narrowing (patch round 2): the same fallback service, a caller the rule is lifted for, and a picker whose display fields all declare a masking rule, refused403 PERMISSION_DENIEDwith the engine never asked; its control is the answering service, which serves that caller. The pickers are parsed by the spec's ownFormFieldPublicPickerSchemafirst.packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts(new). A real boot: the visitor with no session and a signed-in caller are served rows sorted, and searched, on the next queryable display field with the masked field served masked; a picker whose only display field is masked answers403 PERMISSION_DENIED.d9b2e34fac: unit 12 failed, 6 passed (both masked classes on sorted, searched, never-a-key and the no-queryable refusal, and the four fallback cases; each failure on the defect: a 403 where 200 was expected, the masked field as the order key, the engine asked, the masked order). Dogfood 1 of 1 failed (403 where 200 was expected).2fa3a86e9b: unit 18 of 18; the four sibling picker and public-form suites 69 of 69;@objectstack/restrebuilt and the dogfood pin 1 of 1.Ablation
On the fix commit
2fa3a86e9b, throughscripts/ablation-replace.mjsin wrap mode: the queryable branch of the key expression (the first display field in the security service's answer) is replaced by the first display field, so the key isdisplayFields[0]again on every path. Anchor 1 to 0, replacement 1 to 2, blobe2f14974tod520d9ab.@objectstack/rest;ablation-dist-preflight --absentpassed (the marker is in none of the 6 built files; on the pristine build the same marker was in 2).git checkout HEAD --on the absolute path, by the tool and again by the script's ownEXIT INT TERMtrap. Proven byte-identical: the blob equals HEAD's (e2f14974),git diff HEADis empty and the status is clean. Rebuilt; the marker is back in 2 built files; unit pins 18 of 18 and the dogfood pin 1 of 1.ablation-replacerefused to run anything. The anchor was narrowed and the ablation repeated; the readings above are the second attempt's.Local verification
At the merged head
cdeed45868.origin/mainwas merged twice (no rebase) before opening this PR, and both merges were clean. They brought three changes torest-server.ts, all outside this handler: one import line (#21084), the published-snapshot doors (#21002) and the datasource metadata reads (#21119). The changed packages' closure was rebuilt first.maskingRule's describe and the result masker mask it — which one a public door serves is not measured #20995 zero-set pin: 2 files, 3 of 3.@objectstack/resttests (--project local): 250 files, 4744 passed, 248 skipped.@objectstack/resttypecheck passed, the test layer included (check:test-typecheckOK, 0 errors).@objectstack/dogfoodtypecheck passed. Its tsconfig includestest/**/*, so the new pin is compiled.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 66 commands, plus the three roster families it marks under a directory this diff touches (check-changeset-fixed,check:error-code-casing,check:filter-alias-parity) andcheck-partof-closing-keywordfed this body. All 77 ran in one locked sequential script and every one exited 0.--ranreconciliation: 66 derived, 66 run, 0 not measured.eslint --no-inline-config --format jsonon the 3 touched TypeScript files: 3 files, 0 errors, 0 warnings. The population iseslint.config.mjs's TypeScript and JavaScript glob, and all 3 files are in it. The config enables no type-aware linting, so this diff cannot move the verdict on any untouched file. The repo-widepnpm lintis left to CI.Acceptance notes
yes (narrowing), per the contract review (5929549767, which ruled the earliernowrong). Widening: a picker whose first display field is masked for its caller was refused and is now served. Narrowing: for a security service that lacksgetQueryableFields, a caller the masking rule is lifted for, and a picker whose display fields all declare a masking rule, the request was served and is now refused403 PERMISSION_DENIED, by the fallback the service contract prescribes. The only security service in the tree (plugin-security) implements the method, so no in-tree composition reaches the narrowing; the contract names that composition as valid, so it is declared. Patch round 2 carries theminorchangeset and its pin.FormFieldPublicPickerSchema.displayFieldssays the visitor's search matches the first entry. For a caller whose masking rule applies to the first entry it now matches the first entry that caller may query; before this PR that search was refused.packages/spec/src/**is outside this PR's claimed surface, so the describe is reported to the seat for the spec owner and is not edited here.publicPicker.filterrow that names a masked field is still refused403 PERMISSION_DENIEDfor every caller the rule applies to. It is the author's filter, not the route's key, so this PR leaves it alone, and it was measured unchanged. Reported to the seat, by class.Patch round 1
CI's
Test Core (3/6)was red on this PR's own change:@objectstack/rest#test:repofailed inpackages/rest/src/rest-server-canonical-query-ast.test.ts. That test's by-name presence pin (section 1, "names them") still asserted the picker's query literal ordered bydisplayFields[0]. The local runs had usedvitest --project local, which does not run the repo-test project.cdeed45868, under the verify lock:test:repo1 failed, 178 passed, 1 skipped (5 files). The one failure is that pin.orderByon the handler's one key. It also names the line that derives that key from the security service's queryable answer, so no otherkeyin the file can satisfy it.rest-server.tsis untouched in this round (d815dd449a).@objectstack/restsurface at the merged head6dacc4c51b(origin/mainmerged, no rebase, no conflicts): every project together, 256 files (251 local and 5 repo, which is the whole tracked population), 4925 passed and 249 skipped;test:repoon its own, 5 files, 179 passed and 1 skipped; typecheck OK, the test layer included. The dogfood picker and zero-set pins: 3 of 3. Dogfood typecheck OK.check-partof-closing-keyword, in one locked script; every exit 0.--ranreconciliation: 66 of 66, 0 not measured. eslint on the 4 touched TypeScript files: 0 errors, 0 warnings.6dacc4c51b: 32 success, 3 skipped, 0 failed, all sixTest Coreshards included.turboversionmainnow pins writes an agent-guidance block intoAGENTS.mdwhen it runs in an agent session. It happened twice here: during the closure build, and from a type-check gate that spawnsturbo. Both timesAGENTS.mdwas restored fromHEADwith the blob proven equal, and the gates the write could have reached were re-run on the clean tree.AGENTS.mdis not in this PR. Already filed as tooling: turbo 2.11.5 writes a managed block into AGENTS.md in every agent worktree; opt out with "agentGuidance": false in turbo.json #21146, with the fix in flight as PR chore(turbo): opt out of the agent-guidance block in the root turbo.json #21151.Patch round 2
The contract review (
5929549767) ruled the earlierClause-②: nowrong. The value isyes (narrowing), at leastminor. This round carries its items 2 and 3 and nothing else.rest-server.tsis not touched..changeset/21062-picker-queryable-key.md:'@objectstack/rest': minor; the summary isfix(rest)!:; a**BREAKING**banner names the narrowing by class (with a security service that lacksgetQueryableFieldsor answers no answer, every display field declaring amaskingRuleis passed over for every caller, so a picker whose display fields all declare one is refused403 PERMISSION_DENIEDwithout the engine being asked); the body's declaration line readsClause-②: yes (narrowing); exactly oneadr-0087:marker,not-required (no-migration-prescription), in the shape of analytics: on an inferred cube, a dotted path through a lookup whose name differs from its target object is never served — the door admits (and refuses) the lookup's NAME as if it were an object #20986's changeset, closing the other four categories on facts. The widening text is kept. The fallback, which round 0 listed under "Unchanged", moved to the banner, and a "What to do after upgrading" paragraph addresses a deployer whose security service predatesgetQueryableFields.scripts/pm/clause2-line.mjsreads the line as declaredyes, armnarrowing.packages/rest/src/public-form-lookup-picker-queryable-key.test.ts, with the existing synthetic fixtures. A caller the rule is lifted for is served by the answering service (the control). With the method absent, and with it answering no answer, the same caller is refused403with codePERMISSION_DENIED, and the engine's call count is unchanged.scripts/ablation-replace.mjsin wrap mode (anchor 1 to 0, replacement 0 to 1). The prediction was written down before the run: the new pin and the four round-0 fallback pins go red, because they drive the same branch; the other 14 stay green. Observed: 5 failed, 14 passed, those five by name; the new pin failed on a 200 where the 403 was expected. The restore was proven by the tool (blob equal toHEAD's,git diff HEADempty), and the pin file is green again, 19 of 19.origin/mainfirst (no rebase, clean; no incoming change torest-server.ts). Head207eb98a97.@objectstack/rest: all vitest projects, 257 files (the whole tracked population, local and repo), 4931 passed and 254 skipped;test:repoon its own, 5 files, 179 passed and 1 skipped; typecheck OK, the test layer included. Dogfood picker and zero-set pins 3 of 3; dogfood typecheck OK.check-adr-0087-registrationreads[BREAKING+bang+clause-②-narrowing]withnot-required (no-migration-prescription);check-changeset-no-major,check-empty-changesetandcheck-changeset-fixedpassed. Then the 66 derived commands, the three roster families,check-partof-closing-keywordfed the live body, and eslint on the 4 touched TypeScript files (0 errors, 0 warnings). All 81 commands exited 0.--ranreconciliation: 66 of 66, 0 not measured. Locallycheck-changeset-no-major's level axis has no PR payload to read and says so; CI'sCheck Changesetreads it, and it passed.turboandAGENTS.md:turbowrote its block intoAGENTS.mdduring the closure build and duringcheck:type-check-debt. The script restored it fromHEADafter each command and proved the blob, so every later command ran on the clean tree.AGENTS.mdis not in this PR.207eb98a97: 35 check runs, 32 success, 3 skipped (path and opt-in filters), 0 failed. Every required context is success,Check Changesetand all sixTest Coreshards included.The seat appended this section from the dev's patch-round report (
5930061042on #21062), and extended the pin-file line above with the narrowing pin.Generated by Claude Code