Skip to content

fix(plugin-security)!: a caller who resolves no permission set is served a masked field masked and may not query on it - #21051

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20995-zero-set-masking
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20995-zero-set-masking

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20995
Clause-②: no (narrowing)

What this changes

A field that declares maskingRule is masked for every non-system caller unless the caller holds all of the field's requiredPermissions (the describe on maskingRule in packages/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 maskingRule describe, 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)

  • resolveCallerPosture is the posture every gate and both field projections read. For a caller who resolves a set it is getObjectSecurityMeta, 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 (computePartialMaskRules holds nothing to lift it), and an unreadable posture fails closed for this caller as for any other.
  • The masked-echo write refusal (step 2.5a) and the aggregate-input guard (step 2.5b) are no longer gated on a resolved set. The predicate guard (step 2.9) never was. Without 2.5a a caller now served the mask could save it back over the stored value.
  • isPrincipalLessContext is the one predicate for the hand-off the middleware makes before it resolves anything (no position, no named set, no user id). The middleware and resolveProjectionFieldMask both 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.
  • The projection asks the on-behalf-of delegator only when the caller resolved a set, as the middleware does.
  • ⛔ Not carried into the stand-in: the per-field capability fold and the object's capability contract. getReadableFields still answers the full set for this caller (a masked field is a served column), which is what the service contract in packages/spec/src/contracts/security-service.ts states. No spec file is touched.

Per door, by class

Door, by class Caller class Before After
Anonymous public-form door serving a referenced object's declared display fields visitor with no session, on a deployment with no public-form profile (resolves no set) masked field served stored; a search or sort on it answered masked field served masked; a search or sort on it refused, 403 PERMISSION_DENIED
Record, list and query doors signed-in user on a deployment with the baseline switched off, holding no grant (resolves no set) stored; a filter on it answered masked; filter, sort, group and aggregate refused, 403
Doors that compile their own query and read getQueryableFields a caller who resolves no set the field queryable, its value served stored as a group key the field not queryable, refused 403
Engine middleware, any operation a context that names only sets resolving to nothing, or holds only an audience anchor no set is named after stored and queryable masked and not queryable
Engine middleware principal-less context (internal) stored and queryable unchanged
Engine middleware a caller who resolves at least one set unchanged unchanged

ISecurityService.getQueryableFields for this caller drops the masked fields. getReadableFields and getWritableFields answer 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 400 VALIDATION_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.
  • Red on the pins commit 4b7105c437 (tree equal to HEAD for the plugin source): 10 failed, 14 passed. These are the 9 zero-set cases and the updated getQueryableFields case. Premises and boundary green.
  • Green on the fix commit 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 (from 4b7105c437) into the tree at 075fa174e6. Landing proven by blob hash (58bf1990…, the pre-fix blob) and by the marker resolveCallerPosture counting 8, then 0. Then:

  • unit pins: 10 failed, 14 passed, the same ten as the red run;
  • plugin-security rebuilt, and ablation-dist-preflight --absent passed: the marker is in none of the 6 built files;
  • dogfood pin: 2 of 2 failed, each on the stored value served where the mask was expected.

Restored with git checkout HEAD -- on the absolute path inside an EXIT INT TERM trap. Restore proven byte-identical: the blob hash equals HEAD's (2a1f47aa…), git diff HEAD is 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-approvals test: 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-analytics test: 150 files, 3458 passed. Its field gate reads getQueryableFields, so for this caller a masked field is now refused as a group key, aggregate input or filter.

Local verification, at 075fa174e6

  • @objectstack/plugin-security test: 151 files, 3276 passed, 23 skipped. Typecheck passed, including the test layer (check:test-typecheck OK).
  • @objectstack/dogfood typecheck: passed.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 67 commands, all run in one locked sequential script, every exit 0. --ran reconciliation: 67 derived, 67 run, 0 not measured.
  • eslint --no-inline-config --format json on the 4 touched TypeScript files: 4 files linted, 0 errors, 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 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-wide pnpm lint is left to CI.
  • check:adr-0087-registration: the changeset reads [BREAKING+bang+clause-②-narrowing], not-required (no-migration-prescription).

