Repository navigation
fix(plugin-security,spec)!: a non-system caller that carries a principal and resolves no permission set gets the deny baseline at object admission and at the row scope - #21217
Conversation
…ine at object admission and row scope A non-system caller that carries a principal and resolves no permission set is now refused at object admission (the engine middleware's CRUD gate, and canReadObject / canWriteObject / canExport) and answered the deny sentinel as its row scope (getReadFilter). The principal-less context keeps its hand-off. A delegator who resolves no set is refused by the three probes as the middleware already refused it. The ISecurityService docblocks that stated the zero-set answers are narrowed to match. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…pin the zero-set field suites A new suite pins, per measured member (the guest envelope, a context naming only an unregistered set, a signed-in user on a baseline-disabled embedder), that every engine operation is refused at object admission before it runs, that the three probes answer no, and that the row scope is the deny sentinel; with a set-holding member, a registered named set and a set-holding delegator as controls, the principal-less hand-off as the boundary, and a zero-set delegator refused by the probes as by the middleware. The 21 cases that read the old zero-set admission now assert the refusal (code and status), the deny sentinel, or the probe's no; every field-level answer they pinned is kept. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ing grant arms The capability arms' delegator stand-down is dominated by the grant arm that now asks the delegator's empty list, so it stays as it was; only the grant arms of canReadObject and canWriteObject (and canExport's) carry the deny baseline for the second principal. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…o the deny baseline An explicit authRequired:false endpoint still passes authentication on the fixture, and its object read is now refused at admission (403 PERMISSION_DENIED) on a deployment that grants anonymous callers nothing; the declared cache directive stays off that refusal. The picker door and the baseline-disabled record door refuse at admission, the record door for the read and for a query naming an unmasked field alike. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
… BREAKING, with the ruling's migration text Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ro-set-deny-baseline
…ndOne with the engine's own predicate check The engine-double contract gate counts the fake; its findOne now routes through assertEngineFindOnePredicate as ObjectQL.findOne does, and the unused find stub is gone. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 140 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 7e6e4c2caf6d4cd0f209bc867e29affb5f9c3988 && git checkout 7e6e4c2caf6d4cd0f209bc867e29affb5f9c3988
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 097ef802700e109aa98a89c2991041516476b6f9 03ae8604a894db1b7a3f36d34d9ad03904e30609 && git checkout -B drift-repro 097ef802700e109aa98a89c2991041516476b6f9 && git merge --no-ff 03ae8604a894db1b7a3f36d34d9ad03904e30609
node scripts/docs-audit/affected-docs.mjs --json 097ef802700e109aa98a89c2991041516476b6f9
|
…ro-set-deny-baseline
…the guest set to the deny baseline On a deployment that registers no guest set the picker's context resolves no permission set, so the engine refuses it at object admission: every listing, search and masked-only picker answers 403 PERMISSION_DENIED and serves nothing, for a visitor and a signed-in caller alike. The key the picker composes is unchanged and still never names the masked field; the guest-set and lifted-rule classes keep their pins. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
… object admission, not the field guard Under the field guard alone the masked-only picker was refused with the same code and status, so the re-pinned case stayed green with the deny baseline put back. It now also asks the engine for a query keyed on a field the caller may query on, which only object admission refuses. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…t, and answers 403 PERMISSION_DENIED without one Three passages said the picker's results were bounded by its declaration alone and that the guest set was only a back-compat leftover. Under the deny baseline a search context that resolves no permission set is refused at object admission, so the picker needs a guest set that grants read on its target object; the errors table gains that answer. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, and nothing else: card #21079 (body and all 12 comments through ① Derived judgmentsEvery accept-set change the diff implies, each named right or wrong against the head's source:
No widening anywhere in the diff. Docs: the three ② Semver level
③ Boundary flagsEvery dev flag (phase 0
Disclosure discipline held in the PR body, the changeset and the pins: classes, positions, codes and statuses; no pin title states a request. Implemented-by: VERDICT: PASS |
…nonymous lookup route (objectstack-ai#21180) (objectstack-ai#21222) Fixes objectstack-ai#21180 Clause-②: yes (narrowing) Retires the form field's `publicPicker` block and deletes the anonymous lookup route `GET /forms/:slug/lookup/:field`. This is the second half of ruling E on objectstack-ai#21079 (record `5933054144`, maintainer 「同意E」, 2026-10-01). Anonymous public forms no longer take lookup, `master_detail` or `user` fields. The ruling reverses the objectstack-ai#7467 model ("declare `publicPicker`"). It is an immediate retirement under ADR-0087 D2: no alias window and no dual spelling. No new gate: the resolve route's existing strip becomes unconditional. The ADR-0061 note rides a separate docs-only PR (Tier H), not this one. ## What changed | surface | change | | --- | --- | | spec | `FormFieldBaseSchema.publicPicker` is a `retiredKey()` tombstone carrying the card's prescription (FROM → TO below). `FormFieldPublicPickerSchema`, `FormFieldPublicPicker` and `FormFieldPublicPickerParsed` are deleted (no other reader). | | ADR-0087 | D2 conversion `form-field-public-picker-removed` (protocol 18, `retiredFromLoadPath`, `retiredAfter: 17.5.0`, order 53). One `STEP18_RATIONALE` fragment. D3 semantic entry `form-field-public-picker-retired`. `RETIRED_KEYS_BY_MAJOR[18]` gets `ui/FormField:publicPicker` and `RETIRED_DEFS_BY_MAJOR[18]` gets `ui/FormFieldPublicPicker`. The registry is regenerated by `gen:migration-registry`, never hand-typed inside its markers. | | rest | The picker handler is deleted, with its literal `guest_portal` picker context and its picker-only helper `view-filter-rule-lowering.ts` (no other importer, not exported). The ledger row (`rest-route-ledger.ts`) goes too. The resolve route's strip loses its `publicPicker` condition: lookup / `master_detail` / `user` fields are always left off the anonymous rendering. | | error codes | `LOOKUP_NOT_PUBLIC` and `LOOKUP_TARGET_MISSING` leave `error-code-ledger.zod.ts`. This follows the ledger's own retirement rule ("A row whose last EMITTER is deleted comes out with it"), so the row is deleted, with no graded tombstone. The picker handler was the only producer of both codes. | | lint | The `publicPicker` claiming reader in `validate-preset-comparands.ts` is removed, with its cases. The now-unused `graph` parameter of `boundObjectOf` / `bindAncestors` goes too (`noUnusedParameters`). | | tests | Deleted: `public-form-lookup-picker.test.ts`, `public-form-lookup-picker-queryable-key.test.ts`, `public-form-lookup-filter-lowering.test.ts`, `view-public-picker.test.ts`, `public-picker-queryable-key.dogfood.test.ts`. Re-pinned: `public-form-routes.test.ts`, `public-form-routes.stored-row.test.ts`, `rest-server-query-number-census.test.ts`, `rest-server-canonical-query-ast.test.ts`, `view-union-branch-focus.test.ts`, `protocol.save-union-issues.test.ts`, and the picker door case of `zero-set-masking.dogfood.test.ts`. New: `form-field-public-picker-retirement.test.ts` (tombstone at four doors, the conversion, registration, and a tree-scoped absence pin over the radius already declared for `@objectstack/spec`). | | docs | The picker section of `content/docs/ui/forms.mdx` is replaced by a short statement of current behaviour. The references regenerate. The platform checklist item `access-security.public-form-intake` moves to revision 2: its 403 clause now asserts the route's absence. | | generated / ledgers | Regenerated: api-surface, export-origins, declaration-map, json-schema manifest, authorable-surface (the def's four lines deleted deliberately, the key marked `[RETIRED]`), strictness counts and references. `dropped-refinements.baseline.json` is corrected as the build printed it. `engine-double-contract.pinned.json` is regenerated with `--write`: 6 losses, all from the two deleted test files. The `check:route-envelope` pins are banked: 43 → 39 and 58 → 54, the four `{ code, error }` answers the deleted handler carried. | | changeset | `.changeset/21180-retire-public-picker.md`: BREAKING, `Clause-②: yes (narrowing)`, the card's FROM → TO, and the ADR-0087 marker `registered form-field-public-picker-removed, form-field-public-picker-retired`. | **FROM → TO:** delete the `publicPicker` block; an anonymous public form no longer offers record search. Use a `select` field with static `options`, or put the form behind sign-in. ## The PM's hypotheses, measured - **H1, the census.** At BASE `b9087d77e9`: 280 lines. By pattern: `publicPicker` 129, `FormFieldPublicPicker` 43, `LOOKUP_NOT_PUBLIC` 19, `/lookup/:field` 35, `guest_portal` 65. No producer exists outside spec, tests, docs and the REST route; no example declares one. After, at `dafa22868`: `publicPicker` 106, `FormFieldPublicPicker` 10, `LOOKUP_NOT_PUBLIC` 0, `/lookup/:field` 15, `guest_portal` 59. The kit accounts for the residue: the tombstone, the conversion and registry entries, the retirement and union pins, the H2/H3 pins, and generated artefacts (references, authorable-surface, and the base anchor, which only its own generator writes). Untouched history also remains: two dated audits, `releases/v15.mdx`, the ADR-0061 sentence, and the pending `.changeset/21062-picker-queryable-key.md`. `guest_portal` remains as permission-set names (examples, plugin-security tests, the published skill's resolve/submit text) and as the submit route's context. In `rest-server.ts` the picker's literal context was 1 of the 7 lines; the other 6 are the submit route and its docblock. All of those are outside this card. - **H2, the strip depended on the key.** It did: `if (t !== 'lookup' && t !== 'master_detail' && t !== 'user') return true; return !!cfg?.publicPicker;`. The condition is deleted; the function now returns the type test, with no new branch. Pinned by a stored row carrying the old block on a lookup, a `master_detail` and a `user` field: only the text field `subject` renders. - **H3, gone, not refused.** On the in-process `HonoHttpServer`, with the unmatched-request seam installed as `HonoServerPlugin.start()` installs it, `GET /api/v1/forms/test/lookup/owner_id` answers `404` with `error.code` `ENDPOINT_NOT_FOUND`. That body is byte-identical (path aside) to a never-registered sibling path, and `findData` is never called. The registered resolve route on the same harness answers 200, the lit control. - **H4, the ledger rule.** Deletion, per the rule's own text. The `objectui` consumer check: zero readers at the old pin `e420df310f` and the new pin `31971ff1e2` (only an exemption reason string in a parity test). The `cloud` consumer check is NOT MEASURED: code search returned zero for both the codes and a lit control (`"@objectstack/spec"`), so it cannot see that repository. - **H5, `os validate` on a fixture.** Before, at BASE: the fixture authoring `publicPicker: { displayFields: ['name'], maxResults: 10 }` gave exit 0, `valid: true`. After: exit 1, `valid: false`. The text face prints the prescription at `views.0.formViews.contact.sections.0.fields.1.publicPicker`. The control fixture, identical without the key, gives exit 0 both times. - **Pin stop condition.** objectui at `.objectui-sha` imports neither name at `e420df310f` nor, after objectstack-ai#21149 moved it, at `31971ff1e2`: 7 string mentions and 0 import lines, with a lit control of 392 files importing `@objectstack/spec/ui`. Its spec-parity test enumerates `FormFieldSchema.in.shape`, and the tombstone keeps the key there. No pin bump rides this PR. ## Reverse verification (the fix committed first, then BASE's route code restored) Run against `rest-server.ts` and the lowering module restored from BASE (blob `c673773e46`, verified on disk by hash). The pins import source, so no rebuild was needed. All three new pins went red in the expected direction: - strip: `expected [ 'subject', 'contact_id', …(2) ] to deeply equal [ 'subject' ]` - route registered: `expected true to be false` - route answer: `expected 403 to be 404` The other 20 cases in the file stayed green. Restore: back to the HEAD blob `b52e360ec3`, `git diff HEAD` empty, `git status` clean, with a trap on the script. ## Tests and gates (head `dafa22868`, after merging `main` at `5e5ce48ce`) - `@objectstack/rest`, whole package: 252 files, 4776 passed. Typecheck green, including `check:test-typecheck`. - `@objectstack/lint`, whole package (pre-merge, untouched by `main`): 118 files, 5486 passed. Typecheck green. - `@objectstack/spec`: the local project ran 593 files with one failure, the ledger's header totals (fixed: 212 → 210 schemas, 617 → 613 sites), then 27/27. The repo project: 48 files, 849 passed. After the merge: `src/ui`, `src/conversions`, `src/migrations`, error-code ledger, migrate-sentence, alias-integrity and the merge-shape scripts give 120 files, 4299 passed. `check:generated`: 15/15 current. Typecheck green, including `check:scripts-typecheck` and `check:test-typecheck`, which compiles the new test's `@ts-expect-error`. - `@objectstack/metadata-protocol`: `protocol.save-union-issues.test.ts` 25/25. Typecheck green. - `@objectstack/cli`: unit tier 242 files, 3435 passed. The integration tier is declared to CI. - `@objectstack/dogfood`: `zero-set-masking` and `expression-conformance`, 8/8. Typecheck green. - Derived gates: `dispatch-gates --commands` on this head gives 138 families. All 138 ran to exit 0. `--ran` reconciliation: 138 run, 0 NOT-MEASURED, a derived zero with every exit code recorded. Three refusals were cleared by building their prerequisites, not by skipping: `check:skill-examples` and `check:dual-build-cjs-loads` exited 3 until seven packages outside this diff were built. `check:pm-dispatch-gates` and `check:type-check-debt` were re-run without the runner's 480 s cap. - Lint, as a proven narrowing: eslint's own config lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and enables no type-aware linting. The 21 touched code files were all linted (`--format json`): 0 errors, 0 warnings. The repo-wide `pnpm lint` is CI's. - Changeset gates, against BASE: `check:adr-0087-registration` (registered, both ids new here), `check:empty-changeset`, `check-changeset-no-major` and `check:doc-authoring` all pass. The level axis needs this PR's payload, so CI judges it. ## Serial with objectstack-ai#21079 (PR objectstack-ai#21217) At open time PR objectstack-ai#21217 is a draft and not merged, so this PR is first of the pair. This branch deletes both of objectstack-ai#21062's picker pins and removes the picker door case of `zero-set-masking.dogfood.test.ts`, along with the public form and inquiry object only that case booted, since the fixture can no longer carry the retired key. The record-door case is byte-identical. If objectstack-ai#21217 lands first, `main` gets merged here and these deletions are kept. `security-plugin.ts` and `security-service.ts` are untouched. ## Acceptance notes (observed, not filed) - `.changeset/21062-picker-queryable-key.md` is an unreleased changeset describing a change to the route this PR deletes. If both ship in one release, the compiled notes will describe a route that no longer exists. Left for the release compiler; this PR does not edit another PR's changeset. - `objectSchema` from `GET /forms/:slug` still publishes the definitions of declared lookup / `master_detail` / `user` fields. The ruled strip covers the rendered sections only, and widening it would be a new gate. - `ISecurityService.getQueryableFields` loses its only REST reader with the picker (`objectstack-ai#20935`). The method stays: it lives in objectstack-ai#21079's surface. - objectui's parity test carries an exemption reason that describes the retired route. It stays accurate until objectui moves to the spec release that carries this; then its prose is stale (the key stays in the shape). - Two dated audits (`docs/audits/2026-06-*`) and the ADR-0096 table name the route as history and are left as written. ## Deviations - Changeset grade is `minor`, not `major`: the skill and `check-changeset-no-major` refuse `major` in the launch window. BREAKING is carried by the banner, the `Clause-②` line and the ADR-0087 marker. - `LOOKUP_TARGET_MISSING` also leaves the ledger. The card names only `LOOKUP_NOT_PUBLIC`, but the ledger's rule applies to both, and the deleted handler was the only producer of each. - The claim's file surface did not name `view-filter-rule-lowering.ts` (the picker-only helper), `retired-defs/18.ui__FormFieldPublicPicker.ts` (required by the build's manifest-deletion gate), `dropped-refinements.baseline.json`, `engine-double-contract.pinned.json`, `scripts/check-route-envelope.mjs` (ratchet banking), `metadata-protocol` (a stale comment and a test that rode the key), or the platform checklist item. Each follows mechanically from the deletion. - `migrations/registry.ts`: the generated regions come from `gen:migration-registry`. The one hand edit is the `STEP18_RATIONALE` fragment outside the markers, which the retirement skill requires. --- _Generated by [Claude Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…bjectstack-ai#21290) ## What this is A pre-cut draft of the curated release page for **17.6.0**, `content/docs/releases/v17/17-6.mdx`, plus its entry in `content/docs/releases/v17/meta.json` and in `scripts/docs-audit/handwritten-docs.json`. It also appends one dated correction to `content/docs/releases/v17/17-5.mdx` (details below). Docs-only. Everything is under `content/docs/releases/` (release-owned) or the docs-audit list, so this is the dedicated docs-only PR `AGENTS.md` sanctions for that tree. It publishes nothing from any package, hence `skip-changeset`. Clause-②: no It follows the 17.5.0 page's structure: 1. Highlights 2. What's new, including the "read this before treating the version number as a safety guarantee" list of silent runtime changes 3. Breaking changes & migration (triaged, not exhaustive) 4. New capabilities 5. Notable fixes 6. New in Console (Studio) 7. Shipped in 17.5.0, listed again in 17.6.0's CHANGELOG 8. Upgrade checklist (every line marked *not exercised*) ## How it was compiled - **Source:** all 338 changesets pending on `main` at `748b2407`. Every changeset was read and triaged into breaking, feature, fix or internal. PR numbers and short SHAs are taken from the commit that added each changeset. - **Already shipped in 17.5.0:** 16 of the 338 changesets (from 15 commits) describe code that 17.5.0 already published. I checked each commit with `git merge-base --is-ancestor` against the version commit `8c87d26a` and the publish commit `0f6dcac5`. - Eight follow the version commit in first-parent order. The 17.5.0 page already covers them. - Seven are ancestors of the version commit but were not in the version PR when it merged. The 17.5.0 page does not mention them. Two of them are breaking (`e73ee2d` objectstack-ai#20567, `c876a74` objectstack-ai#20504), and `7a1faf1` (objectstack-ai#20579) makes `os validate --strict` fail on a live conversion. - The new section matches the list in `.changeset/20622-release-aftercare-upgrade-and-unannounced-notes.md`. - **Cross-check:** each of the nine ADR-0087 conversions added since 17.5.0 is covered by a migration entry on the page: - `action-block-endpoint-to-target` - `connector-sync-keys-removed` - `connector-triggers-removed` - `cube-refresh-key-removed` - `dataset-count-measure-empty-field-removed` - `form-field-public-picker-removed` - `form-view-subform-columns-canonicalized` - `page-header-breadcrumb-removed` - `time-default-utc-suffix-dropped` - **Console section:** built from the three pin-bump changesets (`dd3f7e1be356 → db11afd4967c → e420df310f5b → 31971ff1e28f`), which carry 232 releasing objectui changesets, 23 of them declared breaking. The range was also read with `scripts/objectui-range.mjs`. - **Fact-check pass:** a second pass checked every cited SHA's changeset against the page, in three slices. It found 31 claims that were wrong or overstated, and the three follow-up commits correct them. Some examples: - An RLS `contains` verdict that `d2bc644` (objectstack-ai#21253) later reversed in this same release. - The `manage_platform_settings` scope: reads of both types, but writes of `datasource` only. - The dataset door's status after `434c6c7` (objectstack-ai#21240). - Filter positions that differ per change. - `7a1faf1`, which is not a breaking change. - **Other corrections made while compiling:** - The connector migration table does not claim that a `job` schedules a `connectorSource` pull. `0efbdc3` says nothing schedules a pull until the `job` stage lands, and none landed in this release. - The public-form picker search-key change (`bafb8c9`, objectstack-ai#21136) is noted as moot, because the route it changed is retired later in the same release (`3dc33b2`, objectstack-ai#21222). ## Open items for the cut These are recorded in the page's RELEASE-TIME TODO comment. - **Anonymous endpoints.** Under the deny baseline (objectstack-ai#21217), an app-declared anonymous endpoint (`authRequired: false`) can no longer read or write objects. objectstack-ai#21158 is still open, and until it lands no supported channel grants anonymous callers a permission set. The page says so instead of prescribing a grant. - **Console pin.** Three 17.6.0 server changes refuse a body that the pinned Studio (`31971ff1e28f`) still sends. objectui fixed each one after the pin: | Studio action | Server change that refuses it | objectui fix (not in the pin) | |:--|:--|:--| | A dataset count measure saved with the Field box blank (`field: ''`) | objectstack-ai#21240 | `0858267e` | | An External / Validate-only datasource saved without a credential | objectstack-ai#21133 | `8001068b` | | Republishing an html page that gained a plugin component | objectstack-ai#20852 | `3ae91930` | The page lists these under "Known console issues". If the pin moves before the cut, the lines it fixes come out. Otherwise the accepted-for-GA waiver is recorded. ## The correction to 17.5.0 One dated correction line is appended in place to the "Also shipped in 17.5.0" paragraph of `17-5.mdx`, in the form objectstack-ai#20985 used. It names the seven commits that shipped in 17.5.0 with no CHANGELOG entry and links to the 17.6.0 section. The original text is unchanged. ## Deliberately not in this PR - `content/docs/releases/v17/index.mdx` is **untouched**. Its status blockquote and per-release list may only claim 17.6.0 after the release ships. - An MDX comment at the top of `17-6.mdx` lists the release-time edits: 1. the publish date; 2. changesets landed after `748b2407`; 3. the per-package CHANGELOG entry count; 4. the console-pin outcome; 5. objectstack-ai#21158 if it lands first; 6. the `v17/index.mdx` update. ## Verification Run locally on the head commit, with the workspace installed. All of these pass: - `check-issue-citations --base origin/main`: every added citation resolves. - `check:doc-anchors`, `check:role-word`, `check:docs-audit-scope`, `check:nul-bytes`, `check:doc-authoring`, `check:docs-single-h1`, `check:docs-redirects`, `check:docs-locale-catch-all` - `check:corpus-claim-drift`, `check:docs-spec-enumerations`, `check:published-readme-links` - `check:release-notes`, `check:release-page-status`, `check:release-index-currency-sync`, `check:release-body` - `check-release-section-coverage` (plain and `--strict`), `check-doc-frontmatter`, `check-docs-nav-label`, `check-docs-section-name`, `check-section-landing-index`, `check-doc-route-spelling --advisory` Both pages compile as MDX with `@mdx-js/mdx` 3 + `remark-gfm`, and the three tables parse with no ragged rows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21079
Clause-②: yes (narrowing)
Ruling E (⚠️ Disclosure discipline applies (security family). This body states classes, positions, codes and statuses only. Every reading is private, in the dev's scratch space.
5933054144, maintainer 「同意E」) as written: this card lands the deny baseline alone. The anonymous public-form picker is retired on its own card, #21180. Execution parameters are seat 1's release5934091842, and the claim is5934758335. The claim revision5937041708records the seat's route B and adds two surfaces: the two out-of-surface picker pins and the in-place delegator-arm fix. Patch round 1 implements both.What this changes
For a non-system caller that carries a principal (a position, a named permission set or a user id) and resolves no permission set, an empty set list is now the ADR-0056 D2 deny baseline. The answer is the same at every layer, and no layer has a door-specific copy of it:
PERMISSION_DENIED/ 403 any ungranted caller gets.canReadObject,canExportandcanWriteObjectanswerfalsefor it.getReadFilteranswers the deny sentinel for it, as its failure paths already did.isPrincipalLessContextis the one exception an empty list has. The middleware hands a principal-less context through, as before, and the three probes admit only that context.getReadFilternow reads the same predicate instead of an inline copy. ADR-0096 stages that context separately, and it is unchanged.ISecurityServicedocblocks inpackages/spec/src/contracts/security-service.tsnarrow to match:canReadObject's arm list and its Fails CLOSED paragraph,canExport's Fails CLOSED paragraph,getMetadataReadableFields's "why the two differ" paragraph, and a deny-baseline paragraph ongetReadFilter. No key, method, parameter, return type or export changes.Positions that stay as they are (phase 0's N3, re-derived on this tree):
'org'for an unmatched list, which widens.checkAuthoredRowWritestill answers'abstain'for the empty list.computeLayeredRlsFilteris unchanged.maskingRule's describe and the result masker mask it — which one a public door serves is not measured #20995 / security(spec): a capability-gated field (requiredPermissions, no maskingRule) is served stored to a caller who resolves no permission set, while explain reports it hidden; narrowing it moves getReadableFields' contract answer #21063) is unchanged. The comments that described the old admission are corrected.A bounded in-place fix in the same class (declared here, as the four conditions require). Three probes skipped the D10 delegator CRUD-grant arm when the delegator resolved no set:
canReadObjectarm 6,canWriteObjectarm 8, and thecanExportintersection. The middleware asks that arm over the delegator's empty list and refuses. Measured privately before the change: on an embedder with no baseline, an agent acting on behalf of a delegator who resolves no set was refused by the middleware (PERMISSION_DENIED/ 403) while all three probes answeredtrue. The fix drops the length guard on those three arms, so they read the same evaluator answer. The capability arms' delegator guard is left as it was, because the grant arm dominates it and no pin can observe it. The four conditions:The claim revision
5937041708adds these three arms to the claim's file surface.Measured (private; classes and positions only)
Measured on base
0b12b9eaand after the change. The engine-level probe drives the registered middleware and the three probes, and asksgetReadFilter, for each member:true; no row scopePERMISSION_DENIED; probesfalse; deny sentineltrue; no row scopefalse; deny sentineltrue; no row scopefalse; deny sentineltrue; no row scopefalse; deny sentineltruefalseDoor-level consequences (N4). These are measured on real
bootStackboots, with plugin-security built at each leg and the build checked byablation-dist-preflight:PERMISSION_DENIEDon a deployment with no matching set. It still passes the authentication gate, so it answers 403, not 401.PERMISSION_DENIEDon a deployment without the set it names.PERMISSION_DENIED.pnpm dev -- --freshboot was NOT MEASURED this round; phase 0 measured 24 anonymous doors on one.Pins
New:
zero-set-deny-baseline.test.ts(18 cases). It covers each measured member (the guest envelope, a picker-shaped context, and a signed-in user on a baseline-disabled embedder). For each member it pins the premise (zero sets), that all seven engine operations are refused at object admission before they run (codeandstatus), that the three probes answerfalse, and that the row scope is the deny sentinel. The controls are a set-holding member (admitted to its granted object, refused the other) and a registered named set. The principal-less context is pinned as the boundary. The second-principal case is pinned with a set-holding delegator as its control. No pin title states a request.Moved: 21
plugin-securitycases in 5 files and 4 dogfood cases in 2 files, the same set phase 0 named and re-derived on this tree. Under route B there are also 5 picker cases in 2 more files (see the next section). Each now asserts the new answer's substance:zero-set-masking.test.ts(9) andzero-set-capability-fold.test.ts(9). The field projections still exclude masked and capability-gated fields. Every query, read and write is now refused at object admission (PERMISSION_DENIED/ 403), and the read's row scope is the deny sentinel. In the fold suite, explain agrees:object_cruddenies,allowedis false, andflsstill reports the field hidden.canWriteObjectstill equals the middleware, field for field. The masked-echo case was refused 400 at the echo gate and is now refused 403 at admission, ahead of it. No refusal assertion was weakened.can-write-object-admission.test.ts(1). The org-bound zero-set twin is now denied by arm 4. The pin adds the middleware's own refusal (codeandstatus).get-writable-fields.test.ts(1). The field answer is unchanged. Every write the case names is refused at admission (codeandstatus).predicate-related-read-tenant-scope.test.ts(1). The write door's refusal is unchanged. The preview is now refused at its write gate, becausecanWriteObjectanswers the public-form grant contextfalse(the grant is not one of its arms). The pin asserts that probe answer directly and keeps the "identical whatever the value" and "related columns never read" assertions.declarative-endpoint-policy(2). The explicitauthRequired: falseendpoint passes authentication, and its object read is refused 403PERMISSION_DENIED. The declared cache directive is not on that refusal. The success side of that directive stays pinned in the runtime units and the showcase file.zero-set-masking(2). The picker door and the baseline-disabled record door are refused at admission. The record door is checked on the read and on a query that names an unmasked field.The picker-door case is shared with #21180. That card deletes the picker door and this dogfood case. Measured: an untouched picker case turns red under this change, so I re-pinned it to the deny baseline. I did not restructure it, and its fixture is untouched. The ruling gives this case to whichever card lands second. Under route B this PR is expected to land first, and #21180 then takes its own deletion of the case.
Route B: the two picker pins from #21062, re-pinned
PR #21136 (#21062) landed after phase 0's measurement. It added two picker pins, and both assume the picker serves a visitor with no session on a deployment with no matching set. Ruling E fixes no landing order between this card and #21180, which retires the picker. The seat ruled route B in claim revision
5937041708: this card re-pins both files and lands on its own clock. The reasons it gives:publicPicker— anonymous public forms stop taking lookup / master_detail / user fields, and the anonymous picker route goes (ruling E on #21079) #21180 has no PR yet, so waiting for it has no stated end.Each fixture's intent is kept, and no case is deleted. #21180 deletes both files when it lands.
packages/rest/src/public-form-lookup-picker-queryable-key.test.ts, 4 cases. The no-guest-set class moves out of the shared loop into its owndescribe.403withcode: 'PERMISSION_DENIED', no row, and no stored value in the body.codeandstatusas the engine's own refusal, and the engine is never asked. The pin also asks the engine for a query keyed on a field the caller MAY query on, which only object admission refuses. Without that line the case stayed green with the deny baseline put back; that was the first ablation run.packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts, 1 case. For a visitor with no session and for a signed-in caller, the listing and the search answer403PERMISSION_DENIED, with nothing served and no stored value in the body. The masked-only picker answers the same.Ablations (one leg per negative pin)
Each leg used
scripts/ablation-replace.mjsin wrap mode, and its owntrap … EXIT INT TERMrestored the file fromHEADon an absolute path. Each leg landed (anchor count 1 to 0, blob changed). The final state was bloba4a95715equal toHEAD, with an emptygit diff HEADand a cleangit status. Every leg ran on theplugin-securitytest files as they stand at3e49b1af; this round does not change them:permissionSets.length > 0 &&restoredcanReadObjectarm 2 answerstruecanWriteObjectarm 4 returns through the organization wallcanExportarm answerstruegetReadFilterzero-set line deletedcanReadObject,canWriteObjectandcanExport(one leg each)The first attempt at the
canExportdelegator leg was a no-op: the replacement contained the anchor, and the tool refused it with exit 1 and nothing run. It was re-run with a non-overlapping anchor, and that run is the one in the table.The picker leg (patch round 1).
scripts/ablation-replace.mjsput back the base's step 2 guard. That guard is the only arm the picker door reads; the door asksgetQueryableFields(unchanged) and then the engine. The wrapped child rebuiltplugin-security, and the preflight found the injected guard in 2 built files. Results:public-form-lookup-picker-queryable-key.test.ts: 4 of 19 red, exactly the 4 re-pinned cases.public-picker-queryable-key.dogfood.test.ts: 1 of 1 red.The restore was proven: blob
a4a95715equalsHEAD,git diff HEADis empty, and after the rebuild the--absentpreflight read the marker absent from all 6 built files and the tree clean. Both files then ran green again: 19/19 and 1/1.The dist-level leg put base
security-plugin.tsback (blob970439f6, marker 3 to 0 in src), rebuilt, and ran the dogfood set. The 4 re-pinned dogfood cases were red, and the stock showcase files stayed green. It then restored and rebuilt, and the preflight found the marker in 2 built files again.Gates and tests (at
03ae8604, afterorigin/main097ef802was merged as2e1564f5)dispatch-gates --commandsderived 109 families on this head; the change set now has 14 paths. All 109 exit 0, and--ranreconciled them with recorded exit codes: 109 run, 0 NOT-MEASURED.check:dual-build-cjs-loadsread the 8 packages it needs after they were built.typecheckexits 0.localproject: 256 files, 4844 passed, 310 skipped.typecheckexits 0, andtsconfig.test.jsonlists the re-pinned file.typecheckexits 0. The dogfood set from round 1 (30 files) is 314 passed and 0 failed, and that now includes the picker file.check:generatedreported all 15 artifacts up to date.eslint.config.mjs(files: **/*.{ts,…}minusNEVER_LINTED). All 12 changed.tsfiles are in it, and--format jsoncounts 12 files, 0 errors and 0 warnings.pnpm lintis CI's.Docs (patch round 1)
The drift check (
5936999532) listed 8 hand-written pages. I read all 8, then greppedcontent/docs/**(outsidereleases/and the generatedreferences/) andskills/**for sentences that say a caller with a principal and no resolved set is admitted, has no row scope, or falls through to sharing alone, or that say what the anonymous picker answers. Three passages incontent/docs/ui/forms.mdxwere made false by this PR, and they are rewritten. Only sentences about what the door answers changed; the text about the door's existence is #21180's.403 PERMISSION_DENIEDunder the deny baseline. Any read granted there is readable by every anonymous visitor through the picker.403 PERMISSION_DENIEDrow: no guest set grants read on the target object, so the search context resolves no set and is refused at object admission. The declaration bounds what is searched; it never grants the read.No other sentence was false. The other 7 drift-check pages either name the probes in the system-context bypass table (unchanged) or say nothing about a set-less caller.
skills/objectstack-apidescribesauthRequired: falseas an authentication opt-out only, which is still true. Release-owned pages were read but not edited; none of the 4 the drift check names states the old zero-set admission.Acceptance notes (observations, not filed)
not-required (no-migration-prescription), and the gate accepts it. The ruling's migration text is a grant decision, not a rewrite of anything an author wrote, soobjectstack migrate metahas nothing to rewrite. Phase 0 expected this category to be wrong under route D. This is the same reading as the security (P0 suspect): a non-system caller who resolves no permission set — an unauthenticated one included — is admitted to aggregate any object at an analytics door, object admission and row scope skipped #21061 and security(spec): a capability-gated field (requiredPermissions, no maskingRule) is served stored to a caller who resolves no permission set, while explain reports it hidden; narrowing it moves getReadableFields' contract answer #21063 changesets.packages/rest/src/rest-server.ts(the export and explain fail-stance docblock). It still says the middleware skips its CRUD gate for an empty resolution. Carrier: spec(forms): retirepublicPicker— anonymous public forms stop taking lookup / master_detail / user fields, and the anonymous picker route goes (ruling E on #21079) #21180, which edits that file. It is a comment only.Generated by Claude Code