Skip to content

metadata read: for a platform-admin caller the object read and the sortability projection keep a field that caller's field-level permission marks unreadable, while the data route refuses it #22250

Closed

Description

@objectstack-fleet

Filing gate ①: a measured inconsistency, class (b): two server answers disagree for one caller. It is not a value leak. Measured by an objectui dev on objectstack-ai/objectui#11925 against a booted, published @objectstack/* 17.7.0 server, over REST. Kept abstract, per the triage posture on that card: classes, positions and functions only.

Who acts on it: objectstack triage, grade and route (the metadata read and the security service). ⛔ Not a claim. Filed by the objectui domain:ui execution seat 1 (session_01DrKzdPdyLLBW3qpZ4vtk7z).

What happens

For a platform-admin caller whose field-level permission marks one field of an object unreadable (through a permission set bound to everyone):

  • The permission read (/auth/me/permissions) answers that field as readable: false.
  • The data read leaves the field out of rows, and a filter or sort on it is refused with 403 by the filter-oracle guard. That is right, and it is why no value leaks.
  • The object metadata read (/meta/object/<name>) still lists the field in fields, and the sortability projection marks it sortable: true.

For a non-admin caller with the same permission set, the metadata read hides the field, as #3661's masking intends. So the masking is skipped for the platform-admin class while that caller's own field permission and data route deny the field.

Consequence

A client that builds pickers from the metadata read offers the field to that caller, and choosing it hits the 403. objectui#11925 (Filter panel) and a sibling objectui card (Sort picker) close it client-side with the permission read. The server's metadata, sortability and data answers should still agree for one caller.

Done when

  • For a caller whose field permission marks a field unreadable, the metadata read and the sortability projection agree with the data route: the field is masked from fields and not offered as sortable, whatever the caller's class. Alternatively, if a platform admin is meant to see such definitions, the permission read and data route say so too and the 403 does not fire.
  • Pinned per caller class.

Duplicate check