Acceptance notes

  • Not measured: an approval inbox read on a real boot by an approver who resolves no set. The consumer suites cover that seam, and the field answers it reads are pinned here.
  • Not measured: CI's whole-root and workflow-valued families. dispatch-gates names 11 whole-root families and 6 workflow-valued ones as not placeable locally. They run on this PR.
  • The gate scripts were read on the base tree. After this branch was cut, origin/main moved 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.
  • The narrowing reaches one authoring shape. A public-form lookup whose first declared display field carries a masking rule now answers 403 for every request from a caller who resolves no set. That door sorts, and searches, by its first display field. The changeset states the class. Whether authoring should refuse that shape is a separate question, reported to the seat.
  • Contract docblock, for the spec owner. getMetadataReadableFields' docblock in packages/spec/src/contracts/security-service.ts says 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.
  • Unchanged by design, and outside this surface: the per-field capability fold for this caller, admission and row scope for this caller, and the public-form submit path, which never resolves sets. Further findings in the same caller class went to the seat privately, by class, under the disclosure discipline.

Generated by Claude Code

claude added 3 commits October 1, 2026 03:08
…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>
@github-actions github-actions Bot added size/l 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/plugin-security, touching 4 documentable anchor(s).

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

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 16 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 d1f8ce8658b937d3685df62b04e2e478430b8e83 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ec7a5af47e83b0d57609e3231d1918f91df3eb19 — the merge of head 075fa174e61e43e962844e8ff2dd7fff75da39ef into base d1f8ce8658b937d3685df62b04e2e478430b8e83, 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 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

⚠️ 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 d1f8ce8658b937d3685df62b04e2e478430b8e83 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 075fa174e61e43e962844e8ff2dd7fff75da39ef
Local-runs: none

Isolated reviewer, read at 2026-10-01T03:55Z. Inputs and nothing else: card #20995 (body and all four comments: triage 5923142248, claim 5923572624, dev report 5924254306, seat ACCEPT 5924277913), PR #21051 (body, five-file list, the net diff from merge base 2f2fa11d to the head), and the head's check-runs. The seven commits main gained since the merge base touch none of the PR's five paths. ⚠️ Security card: everything below is stated by class and position only.

Gate verdicts, read off the head's check-runs

Waited for the head's CI to finish before writing (two polls, three minutes apart). 35 check-runs on the head, all completed: 31 success, 4 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke — path-filtered or opt-in, none a required context), 0 failed. All seven required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Dogfood Regression Gate (and its three shards), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also green: Check Changeset (the PR Automation job that carries the ADR-0087 registration gate and the no-major guard), the claim guards, Spec property liveness, the four Type Check leaves and Dogfood Verify CLI. These conclusions are the gate verdicts; nothing was built, run or re-run locally.

① Derived judgments

Each accept-set or public-surface change the diff implies, with the verdict on it.

  1. One derivation — right. resolveCallerPosture is read at exactly two sites: the engine middleware (its secMeta, which every gate downstream reads: the capability AND-gate, the CRUD gate, the FLS write gate, the masked-echo refusal 2.5a, the aggregate-input guard 2.5b, the predicate guard 2.9 and the step-4 result masker) and resolveProjectionFieldMask, which all three published projections (getReadableFields / getMetadataReadableFields, getWritableFields, getQueryableFields) go through. For a caller with one or more sets it returns the real posture, so that class is byte-identical to before. For a caller with none it returns the stand-in with two things carried from the posture: the masking-rule map and the resolved flag. computePartialMaskRules is untouched and has no empty-set short-circuit (an empty capability set lifts no rule), so the stand-in's rule map is what turns every reader from "stored and queryable" to "masked and not queryable" at once. No second reading of masking is introduced; only the input to the one reading changed. The one reader of masking outside this derivation is the explain engine's injected dep, which reads the posture directly: it agrees with the stand-in on masking by construction (same rule map) and disagrees only on the capability fold for this class, which is the pre-existing F3 the dev reported, not a change this diff makes.

  2. Served value for the class: stored → masked — right. Step 4 was never gated on a resolved set; it was inert for the class because the old stand-in carried an empty rule map. The read partial-mask rules for an empty set are every declared rule (the explicit-deny subtraction has nothing to subtract from an empty evaluator map). Pinned three ways in the unit suite, each case asserting the zero-set premise first, and on two real-boot doors in the dogfood pin.

  3. Query accept set for the class: filter, sort, group and aggregate naming a masked field → refused 403 — right. Step 2.9 was never gated on a set; 2.5b was and is now ungated. Both read computeQueryGuardFieldPerms, whose map for the class is exactly its masked fields (the evaluator's map for no sets is empty, the capability fold is deliberately not carried). The unit suite proves the published getQueryableFields equal to the two guards' verdicts field for field across the four query positions.

  4. Write accept set for the class: an echoed mask → refused 400 — right, and necessary. Ungating 2.5a is the one edit beside the claim's named regions in the same file, and it is load-bearing: a caller now served the mask would otherwise save it back over the stored value. The dev declared it; the seat accepted it; I judge it part of the fix, not scope creep. Callers with sets are unaffected (their condition already held).

  5. getQueryableFields for the class: full set → full set minus masked — right. This is the one published answer that moves, and its contract (a subset of the read projection differing by exactly the fields the caller is served masked) now holds for this class as it did for every other.

  6. getReadableFields / getWritableFields / getMetadataReadableFields for the class — unchanged on a resolved posture, as claimed. The evaluator's field map for no sets is empty, the stand-in's capability contract is empty, so nothing is non-readable or non-editable and a masked field is kept as a served column: the full set, which is what the service contract in packages/spec/src/contracts/security-service.ts states for the data plane. ⚠️ One branch is narrower than the PR body's "answer as before": on an unreadable posture these now answer [] for the class where they answered the full set before. That is the same fail-closed tier every other caller already got, the changeset declares it ("one more refusal"), and the field-universe resolver and the posture resolver read the same two sources in the same order, so it is reached only where the universe resolves and the posture does not — a corner, on the safe side. Judged right; the PR body's sentence is right on every resolved posture.

  7. Middleware fail-closed on an unreadable posture now reaches the class — right. The rules come from that posture; while it is unreadable they are unknown. Declared in the changeset. Principal-less and system contexts still short-circuit before it.

  8. The principal-less boundary — preserved, one predicate. isPrincipalLessContext is the old middleware condition extracted byte-for-byte (no position, no named set, no user id) and is now read by the middleware hand-off and by the projection's early answer, so an absent or empty context still answers the full set and is still handed through. A context naming only sets that resolve to nothing, or holding only an audience anchor no set is named after, carries a principal and reaches the gates. The unit suite pins both sides of the boundary.

  9. Delegator intersection asked only when a set resolved — right. Matches the middleware's own condition; a caller who resolved nothing reaches the projection only for its masking rules, every one of which already applies.

  10. Consumers — nothing beyond the intended safe side. plugin-approvals serves the snapshot fields in the read projection intersected with the query one, so for an approver in this class a masked field is now dropped (the fail-closed shape that seam chose when it landed, not a new behaviour). service-analytics admits a member only when both answers carry its field, so for this class a masked field is refused as a group key, aggregate input, filter or sort key, with the engine's own words. On an unreadable posture the approvals seam reads [] as the denial its own docblock names. Both suites ran green per the dev report and the whole-root test gate on the head is the verdict.

  11. Public surface. No spec file, export, REST route or governed path moves. The one REST door that inherits the narrowing without being touched is the anonymous public-form lookup picker: its context names one set, so on a deployment registering no profile for public forms the visitor is in this class, and because the door sorts and searches by its first declared display field, a picker whose first display field declares a masking rule now answers 403 for that class. Covered by the changeset's mechanism and class (a sort key naming the field is refused; a query that must sort or search on it needs the capability or the rule dropped); not named by door, and the door-specific remedy (reorder the display fields, or register a profile holding the capability) is not in the changeset. Judged in ③ as the F4 residual.

  12. Reach. The dev measured the class reachable at public doors on real boots, correcting the card's source-read (the result masker did not mask either, because the stand-in carried no rule). The dogfood pin boots the real composition for two doors and asserts the scene first; its file lands in the dogfood package's isolated project by glob, so the Dogfood Regression Gate's conclusion is the gate verdict on it.

② Semver level

  • Changeset .changeset/20995-zero-set-masking.md: @objectstack/plugin-security at minor, bang in the title, a BREAKING banner, and Clause-②: no (narrowing). Right: the diff narrows a published accept set for one caller class (values served, queries admitted, one published projection's answer) and widens nothing, which is exactly the no (narrowing) arm; under the launch-window convention a breaking change ships as minor, and the no-major guard ran green. @objectstack/plugin-security is published; @objectstack/dogfood is private, so the dogfood pin needs no changeset. skip-changeset would have been wrong.
  • The PR body's line 2 carries the identical declaration.
  • ADR-0087 disposition not-required (no-migration-prescription): right. Nothing an author can write is removed, renamed or re-meant; no stored shape, spec key or export moves; objectstack migrate meta has nothing to rewrite. The changeset body carries no migration prescription (its "What to do" paragraph is prose without a FROM → TO shape), so the category's statement-against-statement check holds, and the PR Automation run that carries that gate is green.
  • The BREAKING text: states the class (who resolves no set but carries a position, a named set or a user id — the exact complement of the shared predicate), the four refused query positions, the echo refusal, the one moved projection, the unresolved-posture refusal and the remedy. One measured consequence is covered by class but not by door: see ① item 11 and ③.

③ Boundary flags

  • open_questions: none declared by the dev; none found.
  • Dev deviations, each answered: (a) no WIP push before the first edit — the order's push-only-after-the-fix rule is stricter and the seat accepted it; (b) one wait located a PID by name rather than by the recorded PID — a process-discipline deviation, reported, nothing killed, no effect on the diff; (c) the ablation wrote the whole pre-fix blob and proved landing by blob hash and marker count rather than with the replace tool — accepted by the seat, and the restore was proven byte-identical.
  • Surface amendments beside the claim's named regions, same file: the 2.5a gating edit (judged load-bearing in ① item 4) and the byte-for-byte extraction of the principal-less predicate (① item 8). Both declared, both accepted, both right.
  • F4, judged here as the seat asked: the changeset's BREAKING text covers the public-picker consequence by mechanism and class but does not name the door or its own remedy. Not a defect of the fix (the 403 is the safe side triage directed) and not a gate failure; it is a changelog-completeness residual for the F4 card the seat committed to file. That card should carry the authoring-side question (whether a picker whose first display field declares a masking rule is a shape authoring should refuse) and the two remedies by class. Escalated to the domain:services seat for filing.
  • F1, F2, F3: outside this card's surface, measured, reported by class; the seat committed to file each under the disclosure discipline. F3 is the one that touches this PR's own boundary: the capability fold is deliberately not carried into the stand-in because narrowing getReadableFields for this class would move an answer the spec contract states, so it is a domain:spec decision. Escalated as the seat recorded.
  • The spec contract docblock (getMetadataReadableFields in packages/spec/src/contracts/security-service.ts) still says the middleware skips its whole field gate for a caller with no permission sets. After this PR that holds for the grant-based gates and not for masking; the answers it states are unchanged. The dev left the spec file alone per the claim and reported it; escalated to the domain:spec seat.
  • Docs Drift Check (advisory): four hand-written pages name SecurityPlugin; the field-level-security page states no zero-set behaviour, so nothing on it is falsified by this diff.
  • Triage asked for a re-grade against the measured reach; the seat's ACCEPT carries that request. Not a dev flag; noted for the record.

Implemented-by: claude/issue-20995-zero-set-masking
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 03:56
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 03:56
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit a9d36d5 Oct 1, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20995-zero-set-masking branch October 1, 2026 05:17
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants