Repository navigation
fix(plugin-security)!: a caller who resolves no permission set is served a masked field masked and may not query on it - #21051
Conversation
…es no permission set Pins, red on this commit: for a non-system caller who resolves no permission set, the query projection and the middleware's query guards agree field for field and exclude every field whose masking rule applies; result masking serves those fields masked while the read projection keeps them as served columns; and a write echoing the masked placeholder is refused. The class boundary is pinned too: a principal-less context is handed through untouched and the projections say the same. The getQueryableFields case that pinned the full field set for this caller now expects the masked fields excluded. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…ved a masked field masked and may not query on it The middleware stood a posture stand-in in for a caller who resolves no permission set, and the stand-in carried no masking rule, so the result masker, the predicate guard and the field projections all answered "stored and queryable" for that caller, against maskingRule's declared contract (every non-system caller unless the field's requiredPermissions are all held). One derivation now decides it. resolveCallerPosture is the posture every gate and both projections read: as it is for a caller who resolves a set; for one who resolves none, the stand-in with the object's masking rules carried (and whether the posture resolved, which fails closed as for anyone). The masked-echo refusal and the aggregate-input guard are no longer gated on a resolved set, as the predicate guard never was. The principal-less hand-off is one predicate, read by the middleware and by the projections alike, so the boundary of the class has one answer too. The capability fold and the object's capability contract stay out of the stand-in: the read projection's full-set answer for this caller is unchanged, as the service contract states it. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…ves no permission set, on a real boot Two doors on a real composition: a public form's lookup picker serving its declared display fields to a visitor with no session on a deployment that registers no profile for public forms, and the record door for a signed-in user holding no grant on a deployment whose baseline is switched off. Each asserts the scene is real, the masked field is served masked beside a field served as stored, and on the record door a predicate on it is refused. Adds the changeset: plugin-security minor, BREAKING for callers who resolve no permission set, Clause-② no (narrowing). Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 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 ec7a5af47e83b0d57609e3231d1918f91df3eb19 && git checkout ec7a5af47e83b0d57609e3231d1918f91df3eb19
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d1f8ce8658b937d3685df62b04e2e478430b8e83 075fa174e61e43e962844e8ff2dd7fff75da39ef && git checkout -B drift-repro d1f8ce8658b937d3685df62b04e2e478430b8e83 && git merge --no-ff 075fa174e61e43e962844e8ff2dd7fff75da39ef
node scripts/docs-audit/affected-docs.mjs --json d1f8ce8658b937d3685df62b04e2e478430b8e83
|
Contract reviewServed-tier: Isolated reviewer, read at 2026-10-01T03:55Z. Inputs and nothing else: card #20995 (body and all four comments: triage Gate verdicts, read off the head's check-runsWaited for the head's CI to finish before writing (two polls, three minutes apart). 35 check-runs on the head, all completed: 31 ① Derived judgmentsEach accept-set or public-surface change the diff implies, with the verdict on it.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…oes back passes the result masker (objectstack-ai#21101) Part of objectstack-ai#21062 Clause-②: no (narrowing) ## What this changes This is position 1 of objectstack-ai#21062: the public-form submit read-back. Position 2 (the public picker's sort and search key) is a separate dispatch, so objectstack-ai#21062 remains open for it. A public form's submission is authorized by the ADR-0056 declaration-derived grant (`publicFormGrant`), which admits the create and the read-back on the form's declared object and nothing else. Its branch of the engine middleware in `packages/plugins/plugin-security/src/security-plugin.ts` runs before any permission set is resolved, and it used to `return next()` before step 4, the result masker. So the record echoed in the submit door's `201` body carried every field whose `maskingRule` applies as stored, to an anonymous submitter. That included a field filled from its `defaultValue` that the form never shows. PR objectstack-ai#21051's zero-set fix does not reach this, because it is a different mechanism. `maskingRule`'s describe says the field is "Masked for every non-system caller unless …", and an anonymous submitter is a non-system caller. The echo is now masked for that caller. ## One masker (triage's direction, H4) - **Step 4's body moves, unchanged, into the private method `maskOperationResult(opCtx, permissionSets, secMeta, delegatorSets)`.** The middleware's step 4 calls it for the caller it resolved. The grant branch calls it for the caller the grant stands in for. There is no second masker and no masking code in the grant branch. The move is byte-identical: the base block and the method body, compared after a two-space dedent, differ by nothing. - **The caller the grant stands in for is resolved as the data plane resolves it.** The grant branch calls `resolvePermissionSetsForContext` on the grant's context, then `resolveCallerPosture` for those sets. The zero-set stand-in is called, not edited. The anonymous submitter acts for nobody, so the delegator is `null`. - **Those inputs are read before `next()` and fail closed, as the data plane's do.** A permission-resolution failure is refused with `403 PERMISSION_DENIED`, and so is a posture that cannot be read (the data plane's own `unresolvedPosture*` wording). Nothing is written behind either refusal. - **A grant context is never the principal-less hand-off.** A grant context that carries no position, no named set and no user id has its read-back masked too. - **Admission is unchanged.** The grant still admits the declared object only, the create plus the read-back operations (`insert`, `find`, `findOne`, `count`) and nothing else. The server-managed field strip is untouched. ## Where the read-back comes from (H2), and which skipped steps bear on it - **Measured on a real boot:** on the submit door, the only operation the grant's context reaches the engine with is the `insert`. The echo is the insert's result; this door does no `findOne` read-back. The grant's other admitted operations (`find`, `findOne`, `count`) are issued by no public door in the tree. They are masked by the same call now. - **The grant branch skips every gate between it and step 4.** For this card, step 4 is the one that matters. - **Not load-bearing here:** the predicate guard (step 2.9). It would matter only to a grant `find` / `findOne` / `count` carrying a predicate, which no public door issues (see Acceptance notes). - **Not load-bearing here:** the masked-echo write refusal (step 2.5a). It refuses a value on a write; it does not expose anything. - **Admission, not this card:** the CRUD, field-write, row-level, owner and tenant gates. ## The caller the grant stands in for (H3) | Deployment | Sets resolved for the submit route's grant context | Posture the masker reads | |:--|:--|:--| | No guest set registered (the showcase's shape) | none | the zero-set stand-in: the object's masking rules, no capability fold | | A guest set registered under the name the route's context requests | that guest set | the object's posture | The grant's context carries no user id, so no baseline set applies on either deployment. ## Per deployment, per field class (H1) The anonymous submit door was measured on real boots: `bootStack` with the real `SecurityPlugin`, ObjectQL, SQL driver, REST and auth layers. It was measured at the base `dff98c1f51` and at the fix `491331ca3e`. The readings are given by class here; the raw readings are in the seat's private scratch. | Field class | No guest set, before | No guest set, after | Guest set, before | Guest set, after | |:--|:--|:--|:--|:--| | Masking rule, collected by the form | stored | **masked** | stored | **masked** | | Masking rule, filled from its default, not on the form | stored | **masked** | stored | **masked** | | No rule, collected by the form (control) | stored | stored | stored | stored | | No rule, filled from its default, not on the form | stored | stored | stored | stored | | Capability-gated (`requiredPermissions`), no rule, filled from its default, not on the form | stored | stored ¹ | stored | **absent** | The submit answered `201` in every cell. A server-managed field the submitter supplied never landed, before or after. ¹ The zero-set stand-in carries no capability fold, so a caller with no set is served this class as stored. That is objectstack-ai#21063's class, not addressed here; this PR does not edit the stand-in. ## Pins, red then green - **`packages/plugins/plugin-security/src/public-form-grant-masking.test.ts`** (new, 15 cases). - It covers both deployment shapes. Each case first asserts what resolution answers for the grant's caller there. - Per deployment, it pins five things: - every masked field is echoed masked on the submit, the defaulted one included; - the read-back operations the grant admits serve them masked; - the field with no rule is served as stored; - admission is unchanged: the create runs and the server-managed fields are stripped; - a count passes, and another operation or another object is refused `403 PERMISSION_DENIED` before it runs. - A boundary block pins three more cases: a grant context with no principal is masked too, and a permission-resolution failure or an unreadable posture refuses before the operation runs. - **`packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts`** (new, 2 cases). It runs a real boot of each deployment shape and drives the anonymous submit door over HTTP. Each case asserts: - the scene is real (a system read holds the stored values); - both masked field classes are echoed masked; - the field with no rule is echoed as stored; - the create succeeds, and a supplied server-managed field never lands. - **Red on the pins commit `503bf4bafa`** (plugin source at the base): unit 7 failed, 8 passed. The seven are the four masking pins (two per deployment), the principal-less pin and the two fail-closed pins. The premises, the control and the admission pins were green. Dogfood 2 of 2 failed, each on the stored value served where the mask was expected. - **Green on the fix commit `491331ca3e`:** unit 15 of 15, after `@objectstack/plugin-security` was rebuilt. Dogfood 2 of 2 (`ablation-dist-preflight` found the marker in 4 built files). `@objectstack/plugin-security` typecheck passed, the test layer included. ## Ablation The mutation makes the grant branch return before the masker again: `await next(); this.maskOperationResult(…); return;` became `return next();`. It was made through `scripts/ablation-replace.mjs` in wrap mode, with an outer `EXIT INT TERM` trap that restores the absolute path from `HEAD`. The anchor hit 1 → 0 and the blob moved `42d2348c…` → `67bf5842…`. - **Predicted:** exactly the five masker pins red (four masking pins and the principal-less pin), and the other ten green. The fail-closed pins stay green because the inputs are still read. Dogfood 2 of 2 red. - **Observed:** - `@objectstack/plugin-security` was rebuilt, and `ablation-dist-preflight --absent` found the marker absent from all 6 built files. - Unit: 5 failed, 10 passed. These are exactly the predicted five. - Dogfood: 2 of 2 failed, on the stored value. - **Restore:** `git checkout HEAD --` on the absolute path. - The restore was proven byte-identical: the blob is `42d2348c…`, equal to `HEAD`; `git diff HEAD` is empty; and the whole-tree status has 0 entries. - It was then rebuilt, and the preflight found the marker present in 2 built files. - Unit 15 of 15 and dogfood 2 of 2. ## Local verification, at the merged head `d076cc606a` All of the following ran in one locked sequential script, after a full workspace build (`turbo run build`, docs excluded). Every exit code was 0. - **Unit and type checks:** - `@objectstack/plugin-security` test: 153 files, 3301 passed, 23 skipped. This includes the new pin and the existing `publicFormGrant` suites. - `@objectstack/plugin-security` typecheck passed, the test layer included (`check:test-typecheck` OK). - `@objectstack/dogfood` typecheck passed. - **Dogfood (`isolated` project):** the new pin, `zero-set-masking` and `showcase-public-form`, 8 of 8. The `form-self-auth` dogfood (`shared-showcase` project), 3 of 3. These are the neighbours that drive the grant. - **Neighbour route suite:** `@objectstack/rest` `src/public-form-routes.test.ts`, 21 of 21. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands. All 67 ran. `--ran` reconciliation: 67 derived, 67 run, 0 not measured. - `check-adr-0087-registration`: the changeset reads `[BREAKING+bang+clause-②-narrowing]`, `not-required (no-migration-prescription)`. - `check-changeset-no-major`: no `major` bump. - `check:nul-bytes`, `check:doc-authoring`, `check:cross-package-test-inputs`, `check:test-source-alias` and `check:engine-double-contract`: green. - **The four roster families whose rosters sit under this diff's directories** (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`): green. - **Lint:** `eslint --no-inline-config --format json` on the 3 touched TypeScript files linted 3 files, with 0 errors and 0 warnings. - The population is `eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus its never-linted build directories, and all 3 files are in it. - The config enables no type-aware linting (its own header states this), so this diff cannot move the verdict on any untouched file. - The repo-wide `pnpm lint` is left to CI. ## Surface - **The `publicFormGrant` branch** of the engine middleware: the comment and the masker call after `next()`. - **Step 4 of the same middleware:** its body is moved, unchanged, into `maskOperationResult`, which sits beside `computeReadPartialMaskRules`. The step itself is now one call. - This edit sits beside the grant region in the same file. It is declared here because the masker cannot be reached from the grant branch without either a copy or this move. - **Not edited:** - the zero-set stand-in (`resolveCallerPosture`, and the comment at its call site): it is called, not changed; - `packages/rest/src/**`; - `packages/spec/src/**`. ## Acceptance notes - **Not measured locally: CI's whole-root and workflow-valued families.** `dispatch-gates` names 11 whole-root families, 6 workflow-valued families, 5 path-scheduled CI jobs and 4 type-check programs that no local command places. They run on this PR. - **A grant `find` / `findOne` / `count` with a predicate on a masked field is admitted.** The predicate guard (step 2.9) does not run under the grant. This was measured at the engine only. No public door in the tree issues those operations under the grant; the submit door issues the `insert` alone. It is not filed, because it has no public-door reach. - **The masked-echo write refusal (step 2.5a) does not run under the grant.** A submitter can store a value shaped like a mask placeholder into a masked field. That is value integrity on an anonymous create, not exposure, and it is unchanged here. - **On a deployment with no guest set, a capability-gated field with no masking rule is still echoed stored.** That is the zero-set stand-in's missing capability fold, which is objectstack-ai#21063's class; objectstack-ai#21063 is not addressed here. - **The two fail-closed refusals are new answers on failure paths only.** On the submit door the posture is read from the live registry the insert itself requires, so an unreadable posture is not reached there in a healthy composition. The refusal is pinned at the unit layer. - **`main` was merged once, before the gate run.** It had moved 7 commits after the branch was cut, and none of them touches `plugin-security`. The pins were re-run on the merged head. `main` has since moved 8 more commits, none touching `plugin-security` or this diff's files. They are not merged here; the merge queue rebuilds on them. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…s not served a capability-gated field, and the security contract says so (objectstack-ai#21134) Fixes objectstack-ai#21063 Clause-②: yes (narrowing) ## What this changes A field that declares `requiredPermissions` and no masking rule ("mask on read, deny on write; AND-gate", ADR-0066 D3) is no longer served to a non-system caller who resolves no permission set. That caller may not query on it, and a write payload naming it is refused. The explain engine already reported the field hidden for this caller class, and the record doors now agree with it. The service contract's field answers for this class narrow to match. This implements triage's ruling (`5925000390`) as written. The zero-set stand-in folds the capability gate through the fold the full-set path already takes. The full-set answer for every other class is unchanged. ## One derivation (H1) - `resolveCallerPosture`, the zero-set stand-in from PR objectstack-ai#21051, now carries the posture's per-field capability contract (`fieldRequiredPermissions`) beside the masking rules. Every reader already folds that input with `foldFieldRequiredPermissions`, the ADR-0066 D3 helper the full-set path uses. A caller with no set holds no capability, so each capability-gated field reads as neither readable nor editable for it. The fold itself is one line in the stand-in, with no second derivation. - The readers that move for this class all go through that one input: - the step 4 result masker (the record doors); - the step 2.9 predicate guard and the 2.5b aggregate-input guard (`computeQueryGuardFieldPerms`); - the published projections, through `resolveProjectionFieldMask`. - ⛔ Not carried: the object's own capability contract (object-level `requiredPermissions`). This caller's object admission is unchanged: the capability and CRUD gates are both still guarded by a resolved set. ## The write half, and why it is in this PR (H3, measured) The stand-in is also what `getWritableFields` reads, so carrying the field contract moves that projection for this class too. Its contract says it is "the exact complement of the fields that gate refuses". The middleware's step 2.5 field write gate was guarded by a resolved set. Left that way, the plugin would publish a write answer the write path does not enforce. Two load-bearing edits in the same file follow from this, both declared here: - **Step 2.5 is no longer gated on a resolved set**, as 2.5a and 2.5b have not been since PR objectstack-ai#21051. For a caller with no set, the evaluator's field map is empty, so the gate refuses only the capability-gated fields. That is "deny on write", as the field declares. The gate's verdict is now one helper, `computeForbiddenFieldWrites`, which replaces the two spellings the middleware and `canWriteObject` each carried. - **`canWriteObject` (the probe the write preview asks) gains a field arm for this class**, used when the caller carries a principal and supplies a payload. It asks the same helper over the same stand-in, so the preview answers such a payload as the write path does. On an unreadable posture the arm fails closed, as the projection answers `[]` there. ## Per reader, by class | Reader | Caller who resolves no set and carries a principal | Every other class | |:--|:--|:--| | Record doors (step 4 result masker) | capability-gated field: served stored → not served | unchanged | | Explain, `fls` layer | reported hidden (unchanged) | unchanged | | Predicate and aggregate guards (2.9, 2.5b) | a filter, sort, group or aggregate naming it: admitted → 403 `PERMISSION_DENIED` | unchanged | | Field write gate (2.5) | a payload naming it: admitted → 403 `PERMISSION_DENIED` | unchanged | | `getReadableFields`, `getQueryableFields`, `getWritableFields` | full set → full set minus the field | unchanged | | `getMetadataReadableFields` | the same, when the fallback set resolves nothing | unchanged | | `canWriteObject`, payload naming it | true → false | unchanged | | A principal-less context (no position, named set or user id) | handed through, unchanged | not applicable | A field that declares both `requiredPermissions` and a `maskingRule` is still served masked to this class, as since PR objectstack-ai#21051. ## Contract (H2) `packages/spec/src/contracts/security-service.ts` changes in its docblocks only. No method, type or export moves, and `check:api-surface` is green. - `getReadableFields` now states the answer for this class: the full set minus the capability-gated fields it is not served. A masked field stays, as a served column. - `getMetadataReadableFields` no longer says the middleware "skips its whole field gate" for this caller. It now says the middleware skips its permission-set grant gates while a field's own declarations still apply. When the fallback set resolves nothing, the method answers as the data plane does. - `getWritableFields` now states that `requiredPermissions` is part of its answer for every non-system caller. ## Pins, red then green (H4, measured) - `packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts` (new) covers the three ways this class arises, in the house style of `zero-set-masking.test.ts`. Each case first asserts that zero sets resolve. Then: - the record door and explain agree that the field is hidden; - the read projections leave the field out; - the query projection equals both query guards, field for field, across four positions; - the write gate refuses the field, and `getWritableFields` and `canWriteObject` agree with that gate field for field. - Controls in the same file: - a holder of the capability is served the stored value, explain hides nothing, and it may query and write the field; - a set without the capability gets the field hidden, as before; - the principal-less boundary is handed through. - **Red** on the pins commit `d49cc264d` (plugin source equal to the base): 12 failed, 8 passed. The 12 are the class cases; the premises, controls and boundary passed. H4 before the fix: the record door served the field (`gatedServed: true`) while explain reported it hidden (`explainHides: true`). - **Green** after the fix: the file passes 20 of 20. - `get-writable-fields.test.ts`: the one case that pinned the full set for this class now expects the capability-gated field excluded, and checks that the middleware agrees. ## Ablation (measured at `65d1da2b8`) Three one-anchor mutations went through `scripts/ablation-replace.mjs`. Each landed on disk (anchor count 1 → 0, injected marker 0 → 1, blob changed). Each was restored inside its EXIT/INT/TERM trap with `git checkout HEAD --` on the absolute path. Each restore was proven byte-identical: the blob `4d142d02…` equals HEAD's, and `git diff HEAD` is empty. The pins import the plugin source directly, so no `dist/` is on the resolution path. - **A1.** The stand-in line set back to an empty field contract: 12 failed, 8 passed. These are the same 12 as the red run. - **A2.** Step 2.5 gated on a resolved set again: 3 failed, 17 passed. That is one write case per class, where the middleware admits a payload that `getWritableFields` excludes. - **A3.** `canWriteObject`'s field arm for this class switched off: 3 failed, 17 passed. Here `canWriteObject` admits what the gate refuses. ## Consumers (Z3): suites run, not edited These ran at `65d1da2b8`, after building the upstream closures (turbo, `--filter='@objectstack/rest^...'` and the same form for each package below). - `@objectstack/rest`, the whole suite in two shards: 259 files, 5030 passed, 143 skipped. - `@objectstack/service-analytics`: 154 files, 3498 passed, 10 skipped. Its field gate reads the read and query projections, so for this class a capability-gated field is now refused as a group key, aggregate input or filter. - `@objectstack/plugin-approvals`: 52 files, 804 passed. Its snapshot redaction reads the read projection intersected with the query one, so for an approver in this class a capability-gated field is now dropped from the snapshot. - `@objectstack/metadata-core`, which consumes `getMetadataReadableFields`: 16 files, 298 passed. - `@objectstack/objectql` reads no field projection. It reads `canWriteObject` through the write-gate probe. Its four suites that boot plugin-security: 34 passed. The probe seam across both packages is pinned by plugin-security's `write-preview-field-gate-parity.test.ts`, green in the full run below. - `@objectstack/spec`, `src/contracts/`: 45 files, 434 passed. ## Local verification at `f692a171c` (after merging `main`, which brought PR objectstack-ai#21101) - `@objectstack/plugin-security` test: 154 files, 3321 passed, 23 skipped. Typecheck passed, including the test layer. - `@objectstack/spec` `check:generated`: all 15 artifacts up to date. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 87 commands. `--ran`: 87 derived, 84 run (each exit 0), 3 NOT MEASURED, 0 unrun. The three are `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`, each refusing with PREREQUISITE NOT MET because it needs every package's `dist/`. - `check:adr-0087-registration`: both changesets read `[BREAKING+bang+clause-②-narrowing]`, `not-required (no-migration-prescription)`. `check:changeset-no-major`: no major bump. - `eslint --no-inline-config --format json` on the 4 touched TypeScript files: 4 linted, 0 errors, 0 warnings. All 4 are in the config's `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` population. The config enables no type-aware linting (its own text states this), so this diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is left to CI. ## Acceptance notes - **NOT MEASURED: a real-boot reading of the record and explain doors for this class.** The record doors read the middleware's step 4, which the pins drive with the real plugin, and PR objectstack-ai#21051's dogfood pin exercises this class on two real doors. A real boot here would rebuild every package downstream of the spec docblock change. - **NOT MEASURED: the three prerequisite gates named above.** CI builds the whole tree and runs them on this PR. - **The public-form read-back reads the same stand-in.** That read-back landed in PR objectstack-ai#21101, so a capability-gated field is now also left out of the record echoed to a submitter who resolves no set. This follows from the one call; it is not pinned at that door, which is outside this card's surface. - **One corner is unchanged, and outside this surface.** Asked with no payload, `canWriteObject` still admits a caller of this class on an unreadable posture, while the middleware refuses it (since PR objectstack-ai#21051). That is this caller's object admission. The preview probe always hands a payload, so no door asks it that way. Carrier: the `domain:services` split of objectstack-ai#21061's direction 2, which reworks this caller's admission. - **Consumer suites ran at `65d1da2b8`, before the merge of `main`.** The merge touched none of this PR's lines. The plugin-security suite, `check:generated` and the gate union were re-run at the merged head. - **Untouched by design:** object admission and row scope for this class, the REST and analytics doors, and the public-form doors are objectstack-ai#21061's and objectstack-ai#21062's. --- _Generated by [Claude Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…isplay field the caller may query (objectstack-ai#21136) Part of objectstack-ai#21062 Clause-②: yes (narrowing) Position 2 of objectstack-ai#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 objectstack-ai#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`, objectstack-ai#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 (objectstack-ai#21084), the published-snapshot doors (objectstack-ai#21002) and the datasource metadata reads (objectstack-ai#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 objectstack-ai#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 objectstack-ai#21146, with the fix in flight as PR objectstack-ai#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 objectstack-ai#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 objectstack-ai#21062), and extended the pin-file line above with the narrowing pin._ --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20995
Clause-②: no (narrowing)
What this changes
A field that declares
maskingRuleis masked for every non-system caller unless the caller holds all of the field'srequiredPermissions(the describe onmaskingRuleinpackages/spec/src/data/field.zod.ts). A caller who carries a principal but resolves no permission set holds no capability, so the rule applies to it. The runtime served that caller the stored value and let it filter, sort, group and aggregate on the field. It now serves the masked value and refuses those queries, as it already did for every other masked caller.Correction to the card's source-read, measured. The card read the result masker as already masking for this caller. It does not: for a caller who resolves no set, the middleware stood a posture stand-in in for the object, and the stand-in carried no masking rule. The result masker, the predicate guard and the field projections therefore all agreed on "stored and queryable". The disagreement was between that one answer and the
maskingRuledescribe, and the explain engine, which reads the real posture and reports the field partially masked.Reach (triage step 1), measured on real boots. Doors that serve this caller class with a masked field in reach exist at public entry points: an anonymous public-form door that serves a referenced object's declared display fields, on a deployment that registers no profile for public forms, and every data door for a signed-in user on a deployment whose baseline is switched off. The readings, by door and class, are in the seat's private scratch file
issue-20995/reach-readings.txt. Nothing here states a request.One derivation (triage step 2)
resolveCallerPostureis the posture every gate and both field projections read. For a caller who resolves a set it isgetObjectSecurityMeta, unchanged. For a caller who resolves none it is the stand-in with two things carried from the posture: the object's masking rules, and whether the posture resolved. Every rule then applies (computePartialMaskRulesholds nothing to lift it), and an unreadable posture fails closed for this caller as for any other.isPrincipalLessContextis the one predicate for the hand-off the middleware makes before it resolves anything (no position, no named set, no user id). The middleware andresolveProjectionFieldMaskboth read it, so the boundary of the class has one answer too: that context is still served stored and the projections still answer the full set.getReadableFieldsstill answers the full set for this caller (a masked field is a served column), which is what the service contract inpackages/spec/src/contracts/security-service.tsstates. No spec file is touched.Per door, by class
PERMISSION_DENIEDgetQueryableFieldsISecurityService.getQueryableFieldsfor this caller drops the masked fields.getReadableFieldsandgetWritableFieldsanswer as before.Pins, red then green
packages/plugins/plugin-security/src/zero-set-masking.test.ts(new). Three caller classes, each by what resolution answers for it: a user id on a deployment with no baseline, no user id with only unresolvable named sets, and no user id with only an audience anchor. Each case first asserts the premise (zero sets). Then the query projection equals the middleware's query guards field for field across four positions and excludes the masked fields; result masking serves them masked and the read projection keeps them; and a masked-echo write is refused with 400VALIDATION_ERROR. A last block pins the boundary: a principal-less context is handed through untouched and the projections agree.packages/plugins/plugin-security/src/get-queryable-fields.test.ts: the one case that pinned the full field set for this caller now expects the masked fields excluded.packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts(new). A real boot, two doors: the anonymous public-form door and the record door for a signed-in user with no baseline. Each asserts the scene is real (a system read holds the stored value), the masked field is served masked beside a field served as stored, and on the record door a predicate on it is refused.4b7105c437(tree equal to HEAD for the plugin source): 10 failed, 14 passed. These are the 9 zero-set cases and the updatedgetQueryableFieldscase. Premises and boundary green.21a8de2f92: the two files 24 of 24. With the masking, readable and writable suites beside them, 76 of 76.Ablation
The fix reverted by writing the pre-fix blob of
security-plugin.ts(from4b7105c437) into the tree at075fa174e6. Landing proven by blob hash (58bf1990…, the pre-fix blob) and by the markerresolveCallerPosturecounting 8, then 0. Then:plugin-securityrebuilt, andablation-dist-preflight --absentpassed: the marker is in none of the 6 built files;Restored with
git checkout HEAD --on the absolute path inside anEXIT INT TERMtrap. Restore proven byte-identical: the blob hash equals HEAD's (2a1f47aa…),git diff HEADis empty and the status is clean. Then rebuilt, and the preflight found the marker in 4 built files with the tree clean. Unit pins 24 of 24 and the dogfood pin 2 of 2.Consumers (Z3), suites run, not edited
@objectstack/plugin-approvalstest: 52 files, 804 passed. Its snapshot redaction reads the read projection intersected with the query one, so a masked field is now dropped from the snapshot for an approver who resolves no set, where it was served stored.@objectstack/service-analyticstest: 150 files, 3458 passed. Its field gate readsgetQueryableFields, so for this caller a masked field is now refused as a group key, aggregate input or filter.Local verification, at
075fa174e6@objectstack/plugin-securitytest: 151 files, 3276 passed, 23 skipped. Typecheck passed, including the test layer (check:test-typecheckOK).@objectstack/dogfoodtypecheck: passed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 67 commands, all run in one locked sequential script, every exit 0.--ranreconciliation: 67 derived, 67 run, 0 not measured.eslint --no-inline-config --format jsonon the 4 touched TypeScript files: 4 files linted, 0 errors, 0 warnings. The population iseslint.config.mjs's**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}minus its never-linted build directories, and all 4 files are in it. The config enables no type-aware linting (its own header states this), so this diff cannot move the verdict on any untouched file. The repo-widepnpm lintis left to CI.check:adr-0087-registration: the changeset reads[BREAKING+bang+clause-②-narrowing],not-required (no-migration-prescription).Acceptance notes
dispatch-gatesnames 11 whole-root families and 6 workflow-valued ones as not placeable locally. They run on this PR.origin/mainmoved by 7 commits. None of them touches this PR's files, so nothing was merged. Two of those commits changed gate scripts (scripts/check-test-typecheck.mts,scripts/cross-package-test-inputs.mjs), and CI runs the new copies.getMetadataReadableFields' docblock inpackages/spec/src/contracts/security-service.tssays the middleware "skips its whole field gate" for a caller with no permission sets. That now holds for the grant-based gates and not for masking. The answers it states are unchanged: the full read set on the data plane. This PR leaves that file alone, by the claim's surface. The plugin's own docblock is updated.Generated by Claude Code