Dedupe words: meta object FLS mask platform admin · sortability unreadable field sortable true · metadata field level security admin bypass


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · priority:p3 · domain:engine · area:access · pm:queue. Direction: make the server's answers agree by tightening; ⛔ never by loosening the data route

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-08T09:59Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in the metadata read's field masking (#3661's path) and the sortability projection ⇒ domain:engine; rationale: the metadata read is that lane's. If the bypass lives in the security service, the seat names it and the domain:services seat reviews.

    • Why p3: no value leaks. The data route's guard holds, and the client half closes on objectui#11925 and objectui#11943.
    • Measure first why the masking skips the platform-admin class. Find the exemption and its stated reason.
      • If it exists so authoring surfaces (Studio, Setup) see full definitions, keep the definition read for authoring. Make the runtime projections (sortability, and any picker-facing field list) agree with the caller's field permission.
      • If it has no reason, mask for every class.
    • ⛔ The card's other alternative, making the permission read and the data route admit, loosens a field-level boundary. That is the maintainer's, not this lane's or triage's.
    • Pins: per caller class, as the card says. Control: a non-admin is unchanged.
  2. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 64 · 2026-10-08T11:35Z
    Session: session_01EUBvqtauTDmHi2ZgY759p2
    Account: os-litant (the seat's linked user, as GET /user answers it; always the card's assignee)
    Branch: claude/issue-22250-meta-fls-mask-admin
    Worktree: objectstack-issue-22250
    Domain: domain:engine
    Seat: domain:engine#1
    Provenance:

  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22250,
    "status": "done",
    "branch": "claude/issue-22250-meta-fls-mask-admin",
    "pr": "#22308",
    "session": "session_01EUBvqtauTDmHi2ZgY759p2 (mode:subagent, the PM's id; identity = the branch)",
    "premise_still_valid": true,
    "summary": "Measured first: the exemption is ADR-0106 D4 (isObjectSchemaMaskExempt in metadata-core object-schema-fls.ts: isSystem, manage_metadata, studio.access, setup.access). Its stated reason is 'Studio/Setup authoring requires the full schema', and the code adds that a projected read PUT back deletes the withheld fields. That is the ruling's first branch: the authoring definition read is kept. M2 was partly falsified: the sortability projection (rest translateMetaEnvelope, used by both transports) already read the post-mask document, but for an exempt caller that document is unmasked, so it inherited the exemption. Fix: the exempt posture of a capability-exempt caller carries a lazy runtime question that asks the same getMetadataReadableFields the mask asks a non-exempt caller. It is asked at most once and never rejects; a throw or undefined answer settles undetermined with one warn, so D4's sick-service invariant holds. resolveObjectSchemaRuntimeView derives the view sortability is computed from, on both item-read transports and the cached arm. The cached arm folds a qualified fingerprint of the withheld fields into the ETag and judges the conditional itself. Result on a real app-showcase boot: platform admin and organization admin keep the full item.fields, and sortability no longer names the unreadable field (byte-equal to the member's). Member unchanged. Permission read and data route unchanged. Unrestricted admin ETag byte-identical. sortability is the only server-side picker-facing projection beside the definition; item.fields IS the authoring definition the ruling keeps, and objectui handles its pickers against the permission read (objectui#11925 / objectui#11943).",
    "door_table": {
    "setup": "examples/app-showcase, pnpm dev -- --fresh -p 38617. showcase_client_liaison (readable:false on showcase_project.budget/spent/budget_remaining) bound to the everyone position. Callers: seeded platform admin; phone.demo promoted to org role admin (systemPermissions setup.access); auditor.demo as member. base c8bb3c8, head 8b412a8 (source change; later commits are tests, changeset, merge of main, a census-neutral refactor and a test type fix)",
    "permission_read /auth/me/permissions budget": {
    "platform_admin": "readable:false → readable:false",
    "organization_admin": "readable:false → readable:false",
    "member": "readable:false → readable:false"
    },
    "data_read /data/showcase_project": {
    "platform_admin": "budget absent → absent",
    "organization_admin": "absent → absent",
    "member": "absent → absent"
    },
    "data_route sort=budget and budget=100": {
    "platform_admin": "403 PERMISSION_DENIED → same",
    "organization_admin": "403 → same",
    "member": "403 → same"
    },
    "metadata_read item.fields.budget": {
    "platform_admin": "present (22) → present (22)",
    "organization_admin": "present (22) → present (22)",
    "member": "absent (19) → absent (19)"
    },
    "sortability_projection fields.budget": {
    "platform_admin": "{sortable:true} (23 entries) → absent (20)",
    "organization_admin": "{sortable:true} (23) → absent (20)",
    "member": "absent (20) → absent (20)"
    },
    "metadata_read ETag": {
    "platform_admin": "39ce56dc → 39ce56dc3c4b8092",
    "organization_admin": "39ce56dc → 39ce56dc
    3c4b8092",
    "member": "39ce56dc~32c59ec2 → same"
    },
    "authoring layered view /meta/object/showcase_project/layers effective": {
    "platform_admin": "22 with budget → same",
    "organization_admin": "22 with budget → same",
    "member": "19 without → same"
    },
    "head conditional reads": "admin own ETag → 304; pre-change ETag 39ce56dc → 200; member sending admin ETag → 200; after unbinding, unrestricted admin ETag 39ce56dc (byte-identical) and budget {sortable:true}"
    },
    "tests": "Head 3768ffa. Every run went through os-verify-lock and its VERDICT line was read. metadata-core: 18 files / 425 passed, typecheck exit 0 (805f1ba tree; package unchanged since). rest at 3768ffa: 268 files / 5142 passed / 327 skipped, typecheck exit 0 (test-typecheck-debt 0 files). runtime: 340 files / 5508 passed / 19 skipped at 805f1ba; at 3768ffa (a type-only fix to the new test) typecheck exit 0 and the new file 8/8 (the first full run's typecheck was red on 2 TS18048 in the new runtime test, fixed in 3768ffa). New pins: metadata-core object-schema-fls.test.ts per class, plus laziness, D4 sick-service, D3 byte-identity and validator separation; rest meta-object-sortability-caller-fls.test.ts per class on cached and uncached branches, plus ETag/304; runtime meta-object-sortability-caller-fls.test.ts per class on protocol-backed and registry-backed reads. Ablation A (via scripts/ablation-replace.mjs, from committed state): translateMetaEnvelope derives sortability from the served document; rest 5 failed / 8 passed. rest build, then ablation-dist-preflight finds the marker in dist; runtime 4 failed / 4 passed. Restore: blob == HEAD, git diff HEAD empty; rebuild; preflight --absent with a clean tree; 13/13 and 8/8. Ablation B: the exempt posture never carries runtime; metadata-core 7 failed / 29 passed, assertion failures only; restored blob == HEAD. The first B attempt used an undefined identifier and went red on a ReferenceError, so its reading is void; the second was refused by the tool (the replacement contained the anchor, nothing mutated). Lint narrowed: eslint --no-inline-config --format json over the diff's 7 lintable files gives 7 files / 0 errors / 0 warnings. Population: eslint.config.mjs files glob /*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus NEVER_LINTED. Invariance: the config enables no type-aware linting (no parserOptions.project, stated in the config), so the diff cannot move a verdict on an untouched file. Repo-wide pnpm lint is left to CI.",
    "gates": "dispatch-gates --commands at 3768ffa derives 64 commands (8 paths vs merge base 5870716); all 64 ran, exit 0. --ran reconciliation: 64 derived / 64 run / 0 NOT-MEASURED / 0 UNRUN, a derived zero from recorded exit codes. Pass history: at 9865012, 63/64 with check-system-context-census red (a new isSystem elevation read in resolveObjectSchemaMaskPosture). 805f1ba replaced that read with the exemption's capability half: census green, then 64/64 at 805f1ba and again at 3768ffa. CI is not awaited; PR checks are in_progress at report time.",
    "line_budget": "n/a: no skills/
    or governed surface touched. Diff vs origin/main: 8 files, +728 / -30 (source +262 / -29, tests +450 / -1, changeset +16).",
    "files_changed": [
    "packages/metadata-core/src/object-schema-fls.ts (ObjectSchemaMaskPosture passthrough gains optional runtime; ObjectSchemaRuntimeFieldPosture; resolveObjectSchemaMaskPosture exempt branch; private runtimeFieldPostureAsker and holdsObjectSchemaMaskExemptCapability; isObjectSchemaMaskExempt delegates its capability half; new resolveObjectSchemaRuntimeView / ObjectSchemaRuntimeView; module docblock)",
    "packages/rest/src/meta-item-read-gate.ts (translateMetaEnvelope optional runtimeDocument; MetaItemAnswerSources.translateEnvelope optional 3rd param; createMetaItemAnswer step 5)",
    "packages/rest/src/rest-server.ts (private translateMetaEnvelope and metaItemAnswerSources forward the view; GET /meta/:type/:name cached arm: maskApplies includes a runtime-carrying posture, runtime view, ETag fold)",
    "packages/runtime/src/domains/meta.ts (answerMetaItem translateEnvelope port forwards runtimeDocument only)",
    "packages/metadata-core/src/object-schema-fls.test.ts",
    "packages/rest/src/meta-object-sortability-caller-fls.test.ts (new)",
    "packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts (new)",
    ".changeset/22250-meta-fls-runtime-sortability.md (metadata-core minor, rest minor, runtime patch)"
    ],
    "deviations": [
    "Clause-② measured yes (widening), not the claim's no. Built entry declarations: metadata-core gains resolveObjectSchemaRuntimeView, ObjectSchemaRuntimeView, ObjectSchemaRuntimeFieldPosture and an optional runtime member on ObjectSchemaMaskPosture; rest's translateMetaEnvelope and MetaItemAnswerSources.translateEnvelope gain optional params. So the PR body's second line is the measured one, and the changeset grades metadata-core and rest minor (dispatch said patch). Raised in open_questions.",
    "The real-boot door table was measured at 8b412a8, the source change before tests, changeset, merge of main (#22265 region-disjoint), the census-neutral refactor 805f1ba and the test type fix. The final head is covered by the unit and transport suites, not by a second boot.",
    "Ablation B took three attempts: the first was a void ReferenceError red, the second was refused by the tool with nothing mutated, the third is the reading cited.",
    "One metadata-core run was spelled 'pnpm test -- --maxWorkers=2'; vitest dropped the flag and ran the whole package with default workers (the full suite was the intent).",
    "Labels: none written. The dispatch named none and the changeset is present, so skip-changeset does not apply. The label-write carried only the PR assignee; size/l, tests, tooling and documentation came from labelers.",
    "main moved after the merge at 9865012 (#22295, #22266, #22285 and others); no overlap with this diff's files, so it was not re-merged."
    ],
    "mcp_calls": "0",
    "api_writes": "3 REST writes, all through the fleet-write relay as objectstack-fleet[bot] (each is one POST /repos/objectstack-ai/objectstack/dispatches executed by the relay run): (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls, draft #22308, body read back 12313/12313 bytes identical; (2) label-write --assign os-litant, POST /repos//issues/22308/assignees, read back matches; (3) this os-dev-report, POST /repos//issues/22250/comments. git push is not counted. Reads were REST GETs (issue, comments, PR) via gh api.",
    "open_questions": [
    {
    "question": "Clause-②: the claim says no, but the built entry declarations of @objectstack/metadata-core and @objectstack/rest widen (new exports and optional members/params; nothing narrowed). Which line governs the queue gate?",
    "options": [
    "A: keep the measured 'Clause-②: yes (widening)' in the PR body with the minor changeset, and the seat corrects the claim's line",
    "B: keep the claim's 'no' and narrow the change so nothing new is exported (move the runtime-view logic into rest as a non-exported helper and leave ObjectSchemaMaskPosture's declaration untouched, which means transports resolve the exempt caller's field permission themselves = two copies of the posture logic)"
    ],
    "recommendation": "A. The shared normaliser lives in metadata-core so the two transports cannot drift (the module's own stated criterion), and contract-first favours one declared helper over two transport-local copies. The widening is additive and optional and narrows nothing. The card still owes its contract-review-tier review, which can grade it."
    },
    {
    "question": "The spec's ObjectSortabilitySchema.fields description (and objectui's column-sortability.ts docblock) says the projection's domain 'is the served field map plus the always-provisioned id'. For a D4-exempt caller whose permission withholds a field, the domain is now narrower than the served item.fields. The consumer contract (absence = no platform sort behind this name) still holds. Amend the spec text?",
    "options": [
    "A: spec lane amends the describe() and the sortability.zod.ts category list to name 'a field the caller's field permission withholds (served in item to a D4-exempt caller)' as an absence case, with gen:schema/gen:docs",
    "B: leave the text; the consumer contract already covers it"
    ],
    "recommendation": "A, as a separate spec-lane card. This dispatch's stop condition forbids packages/spec here, and a producer sentence that is false for one caller class is the drift the projection exists to prevent. Seat to route; reach: the published describe text served in the generated reference docs and JSON schema."
    }
    ],
    "out_of_scope_findings": [
    "carrier: whoever next touches resolveObjectSortability or the query guard. Noted in Acceptance notes, not filed. For any caller, sortability keys on readability, while plugin-security's query guard also refuses a predicate or sort on a field served partially masked (maskingRule); getQueryableFields' docblock says such a field is readable but not queryable. Read from source, not measured; no in-repo app declares a maskingRule (showcase permission-sets.ts). Dedupe words: sortability maskingRule queryable, masked field sortable true, getQueryableFields sortability",
    "carrier: none (承接者:无). Noted in Acceptance notes, not filed. On an ADR-0015 external object, an author-declared platform-injected column the caller cannot read is re-added by unprovisionedInjectedColumns as sortable:true plus the unprovisioned-anchor caveat once the mask removes its declaration. Same for every class. Read from source, not measured. Dedupe words: unprovisionedInjectedColumns masked, external object injected column sortability, unreadable anchor caveat"
    ]
    }

  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22250,
    "round": "patch round 1",
    "status": "done",
    "branch": "claude/issue-22250-meta-fls-mask-admin",
    "pr": "#22308",
    "head_sha": "a337d0cc537ea09b8a2bee82f9b80270df735b1e",
    "session": "session_01EUBvqtauTDmHi2ZgY759p2 (mode:subagent, the PM's id; identity = the branch)",
    "premise_still_valid": true,
    "summary": "Round items. (1) Spec text in packages/spec/src/api/sortability.zod.ts, text only: the ObjectSortabilitySchema.fields describe() now reads 'the served field map plus the always-provisioned id, less any field the caller's own field permission withholds', naming the D4-exempt case and adding 'a field the caller may not read' to the absent list. The module docblock's category list gains case 5 (withheld by the caller's field permission, ADR-0106 D1/D4). Case 1's domain sentence and resolveObjectSortability's docblock carry the same exception, and the claims stay true for every other caller. Generated: content/docs/references/api/{sortability,protocol}.mdx via check:generated --fix (only check:docs was stale; a rerun reported 15/15 current). No @objectstack/spec changeset entry: no gate requires one (Check Changeset counts changesets; check-changeset-no-major's level axis is satisfied by metadata-core/rest minor and names no ungraded spec package; check-empty-changeset green). objectui's column-sortability.ts docblock goes to Acceptance notes with the named carrier. (2) Door table re-measured on the final head a337d0c (real app-showcase boot): every cell matches 8b412a8, so there was no stop. (3) git merge-tree --write-tree HEAD origin/main (dc4a5c6) exits 0 with zero file overlap, so no merge. (4) All 111 derived gates exit 0; --ran reconciles 111/111/0/0. Spec test and typecheck green; narrowed eslint green; metadata-core/rest/runtime files were not touched this round, so their suites were not rerun. (5) Pushed. pr_body_full.body is below for the seat to write; this report made no PR body write.",
    "door_table_remeasured": {
    "measured_on": "a337d0cc53 (final head), examples/app-showcase, pnpm dev -- --fresh -p 38617, same setup as before (showcase_client_liaison bound to everyone; phone.demo promoted to org admin; auditor.demo member)",
    "platform_admin": "item.fields.budget present (22); sortability.fields.budget absent (20 entries); ETag 39ce56dc3c4b8092; permission read readable:false; data rows without budget; sort and filter on budget 403. Same as 8b412a8",
    "organization_admin": "item.fields.budget present (22); sortability.fields.budget absent (20); ETag 39ce56dc
    3c4b8092; 403 on sort and filter. Same as 8b412a8",
    "member": "item.fields.budget absent (19); sortability.fields.budget absent (20); ETag 39ce56dc~32c59ec2; 403 on sort and filter. Same as 8b412a8 and base",
    "conditional_rows": "admin own ETag → 304; org admin own ETag → 304 (new row, first read here); admin with pre-change ETag 39ce56dc → 200; member sending admin ETag → 200. Same as 8b412a8",
    "authoring_layers_effective": "admin 22 with budget; org admin 22 with budget; member 19 without. Same",
    "sortability_equality": "admin === member and org admin === member (byte-equal JSON)",
    "unrestricted_control": "after unbinding: admin ETag 39ce56dc (byte-identical to base), budget {sortable:true}, 23 entries",
    "cells_differing_from_8b412a83e2": "none"
    },
    "files_changed_this_round": [
    "packages/spec/src/api/sortability.zod.ts (CROSS-LANE domain:spec; text only: module docblock case 5 and case 1 domain sentence, ObjectSortabilitySchema.fields describe(), resolveObjectSortability docblock)",
    "content/docs/references/api/sortability.mdx (CROSS-LANE domain:spec; GENERATED by check:generated --fix)",
    "content/docs/references/api/protocol.mdx (CROSS-LANE domain:spec; GENERATED by check:generated --fix)"
    ],
    "files_changed_total": "11 files vs origin/main, +771 / -42: the 8 from the first round plus the 3 above",
    "commits_this_round": [
    "d0528df721 docs(spec): sortability absence case text",
    "a337d0cc53 docs(references): regenerated"
    ],
    "tests": "At a337d0c, through os-verify-lock. pnpm --filter @objectstack/spec test: 625 files, 18711 passed, 1 todo. pnpm --filter @objectstack/spec typecheck: exit 0 (tsc, check:scripts-typecheck, check:test-typecheck OK). check:generated --fix then check:generated: 15/15 current. Narrowed eslint --no-inline-config --format json on sortability.zod.ts: 1 file, 0 errors, 0 warnings; print-config shows 5 enabled syntax rules and parserOptions.project null (no type-aware linting), so untouched files' verdicts cannot move. metadata-core/rest/runtime suites not rerun (no file of theirs touched this round); their results stand from the first round.",
    "gates": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
    "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
    "node scripts/check-ci-filter-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-corpus.mjs :: exit 0",
    "node scripts/check-dev-prereqs.mjs --self-test :: exit 0",
    "node scripts/check-doc-frontmatter.mjs :: exit 0",
    "node scripts/check-doc-frontmatter.mjs --self-test :: exit 0",
    "node scripts/check-doc-route-spelling.mjs --advisory :: exit 0",
    "node scripts/check-doc-route-spelling.mjs --self-test :: exit 0",
    "node scripts/check-docs-section-name.mjs :: exit 0",
    "node scripts/check-docs-section-name.mjs --self-test :: exit 0",
    "node scripts/check-dts-emitted.mjs --self-test :: exit 0",
    "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
    "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
    "node scripts/check-issue-citations.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
    "node scripts/check-registry-log-declared.mjs :: exit 0",
    "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
    "node scripts/check-section-landing-index.mjs :: exit 0",
    "node scripts/check-section-landing-index.mjs --self-test :: exit 0",
    "node scripts/check-spec-docblock-symbol-anchors.mjs :: exit 0",
    "node scripts/check-spec-docblock-symbol-anchors.mjs --self-test :: exit 0",
    "node scripts/check-system-context-census.mjs :: exit 0",
    "node scripts/check-system-context-census.mjs --self-test :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
    "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
    "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
    "node scripts/release-pending-publish.mjs --self-test :: exit 0",
    "pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0",
    "pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0",
    "pnpm --filter @objectstack/spec run check:api-surface :: exit 0",
    "pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0",
    "pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0",
    "pnpm --filter @objectstack/spec run check:docs :: exit 0",
    "pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0",
    "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
    "pnpm --filter @objectstack/spec run check:empty-state :: exit 0",
    "pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0",
    "pnpm --filter @objectstack/spec run check:export-origins :: exit 0",
    "pnpm --filter @objectstack/spec run check:exported-any :: exit 0",
    "pnpm --filter @objectstack/spec run check:generated :: exit 0",
    "pnpm --filter @objectstack/spec run check:liveness :: exit 0",
    "pnpm --filter @objectstack/spec run check:llms-txt :: exit 0",
    "pnpm --filter @objectstack/spec run check:objectui-pin-citations :: exit 0",
    "pnpm --filter @objectstack/spec run check:skill-examples :: exit 0",
    "pnpm --filter @objectstack/spec run check:skill-refs :: exit 0",
    "pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0",
    "pnpm --filter @objectstack/spec run check:variant-docs :: exit 0",
    "pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0",
    "pnpm check:authz-resolver :: exit 0",
    "pnpm check:changeset-gate-self-tests :: exit 0",
    "pnpm check:corpus-claim-drift :: exit 0",
    "pnpm check:cross-package-test-inputs :: exit 0",
    "pnpm check:dispatcher-error-vocabulary :: exit 0",
    "pnpm check:doc-anchors :: exit 0",
    "pnpm check:doc-authoring :: exit 0",
    "pnpm check:docs-audit-scope :: exit 0",
    "pnpm check:docs-redirects :: exit 0",
    "pnpm check:docs-single-h1 :: exit 0",
    "pnpm check:docs-spec-enumerations :: exit 0",
    "pnpm check:docs-transcript-drift :: exit 0",
    "pnpm check:driver-memory-census :: exit 0",
    "pnpm check:dts-closure :: exit 0",
    "pnpm check:dual-build-cjs-loads :: exit 0",
    "pnpm check:engine-double-contract :: exit 0",
    "pnpm check:gitlink-declared :: exit 0",
    "pnpm check:issue-citations :: exit 0",
    "pnpm check:lean-entry-closure :: exit 0",
    "pnpm check:logger-receiver-detach :: exit 0",
    "pnpm check:merge-driver :: exit 0",
    "pnpm check:nul-bytes :: exit 0",
    "pnpm check:objectql-double-limit :: exit 0",
    "pnpm check:objectui-changeset :: exit 0",
    "pnpm check:org-identifier :: exit 0",
    "pnpm check:page-declaration-shape :: exit 0",
    "pnpm check:pm-changeset-deadline-census :: exit 0",
    "pnpm check:pm-prior-rulings :: exit 0",
    "pnpm check:published-files :: exit 0",
    "pnpm check:published-readme-links :: exit 0",
    "pnpm check:query-options-erasure :: exit 0",
    "pnpm check:quick-reference-counts :: exit 0",
    "pnpm check:react-page-adapter-contract :: exit 0",
    "pnpm check:refd-timer-probe :: exit 0",
    "pnpm check:role-word :: exit 0",
    "pnpm check:route-envelope :: exit 0",
    "pnpm check:skill-identifier-liveness :: exit 0",
    "pnpm check:slot-lookup :: exit 0",
    "pnpm check:sourcemap-no-sources-content :: exit 0",
    "pnpm check:spec-parsed-alias :: exit 0",
    "pnpm check:test-source-alias :: exit 0",
    "pnpm check:tier-file-adoption :: exit 0",
    "pnpm check:type-check-coverage :: exit 0",
    "pnpm check:type-check-debt :: exit 0",
    "pnpm check:vendor-version-stamps :: exit 0",
    "pnpm check:watch-hint-literal :: exit 0",
    "pnpm check:where-matcher :: exit 0",
    "pnpm --filter @objectstack/spec test :: exit 0",
    "pnpm --filter @objectstack/spec typecheck :: exit 0"
    ],
    "gates_summary": "dispatch-gates --commands at a337d0c: 111 commands for the 11-path change set (merge base 5870716). All 111 exit 0. --ran reconciliation: 111 derived / 111 run / 0 NOT-MEASURED / 0 UNRUN, a derived zero from recorded exit codes. The derivation warned STALE TREE: scripts/platform-object-tenancy-census.json changed on main since the merge base, so check-platform-object-tenancy-census read this branch's copy; CI runs on the merge ref.",
    "line_budget": "n/a: no skills/** or governed surface touched",
    "deviations": [
    "No @objectstack/spec changeset entry, per the round's 'only if a gate requires one': none does (see summary).",
    "Beyond the two named spots, resolveObjectSortability's docblock in the same file was also amended (text only), because its 'the projection's domain equals what the caller can see' became inaccurate for a D4-exempt caller.",
    "Case 5 in the spec docblock cites #22250 beside ADR-0106 D1/D4, matching the neighbouring cases' convention; it renders into content/docs/references/api/sortability.mdx. check:doc-authoring and check-issue-citations are green.",
    "Not merged with main: merge-tree clean, zero overlap, per the round's instruction."
    ],
    "mcp_calls": "0",
    "api_writes": "1 REST write this round: this os-dev-report, POST /repos//issues/22250/comments through the fleet-write relay (one POST /repos/objectstack-ai/objectstack/dispatches). git push is not counted. No PR body write: the seat writes pr_body_full.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: objectui domain:ui seat 1 (objectui#11925's filer). Noted in Acceptance notes, not filed. objectui packages/core/src/utils/column-sortability.ts repeats the amended sentence ('the served field map plus the always-provisioned id'). Dedupe words: column-sortability docblock domain, served field map sortability, sortability absence field permission",
    "carrier: whoever next touches resolveObjectSortability or the query guard. Unchanged from the first round: partially masked (maskingRule) fields are readable but not queryable, yet listed sortable.",
    "carrier: none (承接者:无). Unchanged from the first round: unprovisionedInjectedColumns re-adds a masked author-declared injected column on an ADR-0015 external object."
    ],
    "pr_body_full": {
    "body": "Fixes #22250\nClause-②: yes (widening)\n\n## What this does\n\nFor one caller, the object metadata read and the data route disagreed about a field that caller's field-level permission marks unreadable. The sortability projection served beside the schema on GET /meta/object/NAME marked that field sortable: true for a platform-admin or organization-admin caller, while the data route refuses a sort or filter on it with 403 PERMISSION_DENIED.\n\nAfter this PR, the sortability projection is derived from the caller's own field permission for every caller class. The object definition itself (item.fields) is unchanged: whole for the exempt classes, because Studio and Setup authoring need it, and masked for everyone else. The data route and the permission read are unchanged. The spec's description of the projection is amended to match.\n\n## Measured first: the exemption and its stated reason\n\n- Where it is. isObjectSchemaMaskExempt in packages/metadata-core/src/object-schema-fls.ts (ADR-0106 D4). It exempts isSystem and any caller holding manage_metadata, studio.access or setup.access. The platform-admin set holds all three. An organization admin holds setup.access. resolveObjectSchemaMaskPosture answers such a caller passthrough / exempt before it asks the security service.\n- Its stated reason. ADR-0106 D4: "Studio/Setup authoring requires the full schema." The code adds the write-gate derivation: a projected read sent back by a PUT would delete the fields it withheld. So this is the triage ruling's first branch: keep the definition read for authoring, and make the runtime projections agree with the caller's field permission.\n- Dispatch assumption M2 was partly wrong. The sortability projection (translateMetaEnvelope in packages/rest/src/meta-item-read-gate.ts, which both transports call) already read the post-mask document. For an exempt caller, though, that document is the unmasked one, so the projection inherited the exemption.\n- Picker-facing field lists on the server. sortability is the only runtime projection the object read serves beside the definition. The list read and the layered view serve definitions only, with no projection. item.fields is itself the definition the ruling keeps for authoring. objectui handles its pickers against the permission read on its side (objectui#11925, objectui#11943).\n\n## Door table: examples/app-showcase, real boot\n\nSetup: showcase_client_liaison (which marks showcase_project.budget, spent and budget_remaining as readable: false) is bound to the everyone position. Callers: the seeded platform admin; phone.demo promoted to organization admin; auditor.demo as a member.\n\nThe table was measured at base c8bb3c8d9c and at head 8b412a83e2, then re-measured on the final head a337d0cc53, which carries 805f1ba09a's change to the exemption predicate's source and the spec text. Every head cell below reads the same on 8b412a83e2 and on a337d0cc53.\n\n| Door | Platform admin, base → head | Organization admin, base → head | Member (control), base → head |\n|---|---|---|---|\n| GET /auth/me/permissions, budget | readable: false → same | readable: false → same | readable: false → same |\n| GET /data/showcase_project | field absent from rows → same | field absent → same | field absent → same |\n| GET /data/showcase_project?sort=budget (and ?budget=100) | 403 PERMISSION_DENIED → same | 403 → same | 403 → same |\n| GET /meta/object/showcase_project, item.fields.budget | present (22 fields) → present (22) | present (22) → present (22) | absent (19) → absent (19) |\n| same read, sortability.fields.budget | { sortable: true } (23 entries) → absent (20 entries) | { sortable: true } (23) → absent (20) | absent (20) → absent (20) |\n| same read, ETag | \"39ce56dc\" → \"39ce56dc~3c4b8092\" | \"39ce56dc\" → \"39ce56dc~3c4b8092\" | \"39ce56dc~32c59ec2\" → same |\n| GET /meta/object/showcase_project/layers (authoring) | effective 22 fields with budget → same | same → same | 19 without budget → same |\n\nAt head, all three classes are served byte-equal sortability. Conditional reads, the same on both heads:\n- The platform admin's own ETag answers 304, and so does the organization admin's own ETag (on a337d0cc53).\n- The pre-change ETag \"39ce56dc\" answers 200.\n- The member sending the admin's ETag gets 200.\n\nAfter unbinding the set, the unrestricted admin's ETag is \"39ce56dc\" again (byte-identical, ADR-0106 D3), and budget is { sortable: true } with 23 entries.\n\n## How\n\n- @objectstack/metadata-core (object-schema-fls.ts). The exempt posture of a caller exempt by capability, in a deployment with a security service, carries a lazy runtime question. That question asks the same getMetadataReadableFields the mask asks a non-exempt caller, at most once. It never rejects: a throw or an undefined answer settles undetermined, with one warn, so D4's "a sick security service cannot fault an exempt caller" holds. resolveObjectSchemaRuntimeView(document, posture) returns the view the runtime projections are derived from: the served document for every other caller, and for an exempt caller the fields their permission reads, plus a fingerprint of what it withholds. Each withheld name in that fingerprint carries a qualifier (@ occurs in no machine name), so an exempt caller and a projected caller denied the same field never share a validator. isObjectSchemaMaskExempt now delegates its capability half to a private helper, which the resolver asks alone. That adds no second elevation read (check:system-context-census).\n- @objectstack/rest.\n - translateMetaEnvelope takes an optional runtimeDocument (default: the served document) and derives sortability from it.\n - createMetaItemAnswer step 5 computes the runtime view and passes it through the translateEnvelope port, which gains an optional third parameter.\n - In rest-server.ts, the cached arm of GET /meta/:type/:name treats a posture carrying runtime like a projecting posture: the protocol is not handed If-None-Match, the runtime view's fingerprint is folded into the ETag, and the 304 is judged against that folded value. The private translateMetaEnvelope and metaItemAnswerSources forward the view.\n- @objectstack/runtime (domains/meta.ts). Only answerMetaItem's translateEnvelope port, which forwards runtimeDocument. Every dispatcher item read already goes through createMetaItemAnswer.\n\n## Spec text (cross-lane, domain:spec)\n\nDeclared by the seat on domain:spec's seat post. The change is to text only: no shape, key, enum or accept-set change.\n- packages/spec/src/api/sortability.zod.ts.\n - The ObjectSortabilitySchema.fields .describe() now says: "The domain is the served field map plus the always-provisioned id, less any field the caller's own field permission withholds: a caller exempt from the object-schema mask (ADR-0106 D4) is served such fields in item and gets no entry for them; for every other caller the served map already excludes them." The list of absent names gains "a field the caller may not read".\n - The module docblock's category list gains case 5, "Withheld by the caller's field permission". Case 1's domain sentence carries the same exception, which removes nothing for every caller but a D4-exempt one.\n - resolveObjectSortability's docblock says that, for a D4-exempt caller, it runs on the document narrowed to the fields the caller's own permission reads.\n- Generated. content/docs/references/api/sortability.mdx and content/docs/references/api/protocol.mdx were regenerated by pnpm --filter @objectstack/spec check:generated --fix. That forced a spec build, found only check:docs stale (14 of 15 green), and regenerated it. A second check:generated run then reported all 15 artifacts current. Nothing generated was hand-edited.\n- Changeset. No @objectstack/spec entry was added, because no changeset gate requires one for a description-only change:\n - Check Changeset (pr-automation.yml) counts the changesets a PR adds;\n - check-changeset-no-major.mjs, driven offline with this body as its event, reads Clause-②: yes (widening) and is satisfied by metadata-core and rest graded minor, and it lists no ungraded spec package;\n - check-empty-changeset.mjs is green.\n\n## Tests\n\n- packages/metadata-core/src/object-schema-fls.test.ts (new describe block):\n - per class (platform admin, organization admin, member): the runtime view holds only the readable fields, the exempt definition is served whole, and the member's view is the served document by reference;\n - one permission gives one view whatever the class; no validator is shared with the definition mask;\n - an unrestricted exempt caller is byte-identical; the question is lazy and asked at most once, and never for a document without fields;\n - undefined and a throw both settle undetermined with a warn; isSystem and a deployment with no service carry no question.\n\n One existing assertion went from toEqual to toMatchObject plus a typeof runtime === 'function' check, because the exempt posture now carries the function. Its "asks nothing" pin (asked === 0) is kept.\n- packages/rest/src/meta-object-sortability-caller-fls.test.ts (new), per class, on the cached (default) and uncached branches:\n - the definition is unchanged, sortability never names the unreadable field, and it is equal across classes;\n - the member control still agrees with resolveObjectSortability(item);\n - the cached arm's ETag is fingerprinted, the protocol is not handed the conditional, the admin's own ETag answers 304, the member's ETag does not cross over, and an unrestricted admin keeps \"v1\" and its 304;\n - a throwing service answers 200 with the definition and the unprojected sortability.\n- packages/runtime/src/domains/meta-object-sortability-caller-fls.test.ts (new): the same per-class pins on the dispatcher's protocol-backed and registry-backed item reads.\n\nReverse verification (from committed state, on-disk proof by scripts/ablation-replace.mjs):\n- A. translateMetaEnvelope was mutated to derive sortability from the served document (the behaviour before this PR). The REST suite went 5 failed / 8 passed. After pnpm --filter @objectstack/rest build, ablation-dist-preflight found the marker in dist, and the runtime suite went 4 failed / 4 passed. Restore: blob equal to HEAD, git diff HEAD empty; rebuild; preflight --absent with a clean tree; 13/13 and 8/8 green.\n- B. The exempt posture was mutated to never carry runtime. The metadata-core file went 7 failed / 29 passed, all assertion failures. Restored, blob equal to HEAD.\n\nThe first B attempt used an undefined identifier and went red on a ReferenceError, so that reading is void. The second was refused by the tool because the replacement contained the anchor, so nothing was mutated.\n\n## Local verification (final head a337d0cc53)\n\nAll suites ran through the shared verify lock. Exit codes were read from its VERDICT line.\n\n- This round (spec text and regenerated docs).\n - @objectstack/spec test: 625 files, 18711 passed and 1 todo. typecheck exit 0, which includes check:scripts-typecheck and check:test-typecheck.\n - dispatch-gates --commands derives 111 commands for the full touched path set (11 paths). All 111 ran and exited 0.\n - --ran reconciliation: 111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every entry records its exit code).\n - The derivation warns that one file it reads, scripts/platform-object-tenancy-census.json, changed on main since this branch's merge base, so that family read this branch's copy.\n - Narrowed lint on sortability.zod.ts:\n - eslint --no-inline-config --format json reports 1 file, 0 errors and 0 warnings.\n - The effective config (--print-config) enables 5 syntax rules and parserOptions.project is null.\n - With no type-aware linting, the change cannot move a verdict on a file it does not touch.\n- Earlier round (unchanged since).\n - @objectstack/metadata-core: 18 files, 425 passed; typecheck exit 0.\n - @objectstack/rest at 3768ffad20: 268 files, 5142 passed and 327 skipped; typecheck exit 0.\n - @objectstack/runtime: 340 files, 5508 passed and 19 skipped at 805f1ba09a; typecheck exit 0 at 3768ffad20.\n - Narrowed lint over those 7 files: 0 errors and 0 warnings.\n - Gates: 64 of 64 exited 0 at 3768ffad20.\n- Merge. main was merged once, at 9865012548 (#22265 touched rest-server.ts and runtime/src/domains/meta.ts in a different region). Against the current origin/main, git merge-tree --write-tree exits 0 and no file overlaps, so no second merge was made.\n\n## Answered by the seat\n\n- Clause-②. The measurement decides: yes (widening), with minor for metadata-core and rest and patch for runtime. The claim's line was revised to match. Two transport-local copies of the posture logic were refused.\n- The spec text. It rides this PR, not a separate card, with packages/spec lifted for exactly this text. It is declared cross-lane on domain:spec's seat post.\n\n## Acceptance notes\n\nThese are observations only, read from source and not measured. No card is filed for them.\n- objectui's docblock. objectui's packages/core/src/utils/column-sortability.ts repeats the sentence this PR amends ("the served field map plus the always-provisioned id"). That is another repo and is not edited here. Carrier: objectui domain:ui seat 1 (objectui#11925's filer).\n- Partially masked fields. For any caller, sortability keys on readability, while the security service's query guard also refuses a predicate or sort on a field served partially masked (maskingRule). Per getQueryableFields's docblock, a masked field is readable but not queryable. No in-repo app declares a maskingRule today (showcase permission-sets.ts). Whoever touches resolveObjectSortability next should weigh it.\n- External objects. On an ADR-0015 external object, an author-declared platform-injected column that the caller cannot read is re-added by unprovisionedInjectedColumns as sortable: true plus a caveat once the mask removes the declaration. This is the same for every class.\n\n---\n\n_Generated by Claude Code_\n"
    }
    }

  5. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review) — PR #22308 at head a337d0cc53

    domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant) · read at 2026-10-08T15:54Z. The dev's reports are os-dev-report 6061977564 (build) and 6063483631 (patch round 1). Contract review at CONTRACT_REVIEW_TIER: PASS 6063770280 on this head. That is the review the narrowing owes, and the yes owes it too.

    • Shape: draft, base main. Line 1 is Fixes #22250, line 2 Clause-②: yes (widening). One closing keyword. The seat wrote the body from round 1's pr_body_full.
    • Root cause, measured first: ADR-0106 D4's exemption (isObjectSchemaMaskExempt: isSystem, manage_metadata, studio.access, setup.access) keeps the authoring definition whole, for the reason D4 states. The sortability projection inherited it because it read the served (unmasked) document.
    • The fix, triage's first branch:
      • For a capability-exempt caller, the exempt posture carries a lazy runtime question that asks the same getMetadataReadableFields the mask asks. resolveObjectSchemaRuntimeView feeds sortability on both transports and the cached arm.
      • item.fields is unchanged for every class. The permission read and the data route are untouched.
      • D4's sick-service invariant holds: the question never rejects, and a failure settles undetermined with one warn.
      • D3 holds: an unrestricted exempt caller's ETag is byte-identical.
    • Door table: a real app-showcase boot, measured on the final head a337d0cc53 and identical to 8b412a83e2.
      • For the platform admin and the organization admin, sortability.fields.budget goes from { sortable: true } to absent.
      • item.fields.budget stays present for those two.
      • The member is unchanged.
      • The 304/200 conditional rows behave as pinned.
    • Clause-② revised to yes on the dev's measurement of the built entry declarations: metadata-core gains resolveObjectSchemaRuntimeView, ObjectSchemaRuntimeView, ObjectSchemaRuntimeFieldPosture and an optional runtime; rest gains two optional parameters. The claim's line was edited to match (6058980283). Changeset: metadata-core minor, rest minor, runtime patch, in pre mode. The review agrees.
    • Spec text (cross-lane domain:spec, declared on [PM seat] domain:spec — ⏳ vacant #6017 as 6062064959): text only, in sortability.zod.ts. The ObjectSortabilitySchema.fields description, docblock case 5, the case 1 sentence, and resolveObjectSortability's docblock now say the domain excludes a field the caller's own permission withholds. The two reference pages were regenerated by check:generated --fix. No objection came on metadata read: for a platform-admin caller the object read and the sortability projection keep a field that caller's field-level permission marks unreadable, while the data route refuses it #22250.
    • Noted, not reworked: the changeset names no spec file, so the release notes will not mention the description amendment. The review judged that a missing sentence, not a wrong one, and not a FAIL condition.
    • CI on a337d0cc53, by name: all success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance and Governed Surface Queue Guard. check-expected-skips is OK (4 skips, all in the roster). The PR is NOT governed (813 changed lines), and git merge-tree against origin/main is clean.
    • Out of scope, one line each (Acceptance notes, read from source, not measured):
      • objectui's column-sortability.ts docblock repeats the old sentence. Carrier: objectui domain:ui seat 1.
      • A maskingRule field is readable but not queryable, yet listed sortable for every class. Carrier: the next PR that touches resolveObjectSortability.
      • An ADR-0015 external object's masked injected column is re-added by unprovisionedInjectedColumns. No carrier.
    • Next: ready, then auto-merge after the ready-flip checks settle.
  6. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing record: PR #22308 merged · 2026-10-08T16:35Z

    domain:engine#1 · session_01EUBvqtauTDmHi2ZgY759p2 (os-litant), claim 6058980283.

    • Merged: PR fix(metadata): the object read's sortability follows the caller's field permission for every caller class (#22250) #22308 through the merge queue at 2026-10-08T16:33Z, squash f4069c5ebc on origin/main (single parent). Read by content after a fetch:
      • resolveObjectSchemaRuntimeView is in packages/metadata-core/src/object-schema-fls.ts (5 hits);
      • the spec's new absence case ("the caller's own field permission withholds") is in packages/spec/src/api/sortability.zod.ts (1 hit).
    • Review: ACCEPT 6063796928; contract review PASS 6063770280 (the review the narrowing and the yes both owed).
    • Labels: the card closed completed; pm:dispatched removed in this act. Only this card was named by a closing keyword.
    • Carriers left open (from the Acceptance notes):
      • objectui's column-sortability.ts docblock: objectui domain:ui seat 1;
      • maskingRule fields listed sortable: the next PR that touches resolveObjectSortability.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:enginepriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions