Skip to content

fix(rest): the public lookup picker searches and sorts by the first display field the caller may query - #21136

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21062-picker-queryable-key
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21062-picker-queryable-key

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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 of publicPicker.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 with 403 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 answered 403 to 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 in rest.

  • No display field queryable: the route answers the engine's refusal for those fields, in the same envelope (403 PERMISSION_DENIED, the flat body mapDataError already produces for this route) and with the engine's sentence, before any query runs.
  • No security service: nothing is masked on such a deployment, and the first display field stays the key, as before.
  • A security service that cannot give the query answer (the method absent, or it answered undefined): the fallback the contract prescribes on getQueryableFields. Every display field whose declaration carries a maskingRule is passed over, whoever the caller is. A declaration that cannot be read admits no display field.
  • The projection is unchanged. Every display field is still returned in each row, and the engine still masks the masked one.

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)

bootStack with the real security, REST, auth and SQL layers, before at d9b2e34fac (the pins commit, the route as on main) and after at 2fa3a86e9b. 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.

Picker Caller class Before: status, key After: status, key
First display field masked visitor with no session, no guest set registered (resolves no set) 403 PERMISSION_DENIED on every request; the masked field 200; the next queryable display field; masked field served masked
First display field masked visitor, guest set without the capability 403 PERMISSION_DENIED on every request; the masked field 200; the next queryable display field; masked field served masked
First display field masked signed-in caller the rule applies to 403, the same as the visitor 200, the same as the visitor
First display field masked visitor, guest set holding the capability (rule lifted) 200; the first display field; served stored unchanged
Masked field not first (the dogfood shape) every class above 200; the first display field; masked where the rule applies unchanged
No masked display field (control) every class above 200; the first display field unchanged
Only display field masked the two classes the rule applies to 403 PERMISSION_DENIED, from the engine 403 PERMISSION_DENIED, from the route before the engine is asked; same refusal text

The 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 real SecurityPlugin over a real ObjectQL on a real SqlDriver, and the real protocol findData, 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 answering undefined, 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, refused 403 PERMISSION_DENIED with the engine never asked; its control is the answering service, which serves that caller. The pickers are parsed by the spec's own FormFieldPublicPickerSchema first.
  • 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 answers 403 PERMISSION_DENIED.
  • Red on the pins commit 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).
  • Green on the fix commit 2fa3a86e9b: unit 18 of 18; the four sibling picker and public-form suites 69 of 69; @objectstack/rest rebuilt and the dogfood pin 1 of 1.

Ablation

On the fix commit 2fa3a86e9b, through scripts/ablation-replace.mjs in 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 is displayFields[0] again on every path. Anchor 1 to 0, replacement 1 to 2, blob e2f14974 to d520d9ab.

  • Prediction, written down before the run: exactly the 12 unit pins that were red on the pins commit go red and the other 6 stay green; the dogfood pin goes red.
  • Unit pins: 12 failed, 6 passed. They are the same 12 as the red run, compared by name.
  • Rebuilt @objectstack/rest; ablation-dist-preflight --absent passed (the marker is in none of the 6 built files; on the pristine build the same marker was in 2).
  • Dogfood pin: 1 of 1 failed, on a 403 where 200 was expected.
  • Restore: git checkout HEAD -- on the absolute path, by the tool and again by the script's own EXIT INT TERM trap. Proven byte-identical: the blob equals HEAD's (e2f14974), git diff HEAD is 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.
  • A first attempt was a no-op and is not counted. Its replacement text already sat inside its anchor, so the replacement count did not rise and ablation-replace refused 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/main was merged twice (no rebase) before opening this PR, and both merges were clean. They brought three changes to rest-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.

  • Picker and public-form suites with the new pins: 5 files, 87 of 87. Dogfood, this pin and the 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 zero-set pin: 2 files, 3 of 3.
  • @objectstack/rest tests (--project local): 250 files, 4744 passed, 248 skipped. @objectstack/rest typecheck passed, the test layer included (check:test-typecheck OK, 0 errors).
  • @objectstack/dogfood typecheck passed. Its tsconfig includes test/**/*, 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) and check-partof-closing-keyword fed this body. All 77 ran in one locked sequential script and every one exited 0. --ran reconciliation: 66 derived, 66 run, 0 not measured.
  • eslint --no-inline-config --format json on the 3 touched TypeScript files: 3 files, 0 errors, 0 warnings. The population is eslint.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-wide pnpm lint is left to CI.

