Repository navigation
fix(metadata-core,rest,runtime): judge an objectOverride action param against the object it names - #21904
Conversation
…object it names The ADR-0106 object-schema mask read every action param's `field` as a field of THIS object, so `sys_user.invite_user` (whose `role` param names `sys_member.role` through `objectOverride`) was dropped for every caller denied `sys_user.role`, although the invite door admits a delegated admin. A param under `objectOverride` naming another object is now judged against the caller's readable set on THAT object: the action is still dropped when the field is not readable there, or when that set cannot be determined. A `project` posture carries `relate` (the same question, same caller and service, about another object) and, after the fetch, `relateObjectSchemaMaskPosture` fills `related` for the objects the document's params name. Withheld related reads fold into the fingerprint. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…er its fetch Every exit that masks a fetched object schema now relates its posture to that document (`relateObjectSchemaMaskPosture`) before projecting it, so an action param reading another object through `objectOverride` is judged against that object on both transports: the shared item, layered and list chains, RestServer's cached and published reads, and the runtime dispatcher's mask. The diff route masks `fields` only and needs no relate. The shared ADR-0106 contract gains two `objectOverride` actions, so an exit that never relates its posture withholds `invite` and fails by name. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…changeset The org-admin reach dogfood pinned `sys_user.invite_user` as withheld below tenant-admin grade by the field mask. It is now served to every grade, and a new case shows, on a real showcase boot, that a delegated_admin and a member are both denied `sys_user.role` but served `sys_member.role`, that the delegated_admin is offered the action, and that the member is not because the served reach predicate excludes its grade. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…s-mask-object-override
Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…s-mask-object-override
📓 Docs Drift CheckThis PR changes 3 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 34 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 a62ec4be341bf7fc1c47b5a25cd99b34e5e3947f && git checkout a62ec4be341bf7fc1c47b5a25cd99b34e5e3947f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e e5e2792cf1b7492afcf2426c519ae7070308728f && git checkout -B drift-repro 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e && git merge --no-ff e5e2792cf1b7492afcf2426c519ae7070308728f
node scripts/docs-audit/affected-docs.mjs --json 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e
|
ACCEPT (seat review) — PR #21904 at head
|
Contract reviewServed-tier: ① Derived judgmentsInputs: card #21884 (body; comments 5996976481 triage, 5998699624 claim as revised, 6000301028 os-dev-report), PR #21904 (body, file list, net diff against Security direction (ADR-0106 D1 field disclosure), judged first.
Hygiene: no tracker number reaches a runtime string (the two ② Semver level
③ Boundary flagsThe os-dev-report (comment 6000301028) has
Triage's constraints (comment 5996976481) hold: no special case for Implemented-by: VERDICT: PASS Adopted by The record's two non-blocking notes stay recorded here and are not filed, for these reasons:
Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37360556017 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Merge queue: re-queued after runner loss · The queue removed this PR for
The seat held the re-queue while GitHub-hosted runners were degraded (2026-10-05T19:50Z to about 2026-10-05T21:05Z: relay and CI runs failed with the same "not acquired" annotation). It re-enables auto-merge once now, which re-queues the PR. The head ( Generated by Claude Code |
Fixes #21884
Clause-②: yes (widening)
What a user saw
A
delegated_adminmay invite members: the invite door answers 200 for that principal. ButGET /meta/object/sys_userserved that principal noinvite_useraction, so the console withheld an action the server admits. The action'sroleparam is{ field: 'role', objectOverride: 'sys_member' }, so it namessys_member.role. The ADR-0106 mask read every param'sfieldas a field of the served object. A caller deniedsys_user.roletherefore lost the whole action.The mechanism, measured at
607463d736:presentationEntryinpackages/metadata-core/src/object-schema-fls-references.tstested every non-list key of an action withmentionsDenied({ [key]: inner }, denied, 'skip', 'classified')(line 383), anddeniedis the served object's denied set. A unit repro against the base build (roledenied onsys_user) served['other']and droppedinvite_user. The base census below shows the same thing on a real showcase boot.What changed
The rule (
@objectstack/metadata-core,object-schema-fls-references.ts). An action'sparamsare now read one param at a time (actionParamReadsDenied). A param whoseobjectOverridenames another object reads that object's field. Itsfieldis judged against the caller's readable set on that object, and it is not a reference to the served object's fields. The action is still dropped when the field is not readable there. It is also dropped when that object's readable set cannot be determined. The override's value is an object name, so it is no longer tested as a field token. Everything else on the param is still read against the served object. There is no special case forinvite_user: the rule covers every authored param withobjectOverride.Where the other object's readable set comes from (H3). The posture is still decided once per caller and object, before the fetch (ADR-0106 D3). Only the fetched document says which other objects its params name, so the related half runs after the fetch:
resolveObjectSchemaMaskPosturenow putsrelateon aprojectposture.relateasks the posture's own question (same caller, same security service, same D7 preference forgetMetadataReadableFields) about another object.relateObjectSchemaMaskPosture(posture, ...documents)fillsrelatedfor the objects those documents' params name. It does nothing for any other posture or for a document with no such param. It asks each object once, and it never throws.applyObjectSchemaMaskpassesrelatedinto the reference mask. Every related read it withholds goes into the fingerprint asobject.field. Two callers denied the same fields on the served object but different fields on the other object therefore never share a validator (D3's 304 cohorts). An unrestricted caller's ETag is byte-identical to before.I chose this over a second posture argument at every exit for one reason: the posture already reaches every projection site, and the masker closure that resolved it does not. With
relateon the posture, each exit adds one awaited call between its fetch and its projection. No exit had to add a port or a request field.Every exit relates its posture (
@objectstack/rest,@objectstack/runtime). The issue placed the fix atpresentationEntry, with the runtime dispatcher'smaskObjectSchemaas the possible exit. Measured, the projections that serve actions live in two packages. In@objectstack/restthey are the shared item chain, layered chain and list chain (meta-item-read-gate.ts) andRestServer's cached read and published read (rest-server.ts). The runtime dispatcher reaches the shared chains throughprojectMetaObjectSchemaplus its ownmaskObjectSchema. ADR-0106 D5 requires all of them to mask alike. So each one now relates its posture right after the fetch, which is the narrowest correct form:packages/restis the real home of most exits. The/metadiff route masks only{ fields }and has no actions, so it needs no relate step.The shared contract (
@objectstack/metadata-core/testing).FLS_CONTRACT_OBJECTgains two actions whose params readcontactfields throughobjectOverride, and the retention facts require the readable one to be served. An exit that skips the relate step withholds it (fail closed) and fails the contract by exit name. This was measured: see reverse verification below.Decisions
name. UnderobjectOverride, anamethat repeatsfieldis read as that field, so it is judged on the other object. An explicitnamethat differs fromfieldis a request-body key whose owner nothing here can verify. It keeps the existing reading, as a reference to the served object, which can over-mask but never leak. WithdefaultFromRow, the param also seedsfieldfrom the served object's row (the spec's "key = the resolved field name"). That is a second read of the served object, sofieldis judged there too. All three cases are pinned.projectposture that nobody related (hand-built, or an exit that skipped the step) relates nothing, so itsobjectOverrideactions are dropped too. A related throw withholds only the actions that read that object, with awarnnaming it. The served object's own D6 tiers are unchanged. Exempt callers (platform admin,isSystem) get passthrough and the service is never asked about the related object. That is pinned.Census (H2), measured on a real showcase boot
I added a scratch probe under
packages/qa/dogfood/test/(deleted afterwards, not committed). It read every object schema the showcase serves (78 objects) through the by-name read and the list read, as five principals in one organization: the seeded platform admin, anownerwho is not a platform admin, anadmin, adelegated_adminand amember. I ran it once on head, and once with all six touched source files restored to the base blobs and those packages rebuilt. The restore was proven by blob equality with HEAD and an emptygit diff HEAD.The workspace has two authored params with
objectOverride:sys_user.invite_user'srole(onsys_member) andsys_member.invite_user'semail(onsys_invitation).sys-member.object.tshas two hits, but the other one is a comment. The only other hit is thepackages/linttest fixture, which this mask never reads.sys_user.invite_user, base to headsys_member.invite_user, base to headThe by-name read and the list read agree in every cell. Across all 78 objects × 5 principals × 2 reads, the only served/dropped verdicts that moved are the two in bold. No read answered anything but 200 at base or head. On head, the
delegated_adminand thememberare both not servedsys_user.role, and both are servedsys_member.role.Why the member is still not offered it
The member is not denied
sys_member.role, so it is now servedsys_user.invite_userin the metadata. It is not offered the action because of the reach gate from #21883:requiresMembershipReach: 'invite_member'lowers to avisiblepredicate overcurrent_user.positions, and that predicate excludes the member grade. The dogfood case says this in as many words. It asserts that both grades are deniedsys_user.role, servedsys_member.roleand served the action, that the delegated_admin is offered it, and that the member's served predicate evaluates false.Exported surface (measured on the built declarations)
I diffed
packages/metadata-core/dist/index.d.ts(andindex.d.cts) built at base607463d736against head:relateObjectSchemaMaskPosture(posture, ...documents);projectmember ofObjectSchemaMaskPosture,related(a map from object name to its readable field set, or undefined) andrelate(a function from object name to a promise of that set);dist/testing.d.ts: theFLS_CONTRACT_OBJECTliteral type gains the two actions;So the claim's
Clause-②: nobecomesyes (widening), and@objectstack/metadata-coretakes aminorchangeset.@objectstack/restand@objectstack/runtimetakepatch: their exported signatures are unchanged (projectMetaObjectSchemakeeps its signature).Tests
Final head
e5e2792cf1, which mergesorigin/mainat1e18a0735c:pnpm --filter @objectstack/metadata-core test: 18 files, 397 passed.--project isolated:org-admin-affordance-reach.dogfood.test.tsanddelegated-admin-invite.dogfood.test.ts, 2 files, 17 passed.packages/rest/src/meta-object-fls.test.tsplus the two capability-gate suites that read the fixture, 3 files, 123 passed;packages/runtime/src/domains/meta-object-fls.test.ts, 85 passed.typecheckfor metadata-core and dogfood: exit 0.At
0f9ce970cd, the previous merge oforigin/main:@objectstack/rest(both projects): 265 files, 5075 passed, 327 skipped;@objectstack/runtime(both projects): 330 files, 5390 passed, 19 skipped;typecheckfor rest and runtime: exit 0.Between those two heads, this branch changed two test titles, and
origin/mainbrought aplatform-objectsaction retirement and spec test-title text. Neither touches rest or runtime, so the full suites were not rerun (AGENTS.md, Multi-agent discipline §10). CI runs them.New unit pins, in
object-schema-fls-references.test.tsunder "an action param underobjectOverrideis judged against the object it names":invite_user;objectOverrideparam on a field denied on the other object still drops the action, even when nothing of the served object is denied;nameanddefaultFromRowreadings;The dogfood:
org-admin-affordance-reach.dogfood.test.tspinned the defect itself asMASKED_BELOW_TENANT_ADMIN = ['sys_user.invite_user']. That pin is now "every site is served to every grade". The new case, "a delegated_admin is offered Invite User on sys_user; a plain member is not — by the reach gate, not the field mask", is the one dogfood test for this card. I edited the existing file rather than adding a second showcase boot.Reverse verification (one-off, on committed HEAD
af06da75ac)scripts/ablation-replace.mjs, I setpresentationEntry's key reading back to the base reading (const read = readsAsThisObject(key);, which readsparamsas the base did). Anchor 1 to 0, blobd7455eadbd55to5b00498c2f07. Result: 4 failed, 73 passed. Red: the delegated_admin pin; the other-object-denied pin (through its served-object-whole half, where the base serves the action); fail-closed; and thename/defaultFromRowpin. Green, as expected: the "denied field of THIS object" pin, the exempt pin, the fingerprint pin and the relate pin. The restore was proven byblob == HEAD (d7455eadbd55)and an emptygit diff HEAD.createMetaItemAnswer) and ranpackages/rest/src/meta-object-fls.test.ts. Result: 4 failed, 91 passed. The four wererestricted-caller/field-vanishes-whole,restricted-caller/required-permissions-cause,unrestricted-caller/byte-identicalandguest-fallback/D7, each failing under the exit "GET /meta/object/:name — uncached branch" with "the mask over-reached". No other exit failed. The restore was proven byblob == HEAD (b9e94d4bd884)and an emptygit diff HEAD.Gates (at
e5e2792cf1)node scripts/pm/dispatch-gates.mjs --commandsderives the same 68 families. All 68 exit 0, and--ranreconciles them: 68 run, 0 NOT MEASURED, 0 unrun, and every family carries a recorded exit code.check:dual-build-cjs-loads. At0f9ce970cdit first answeredPREREQUISITE NOT MET, because 8 packages outside the dogfood closure had nodist/. That was a run that measured nothing, not a red. Afterturbo run buildover./packages/*and./packages/*/*, it reported 106 entry points across 66 packages load.check-closing-target-claim,check-partof-closing-keywordandcheck-single-claim-paths.check-partof-closing-keywordpasses on this body when given it asPR_BODY. The other two run against this PR once it exists, and their results are in the dev report.check:adr-symbol-anchors,check:scripts-symbol-anchors,check:spec-docblock-symbol-anchorsandcheck:adr-anchorsall exit 0.pnpm exec eslint --no-inline-config --format jsonover the 9 touched.tsfiles reports 9 files, 0 errors, 0 warnings. All 9 are in the populationeslint.config.mjsdeclares (packages/**/*.{ts,tsx,mts,cts}). The config never enables type-aware linting (noparserOptions.project, noprojectService), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.Acceptance notes
objectOverrideparam costs one more security-service read per object those params name. Today that means two documents,sys_userandsys_member, one related object each. It applies to everyprojectposture, including a caller who is denied nothing on the served object, because that caller can still be denied the field on the other object.packages/rest/src/meta-item-read-gate.tsandpackages/rest/src/rest-server.tsare outside the declared lane. They are where most of the exits are (see above).sys-user.object.tsis untouched: the declaration was right and the mask was wrong.Generated by Claude Code