Acceptance notes

  • Clause-②: yes (narrowing), per the contract review (5929549767, which ruled the earlier no wrong). Widening: a picker whose first display field is masked for its caller was refused and is now served. Narrowing: for a security service that lacks getQueryableFields, 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 refused 403 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 the minor changeset and its pin.
  • The spec describe on FormFieldPublicPickerSchema.displayFields says 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.
  • A declared publicPicker.filter row that names a masked field is still refused 403 PERMISSION_DENIED for 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.
  • The refusal text is the engine's predicate-guard sentence, carried into the route so that a caller reads the same words whichever side refuses. The unit pin asserts it equal to the engine's live refusal for the same fields and context, so the copy cannot drift silently.
  • Not measured: a security service without the method on a real boot (the tree has none); the published-snapshot door and the form-submit route, which are outside this surface; the Console's picker client, which lives in another repository. CI's whole-root and workflow-valued families (10 and 6), the 5 path-scheduled CI jobs and the 4 type-check lanes the derivation names run on this PR.

Patch round 1

CI's Test Core (3/6) was red on this PR's own change: @objectstack/rest#test:repo failed in packages/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 by displayFields[0]. The local runs had used vitest --project local, which does not run the repo-test project.

  • Reproduced first at cdeed45868, under the verify lock: test:repo 1 failed, 178 passed, 1 skipped (5 files). The one failure is that pin.
  • The pin's purpose is kept. It is a backstop for the census: it proves the server-built literals are still present, in the canonical QueryAST spelling. It now names the picker's literal as it is, orderBy on the handler's one key. It also names the line that derives that key from the security service's queryable answer, so no other key in the file can satisfy it. rest-server.ts is untouched in this round (d815dd449a).
  • Full @objectstack/rest surface at the merged head 6dacc4c51b (origin/main merged, 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:repo on 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.
  • Gates: the 66 derived commands, the three roster families and check-partof-closing-keyword, in one locked script; every exit 0. --ran reconciliation: 66 of 66, 0 not measured. eslint on the 4 touched TypeScript files: 0 errors, 0 warnings.
  • CI on 6dacc4c51b: 32 success, 3 skipped, 0 failed, all six Test Core shards included.
  • Tooling note, outside this diff: the turbo version main now pins writes an agent-guidance block into AGENTS.md when it runs in an agent session. It happened twice here: during the closure build, and from a type-check gate that spawns turbo. Both times AGENTS.md was restored from HEAD with the blob proven equal, and the gates the write could have reached were re-run on the clean tree. AGENTS.md is 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 earlier Clause-②: no wrong. The value is yes (narrowing), at least minor. This round carries its items 2 and 3 and nothing else. rest-server.ts is not touched.

  • Changeset .changeset/21062-picker-queryable-key.md: '@objectstack/rest': minor; the summary is fix(rest)!:; a **BREAKING** banner names the narrowing by class (with a security service that lacks getQueryableFields or answers no answer, every display field declaring a maskingRule is passed over for every caller, so a picker whose display fields all declare one is refused 403 PERMISSION_DENIED without the engine being asked); the body's declaration line reads Clause-②: yes (narrowing); exactly one adr-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 predates getQueryableFields. scripts/pm/clause2-line.mjs reads the line as declared yes, arm narrowing.
  • Pin on the declared narrowing, appended to 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 refused 403 with code PERMISSION_DENIED, and the engine's call count is unchanged.
  • Ablation: the fallback stops excluding fields that declare a masking rule, through scripts/ablation-replace.mjs in 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 to HEAD's, git diff HEAD empty), and the pin file is green again, 19 of 19.
  • Merged origin/main first (no rebase, clean; no incoming change to rest-server.ts). Head 207eb98a97.
  • @objectstack/rest: all vitest projects, 257 files (the whole tracked population, local and repo), 4931 passed and 254 skipped; test:repo on 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.
  • Gates: in one locked script, the changeset families against the new declaration ran first: check-adr-0087-registration reads [BREAKING+bang+clause-②-narrowing] with not-required (no-migration-prescription); check-changeset-no-major, check-empty-changeset and check-changeset-fixed passed. Then the 66 derived commands, the three roster families, check-partof-closing-keyword fed the live body, and eslint on the 4 touched TypeScript files (0 errors, 0 warnings). All 81 commands exited 0. --ran reconciliation: 66 of 66, 0 not measured. Locally check-changeset-no-major's level axis has no PR payload to read and says so; CI's Check Changeset reads it, and it passed.
  • turbo and AGENTS.md: turbo wrote its block into AGENTS.md during the closure build and during check:type-check-debt. The script restored it from HEAD after each command and proved the blob, so every later command ran on the clean tree. AGENTS.md is not in this PR.
  • CI on 207eb98a97: 35 check runs, 32 success, 3 skipped (path and opt-in filters), 0 failed. Every required context is success, Check Changeset and all six Test Core shards included.

The seat appended this section from the dev's patch-round report (5930061042 on #21062), and extended the pin-file line above with the narrowing pin.


Generated by Claude Code

claude added 4 commits October 1, 2026 06:10
…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>
@github-actions github-actions Bot added the size/l label Oct 1, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

⛔ 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): node scripts/docs-audit/affected-docs.mjs --json f20f669e17a860a00c74a7a9495b8d3a56957675 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0fdc9e9778fe4ce9961df9765c9e63ab00c7f265 — the merge of head 207eb98a975ea45f9fc6808ddce1a4b4ee645a35 into base f20f669e17a860a00c74a7a9495b8d3a56957675, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI on cdeed458 · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-10-01T09:20Z · ⚠️ Classes and positions only.

Test Core (3/6) is red, and the cause is this PR's own change.

  • @objectstack/rest's test:repo project fails at src/rest-server-canonical-query-ast.test.ts:301. That source pin asserts the picker handler's literal sort key, and this PR changed the key.
  • The dev's local runs used the local project only, which does not run the repo tests.

Patch round 1 is sent to the same dev:

  1. reproduce the red;
  2. update the source pin to what the handler now does, without weakening it and without touching the handler's behaviour;
  3. run @objectstack/rest's full test surface and the derived gates;
  4. push to this branch.

The PR stays a draft until the patch head is green.


Generated by Claude Code

claude added 2 commits October 1, 2026 09:27
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6dacc4c51baf72336c73a5369ee8d1f094d50b32
Local-runs: none

PR #21136 (card #21062, position 2, Part of), claude/issue-21062-picker-queryable-key onto main. Inputs: the card body and its twelve comments (triage 5925021024; the claims 5925300766 and 5925661343; the cross-lane acknowledgement 5925483493; position 1's report, ACCEPT and landing 5926107785 / 5926131722 / 5926619268; the pointer 5928414754; position 2's reports 5928441490 and 5929268108; the seat's ACCEPTs 5928488316 and 5929308243); the PR body with the appended patch-round section and its 5-file list; the net diff of the head against main at the merge base c6954d6d09 (5 files, +682 / −13; the head is a merge of origin/main, and rest-server.ts is identical between cdeed45868 and the head); the 42 check-runs on the head, all complete; and, for the Clause-② weighing alone, #20986's record 5925735766 on PR #21088. Not read as conclusions: the dispatch order and the seat's ACCEPTs — every statement below is re-derived from the diff. ⚠️ Classes and positions only: no fixture spelling, slug, request body or returned value appears here.

① Derived judgments

The one key, and both readers of it — right. In the GET /forms/:slug/lookup/:field handler (rest-server.ts) key is derived once: with a security service resolved — resolveSecurityService, the resolver the class's other doors already use, asked for the picker's own route-built guest context (the session is not read, as before; the context object only moved above the derivation, its content unchanged) — key is the first display field in the queryable set; with no service, displayFields[0]. Both consumers read key: the contains search predicate and the fixed ascending orderBy. No other reader of displayFields[0] remains in the file (one occurrence: the derivation). The projection (fields: ['id', ...displayFields]) and the server-side re-projection are untouched, so every display field is still returned and the engine still masks the masked one. Right.

The queryable set, by branch — each judged:

  • A service that answers: the published answer of ISecurityService.getQueryableFields (security(analytics): a field the caller may only see masked is answered unmasked as a grouped or filtered member on the native-SQL strategy; the published field reader has no masked-for-this-caller answer #20935), computed by the service from the derivation the engine's predicate guard refuses with. No masking derived in rest — the direction's ⛔ holds. Right.
  • A service with the method absent, or answering no answer: the fallback the contract text prescribes verbatim — every field whose declaration carries a maskingRule is not queryable, whoever the caller is — read off the referenced object's declaration through the protocol's getMetaItem; a declaration that cannot be read admits no display field (fail closed). Right in direction; it is the one branch that changes answers for a composition the contract names as valid — judged in ②.
  • No service: queryable is undefined and key is the first display field — main's behaviour. Right; carried by the four sibling picker and public-form suites, which drive the route with no service provider.
  • A real empty answer (nothing queryable), or no display field in the answer: key undefined → the refusal below. Right.

Accept-set changes the diff implies, each judged:

  • Widening: a masked-first picker, caller the rule applies to (no guest set registered; a guest set without the capability; a signed-in caller, who gets the same route-built context): 403 PERMISSION_DENIED on every request → 200, rows sorted and searched on the next queryable display field, the masked field served masked. Triage's pin. Pinned on both masked classes (the unit pin boots the real SecurityPlugin over a real ObjectQL on a real SqlDriver and routes through the real protocol findData, so the composed request crosses the engine's own field guards; it also asserts no request the engine receives names the masked field) and on a real boot (the dogfood pin: visitor and signed-in caller). Right.
  • No queryable display field, masked caller: the engine's 403 → the route's 403 before the engine is asked, same code, status and sentence (pinned equal to the engine's live refusal for the same fields and context; the engine's call count pinned unchanged). "The platform's words": right. The sentence is a literal copy of predicate-guard.ts's, not a shared export — the equality pin is what holds it (a second copy already lives in service-analytics; this is the third). The fields it names are the picker's own display fields, which every 200 body already lists under displayFields — no new disclosure. Right.
  • Controls unchanged: a picker with no masked display field (pinned); a caller the rule is lifted for, real service (pinned: first field as key, served stored); a masked field not first (measured in the PR body; the same shape as the pinned control); no service (sibling suites). Right.
  • Narrowing, fallback composition only (a service lacking the method or answering no answer): (i) a caller the rule is lifted for, every display field declaring a maskingRule: served, keyed on the first → 403; (ii) the same caller, a masked-first picker with a later unmasked display field: served keyed on the first → served keyed on the next unmasked one (order and search target move). (ii) is pinned for both caller classes, method absent and no answer; (i) — the only refusal the diff introduces — is NOT pinned (the fallback pins drive only the two-field picker). Direction right (the contract's own ⛔ for a consumer that cannot get the answer); its declaration is wrong — ②.
  • Public surface: nothing moves. src/index.ts and package.json are not in the diff; no route, response key or error code is added (PERMISSION_DENIED exists; the body is the flat envelope mapDataError already produces for this route's outer catch). Right.

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 catch (mapDataError + log) and is not served. The context the service is asked for is the one the engine will see. The key choice is observable through row order, which the masked value already reveals. Nothing in the diff returns a value the engine would mask.

Patch round 1 (d815dd449a) — right. One file, rest-server-canonical-query-ast.test.ts (+10 / −1): the census presence pin for the picker's literal is re-spelled from the displayFields[0] literal to the key literal, with a second toContain on the exact derivation line, so the first cannot be satisfied by any other key in the file; every other literal in the block is unchanged. Not weakened — the belt still proves the literal's presence in canonical QueryAST spelling, now anchored to its derivation. rest-server.ts is not in that commit.

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: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in) (path and opt-in filters) and the second trigger's Auto Label and Check PR Size. Every required context success: Lint & Repo Gates, TypeScript Type Check, Test Core (6 of 6, Test Core (3/6) included — red before the round), Dogfood Regression Gate (3 of 3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard (no governed path among the 5 files), Check Changeset (both triggers). ⚠️ Check Changeset and the ADR-0087 registration gate are green on a bare no because a bare no stands both down (check-changeset-no-major.mjs: no without the arm → not-declared); that green measures the declaration's grammar, not its truth. ② judges the truth.

② Semver level

As carried. .changeset/21062-picker-queryable-key.md: @objectstack/rest: patch; summary fix(rest): without !; no **BREAKING** banner; no adr-0087: marker; the body's declaration reads no with no arm, and PR body line 2 the same. The claim 5925661343 declares no. The seat's decision A (5928488316) kept it, explicitly provisional on this review.

The value, read against the fleet's own criteria (.claude/skills/pm-dispatch/references/execution-duties.md 派发 §; references/lanes/spec.md 席内分派参考; AGENTS.md Post-Task §3):

⇒ The value is yes, and the level is at least minor; patch under-grades it.

The arm. The diff narrows the door for a composition the published contract names as valid: getQueryableFields is optional by type, and its docblock states that "absence is a defined state" and that "a security service that predates this method omits it". For such an out-of-tree service — the contract's own addressee — a caller the rule is lifted for loses the first display field as key, and a picker whose display fields all declare a rule goes served → 403. That is served → refused on a published door, by the measure #20986's record applied to its own "answered → 403" edge (ruled (narrowing), breaking, although the old answer there was wrong too). The dev's case A rests on no in-tree composition reaching it and no measured consumer; AGENTS.md's grammar ((narrowing) is BREAKING) binds on the published contract, not on measured consumers, and the contract text itself is what makes the older implementer real. That the fallback is the contract's prescription for consumers does not make the door's new refusal non-breaking for a deployer who upgrades @objectstack/rest alone.

⇒ The arm is (narrowing).

Ruling: the spelling is wrong. The PR must carry the declaration yes (narrowing) — bare, first after the colon, at the start of its own line, on PR body line 2 and in the changeset body — and @objectstack/rest: minor (the launch-window grade for a breaking change: no .changeset/pre.json at the head, and check-changeset-no-major.mjs refuses major). With the arm, check-adr-0087-registration.mjs reads a breaking declaration (its signal 4) and requires the carriers: the breaking signal on the summary (fix(rest)!:) and/or a **BREAKING** banner naming the narrowing by class, and exactly one adr-0087: disposition marker in an HTML comment. On the facts, not-required (no-migration-prescription) is the honest category — no authorable key, spelling, export or stored shape moves; the package publishes; no registry id covers which field a picker keys on; the change is route behaviour, not an interface or type surface — spelled as #20986's changeset spells it; the body's "What to do" prose carries no FROM → TO rewrite and may stay. The claim's declaration (5925661343) is the seat's to amend in the same round, since the queue gate reads the claim comment.

③ Boundary flags

open_questions (5928441490), one: the Clause-② grammar — A (no, patch) or B (no (narrowing), minor). Answered in ②: neither as written — yes (narrowing) with minor; B's carriers (the breaking signal and the ADR-0087 marker) are the ones owed. Decision A does not stand.

Deviations declared by the dev, each answered:

  1. (round 0) The branch pushed empty at main's commit before any pin — nothing disclosed; the first content push carried pins and fix together. Process; no bearing.
  2. (round 0) The finding's number (F4 against F5) — labelling; none.
  3. (round 0) Ablation attempt 1 refused as a no-op by the tool and re-anchored — the second attempt's readings are the ones reported; the red-first and ablation readings are the dev's and were not re-run here; the pins' verdict on the head is CI's. None.
  4. (round 0) A private measurement script copied in under a trap and removed — the 5-file list carries no such file. None.
  5. (round 1) turbo writing its agent-guidance block into AGENTS.md in the worktree, twice, restored each time — AGENTS.md is not among the 5 files and Governed Surface Queue Guard is success. None on this head; carried by 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 / PR chore(turbo): opt out of the agent-guidance block in the root turbo.json #21151.

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 → 403 PERMISSION_DENIED, the engine never asked — has no pin. A refusal case carries a code-plus-status assertion at minimum (the dispatch rule for 拒收用例), and #20986's declared narrowing was pinned.

What the PR must change:

  1. PR body line 2 reads Clause-②: yes (narrowing).
  2. .changeset/21062-picker-queryable-key.md: frontmatter '@objectstack/rest': minor; the body's declaration line reads Clause-②: yes (narrowing); a breaking signal the gates read — fix(rest)!: on the summary and/or a **BREAKING** banner naming the narrowing by class (for a security service lacking getQueryableFields or answering no answer: the key moves off any display field declaring a masking rule for every caller, and a picker whose display fields all declare one is refused 403 PERMISSION_DENIED); exactly one adr-0087: disposition marker in an HTML comment, not-required (no-migration-prescription) with the facts that close the other four categories, 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.
  3. One pin on the declared narrowing: a service without the method, a caller the rule is lifted for, a picker whose display fields all declare a rule → 403 with code PERMISSION_DENIED, the engine never asked.
  4. (the seat, same round) an amended claim on 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 whose declaration line reads Clause-②: yes (narrowing).

Nothing escalated. No security stop.

Implemented-by: claude/issue-21062-picker-queryable-key
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: FAIL

claude added 2 commits October 1, 2026 10:33
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI on 6dacc4c5 after the body edit · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-10-01T10:34Z

Check Changeset is red on 6dacc4c5, and that is this PR's own state, not a flake.

  • The contract review 5929549767 ruled the declaration yes (narrowing). This seat set PR body line 2 to match, which re-ran the gate on the old head.
  • The head's changeset still declares patch with no breaking signal, so the gate correctly reports the level and the line disagreeing.

The fix is patch round 2, already with the same dev: the minor changeset with the breaking banner, the matching declaration line, the ADR-0087 marker, and one pin on the declared narrowing. No re-run of this head: it would fail identically. The PR stays a draft until the new head is green and re-reviewed.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 207eb98a975ea45f9fc6808ddce1a4b4ee645a35
Local-runs: none

PR #21136 (card #21062, position 2, Part of), claude/issue-21062-picker-queryable-key onto main — re-review after the FAIL record 5929549767 on 6dacc4c51b. Inputs: the card body and its fifteen comments (triage 5925021024; the claim 5925300766 and both amendments 5925661343 and 5929563772; the cross-lane acknowledgement 5925483493; position 1's report, ACCEPT and landing 5926107785 / 5926131722 / 5926619268; the pointer 5928414754; position 2's three reports 5928441490, 5929268108 and 5930061042; the seat's ACCEPTs 5928488316, 5929308243 and 5930095390); the PR body (line 2 set by the seat, both patch-round sections appended), its 5-file list and every comment on it (the docs-drift advisory 5928415194, the seat's CI notes 5928511419 and 5929612459, the previous record 5929549767); the net diff of the head against main at the merge base f20f669e17 (5 files, +716 / −13); the 42 check-runs on the head, all complete; and, for the Clause-② weighing alone, #20986's record 5925735766 on PR #21088 and .changeset/20986-analytics-hop-object-reference.md on main. Not read as conclusions: the dispatch order and the seat's ACCEPTs — every statement below is re-derived from the diff and the head. ⚠️ Classes and positions only: no fixture spelling, slug, request body or returned value appears here.

① Derived judgments

The route is unchanged since the head the previous record judged — verified three ways. git diff 6dacc4c51b 207eb98a97 over rest-server.ts, rest-server-canonical-query-ast.test.ts and the dogfood pin is empty; the merge-base patch of rest-server.ts at the previous head (against c6954d6d09) and at this head (against f20f669e17) are identical hunk for hunk; and main brought no change to rest-server.ts between the two merge bases. Patch round 2 (207eb98a97, first parent the merge 9bc6855025) touches two files: the changeset (+16 / −8) and the pin file (+26 / −0). So the previous record's ① judgments stand as written; re-derived here in brief, each still right:

  • One key, both readers. In the GET /forms/:slug/lookup/:field handler key is derived once and read twice — the contains search predicate and the fixed ascending orderBy. At the head displayFields[0] has exactly one occurrence in the file: the no-service arm of the derivation. The projection (fields: ['id', ...displayFields]) is untouched, so every display field is still returned and the engine still masks the masked one. The context object moved above the derivation, its content unchanged; the session is still not read.
  • The queryable set, by branch. A service that answers: ISecurityService.getQueryableFields' published answer, asked for the picker's own route-built context through resolveSecurityService — no masking derived in rest. A service lacking the method or answering undefined: the fallback the contract text prescribes verbatim (every display field whose declaration carries a maskingRule is passed over, whoever the caller is), read off the referenced object's declaration through the protocol's getMetaItem; a declaration that cannot be read admits no display field (fail closed). No service: queryable undefined, the first display field — main's behaviour. A real empty answer, or no display field in the answer: key undefined → the refusal.
  • Accept-set changes. Widening — a masked-first picker, a caller the rule applies to: 403 PERMISSION_DENIED on every request → 200, keyed on the next queryable display field, the masked field served masked (pinned on both masked classes through the real SecurityPlugin, ObjectQL, SqlDriver and protocol findData; and on a real boot by the dogfood pin). No queryable display field: the engine's 403 → the route's 403 with the same code, status and sentence, the engine never asked (pinned equal to the engine's live refusal). Narrowing — fallback composition only: (i) a caller the rule is lifted for, every display field declaring a rule: served → 403; (ii) the same caller, a masked-first picker with a later unmasked display field: keyed on the first → keyed on the next unmasked one. (ii) was pinned in round 0; (i) is now pinned — below. Controls unchanged (no masked display field; a lifted caller with the answering service; no service). Public surface: nothing moves — src/index.ts and package.json are not in the diff; no route, response key or error code is added.
  • Security posture — no stop. Unchanged from the previous record: the route never derives for whom a rule is lifted; the fallback fails closed; a throwing service falls to the outer catch; the refusal's sentence names the picker's own display fields, which every 200 body already lists.

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, 200 — then drives the same picker, same caller, through the method-absent service and through the service answering undefined, asserting {403, PERMISSION_DENIED} and that the engine's find count on the referenced object did not move. The service is injected through the constructor's securityServiceProvider slot, the one resolveSecurityService reads, so the handler under test sees the altered service. On the diff, the only path to that refusal for a lifted caller is the fallback's filter excluding the rule-declaring field, leaving queryable empty and key undefined; on main's route (key = first display field, served to a lifted caller) and under the reported ablation (the exclusion removed, so the key is the first display field, the engine is asked, and a lifted caller is served) the pin fails on status and on the call count. The four round-0 fallback pins (masked-first picker, method absent / no answer, both caller classes) read the same filter, so an ablation of it reds exactly those five — the dev's prediction and reading agree with the derivation. The ablation and red-first readings themselves are the dev's and were not re-run here; the pin's verdict on the head is CI's (Test Core, 6 of 6, success).

Check-runs on the head: 42 — 37 success, 5 skipped, 0 failure, over two triggers (the head push and the PR-body edit). Skipped: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in) (path and opt-in filters) and the second trigger's Auto Label and Check PR Size. Every required context success: Lint & Repo Gates (which runs the ADR-0087 registration and empty-changeset gates), TypeScript Type Check and the four type-check lanes, Test Core (6 of 6), Dogfood Regression Gate (3 of 3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard (no governed path among the 5 files), Check Changeset on both triggers — the gate that was red on 6dacc4c5 after the body edit (5929612459) now reads the body line and the changeset level agreeing.

② Semver level

The previous record's four items, each judged on the head:

  1. PR body line 2 reads Clause-②: yes (narrowing) — bare, first after the colon, on its own line; line 1 is Part of #21062. Done. The body's Acceptance-notes bullet was rewritten to match; the remaining mentions of no are historical, naming the ruling.
  2. The changeset .changeset/21062-picker-queryable-key.md: frontmatter '@objectstack/rest': minor; summary fix(rest)!: (the breaking signal on the summary); a **BREAKING** banner naming the narrowing by class — the composition (a service lacking getQueryableFields, or answering no answer for the object), the mechanism (every display field declaring a maskingRule passed over for every caller, the lifted caller included), the consequences (search and order move to the next rule-free display field; a picker whose display fields all declare a rule is refused 403 PERMISSION_DENIED without the engine being asked, where it used to be served) and the exemption (the shipped service implements the method); the declaration line Clause-②: yes (narrowing) at the start of its own line; exactly one adr-0087: marker (count 1), in an HTML comment, not-required (no-migration-prescription), closing the other four categories on facts — the package publishes (not unpublished); no registry id covers which field a picker keys on (not already-registered); route behaviour, not a declaration (not runtime-interface-only / type-surface-only) — 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. The body carries no FROM → TO rewrite, migration heading or rewrite table, so the category's statement holds. No .changeset/pre.json at the head, so minor is the launch-window grade for a breaking change, as 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 shipped. Done.
  3. The pin on the declared narrowing — judged above. Done, and wider than asked: the no-answer service is driven beside the method-absent one, with the answering service as control.
  4. The claim amendment 5929563772's declaration line reads Clause-②: yes (narrowing), and it names the previous record as its reason. Done.

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 main and served at the head, on an unauthenticated public door of a published package, with no published contract text negating the old refusal (the displayFields describe still names the first entry, #21137): yes, at least minor. The door narrows for a composition the published contract names as valid — getQueryableFields is optional by type, its docblock states that a service predating it omits it, and the fallback it prescribes is what refuses the all-masked picker to a lifted caller — served → refused: (narrowing), breaking, as #20986's record ruled its own answered → 403 edge. yes (narrowing) with minor and the carriers is the right spelling; the head carries it. The gates agree: Check Changeset success on both triggers; Lint & Repo Gates success.

Clause-②: yes (narrowing) — right, as carried.

The changeset prose, for disclosure and for accuracy against the diff:

  • Disclosure: it names the route, the schema keys (publicPicker.displayFields, maskingRule), the contract method, the code and the status, and the package — no fixture spelling, form slug, request body or returned value. Holds.
  • The banner: each clause matches the diff — the fallback runs when the method is absent or returned undefined (a real [] answer is not the fallback; it is "nothing queryable" and refuses, as the "When no display field is queryable" paragraph says); the exclusion is by the declaration's maskingRule, context-free; the key moves to the first surviving display field; key undefined refuses before the engine is asked with mapDataError's envelope. "Where it used to be served" is true of the caller the rule is lifted for, which the sentence names; for a caller the rule applies to the same picker was 403 from the engine before and is 403 from the route now — same code and status, so no accept-set change for that class. Right.
  • "Unchanged": a no-masked picker and a lifted caller keep the first display field with the shipped service (its answer holds every display field, so find returns the first); a deployment with no security service keeps the first (queryable undefined). Both read off the diff. Right.
  • "What to do": the first display field the caller may query is the key (displayFields.find), so listing one such field ahead is the remedy; an all-masked picker is refused. Right.
  • The paragraph for a deployer whose service predates the method: "which fields a caller may filter, sort, group or aggregate by" is the contract docblock's own first sentence; "the picker then keys on the first display field in that answer" is the diff; "until it does, give each picker at least one display field that declares no maskingRule, or the picker is refused for every caller" is the fallback branch. Right.
  • One sub-case the changeset does not spell, noted for the next reader and not a flag on the verdict: under the same fallback composition, a referenced object whose declaration the protocol cannot read (no getMetaItem, or no fields on the item) admits no display field, so that picker is refused for every caller whatever its fields declare. It is the fail-closed arm inside the declared composition, the same class and code as the banner's narrowing, disclosed in the PR body and the handler's comment; the primary remedy the paragraph gives (implement the method) covers it, and the shipped protocol reads declarations. Not a separate narrowing to declare.

③ Boundary flags

open_questions (5930061042): none declared. Round 0's one question (the Clause-② grammar) was answered by the previous record and is closed by the head's declaration.

Deviations declared by the dev in round 2 (5930061042), each answered:

  1. The order's "the ablation must turn exactly this pin red" could not hold: the four round-0 fallback pins read the same filter, so the prediction written before the run was five, and five went red by name. Judged above: the derivation agrees; the new pin's own red is on the exact assertion that pins the narrowing. Right; no bearing on the verdict.
  2. The round's commit was pushed before the long locked run (AGENTS.md's WIP rule over the order's "run, then push"). The behaviour was built, pinned and reviewed on the previous head; this round disclosed no new behaviour, and the head's gates are CI's. Process; none.
  3. turbo writing its block into AGENTS.md twice, restored and proven each time — AGENTS.md is not among the 5 files and Governed Surface Queue Guard is success. None on this head; carried by 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 / PR chore(turbo): opt out of the agent-guidance block in the root turbo.json #21151.
  4. The PR body was not edited by the dev; the seat appended the round's section and replaced the one line the new pin made incomplete. The body read on GitHub carries both. None.

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 displayFields describe and (c) a declared publicPicker.filter row naming a masked field, carried by #21137; (a) turbo / AGENTS.md, carried by #21146 — are unchanged by this head; none is a data-exposure class needing a card now.

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 security stop.

Implemented-by: claude/issue-21062-picker-queryable-key
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 11:20
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 11:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit bafb8c9 Oct 1, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21062-picker-queryable-key branch October 1, 2026 11:34
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
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

Development

Successfully merging this pull request may close these issues.

2 participants