Skip to content

fix(metadata-core,rest,runtime): judge an objectOverride action param against the object it names - #21904

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21884-fls-mask-object-override
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21884-fls-mask-object-override

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21884

Clause-②: yes (widening)

What a user saw

A delegated_admin may invite members: the invite door answers 200 for that principal. But GET /meta/object/sys_user served that principal no invite_user action, so the console withheld an action the server admits. The action's role param is { field: 'role', objectOverride: 'sys_member' }, so it names sys_member.role. The ADR-0106 mask read every param's field as a field of the served object. A caller denied sys_user.role therefore lost the whole action.

The mechanism, measured at 607463d736: presentationEntry in packages/metadata-core/src/object-schema-fls-references.ts tested every non-list key of an action with mentionsDenied({ [key]: inner }, denied, 'skip', 'classified') (line 383), and denied is the served object's denied set. A unit repro against the base build (role denied on sys_user) served ['other'] and dropped invite_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's params are now read one param at a time (actionParamReadsDenied). A param whose objectOverride names another object reads that object's field. Its field is 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 for invite_user: the rule covers every authored param with objectOverride.

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:

  • resolveObjectSchemaMaskPosture now puts relate on a project posture. relate asks the posture's own question (same caller, same security service, same D7 preference for getMetadataReadableFields) about another object.
  • relateObjectSchemaMaskPosture(posture, ...documents) fills related for 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.
  • applyObjectSchemaMask passes related into the reference mask. Every related read it withholds goes into the fingerprint as object.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 relate on 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 at presentationEntry, with the runtime dispatcher's maskObjectSchema as the possible exit. Measured, the projections that serve actions live in two packages. In @objectstack/rest they are the shared item chain, layered chain and list chain (meta-item-read-gate.ts) and RestServer's cached read and published read (rest-server.ts). The runtime dispatcher reaches the shared chains through projectMetaObjectSchema plus its own maskObjectSchema. 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/rest is the real home of most exits. The /meta diff route masks only { fields } and has no actions, so it needs no relate step.

The shared contract (@objectstack/metadata-core/testing). FLS_CONTRACT_OBJECT gains two actions whose params read contact fields through objectOverride, 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

  • H4: the param's name. Under objectOverride, a name that repeats field is read as that field, so it is judged on the other object. An explicit name that differs from field is 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. With defaultFromRow, the param also seeds field from the served object's row (the spec's "key = the resolved field name"). That is a second read of the served object, so field is judged there too. All three cases are pinned.
  • H5: fail closed. If the security service has no answer for the other object, throws for it, or the object does not exist, the action is dropped. A project posture that nobody related (hand-built, or an exit that skipped the step) relates nothing, so its objectOverride actions are dropped too. A related throw withholds only the actions that read that object, with a warn naming 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, an owner who is not a platform admin, an admin, a delegated_admin and a member. 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 empty git diff HEAD.

The workspace has two authored params with objectOverride: sys_user.invite_user's role (on sys_member) and sys_member.invite_user's email (on sys_invitation). sys-member.object.ts has two hits, but the other one is a comment. The only other hit is the packages/lint test fixture, which this mask never reads.

principal sys_user.invite_user, base to head sys_member.invite_user, base to head
platform admin served, served served, served
owner served, served served, served
admin served, served served, served
delegated_admin dropped, served served, served
member dropped, served served, served

The 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_admin and the member are both not served sys_user.role, and both are served sys_member.role.

Why the member is still not offered it

The member is not denied sys_member.role, so it is now served sys_user.invite_user in the metadata. It is not offered the action because of the reach gate from #21883: requiresMembershipReach: 'invite_member' lowers to a visible predicate over current_user.positions, and that predicate excludes the member grade. The dogfood case says this in as many words. It asserts that both grades are denied sys_user.role, served sys_member.role and 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 (and index.d.cts) built at base 607463d736 against head:

  • added: relateObjectSchemaMaskPosture(posture, ...documents);
  • added: two optional members on the project member of ObjectSchemaMaskPosture, related (a map from object name to its readable field set, or undefined) and relate (a function from object name to a promise of that set);
  • dist/testing.d.ts: the FLS_CONTRACT_OBJECT literal type gains the two actions;
  • nothing removed, renamed or narrowed. The rest of the diff is docblock text.

So the claim's Clause-②: no becomes yes (widening), and @objectstack/metadata-core takes a minor changeset. @objectstack/rest and @objectstack/runtime take patch: their exported signatures are unchanged (projectMetaObjectSchema keeps its signature).

Tests

Final head e5e2792cf1, which merges origin/main at 1e18a0735c:

  • pnpm --filter @objectstack/metadata-core test: 18 files, 397 passed.
  • dogfood, --project isolated: org-admin-affordance-reach.dogfood.test.ts and delegated-admin-invite.dogfood.test.ts, 2 files, 17 passed.
  • The ADR-0106 contract suites at every exit: packages/rest/src/meta-object-fls.test.ts plus the two capability-gate suites that read the fixture, 3 files, 123 passed; packages/runtime/src/domains/meta-object-fls.test.ts, 85 passed.
  • typecheck for metadata-core and dogfood: exit 0.

At 0f9ce970cd, the previous merge of origin/main:

  • full @objectstack/rest (both projects): 265 files, 5075 passed, 327 skipped;
  • full @objectstack/runtime (both projects): 330 files, 5390 passed, 19 skipped;
  • typecheck for rest and runtime: exit 0.

Between those two heads, this branch changed two test titles, and origin/main brought a platform-objects action 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.ts under "an action param under objectOverride is judged against the object it names":

  • triage's three:
    • a delegated_admin-shaped caller is served invite_user;
    • an action whose param names a denied field of the served object is still dropped;
    • an objectOverride param on a field denied on the other object still drops the action, even when nothing of the served object is denied;
  • fail closed: undetermined, throwing, unknown object, and an unrelated posture;
  • exempt callers are untouched;
  • the name and defaultFromRow readings;
  • fingerprint cohorts;
  • relate asks each object once.

The dogfood: org-admin-affordance-reach.dogfood.test.ts pinned the defect itself as MASKED_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)

  • The rule. Through scripts/ablation-replace.mjs, I set presentationEntry's key reading back to the base reading (const read = readsAsThisObject(key);, which reads params as the base did). Anchor 1 to 0, blob d7455eadbd55 to 5b00498c2f07. 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 the name/defaultFromRow pin. Green, as expected: the "denied field of THIS object" pin, the exempt pin, the fingerprint pin and the relate pin. The restore was proven by blob == HEAD (d7455eadbd55) and an empty git diff HEAD.
  • An exit that forgets to relate. I removed the relate step from the shared item chain (createMetaItemAnswer) and ran packages/rest/src/meta-object-fls.test.ts. Result: 4 failed, 91 passed. The four were restricted-caller/field-vanishes-whole, restricted-caller/required-permissions-cause, unrestricted-caller/byte-identical and guest-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 by blob == HEAD (b9e94d4bd884) and an empty git diff HEAD.

Gates (at e5e2792cf1)

  • Derived families. After the second merge, node scripts/pm/dispatch-gates.mjs --commands derives the same 68 families. All 68 exit 0, and --ran reconciles them: 68 run, 0 NOT MEASURED, 0 unrun, and every family carries a recorded exit code.
  • check:dual-build-cjs-loads. At 0f9ce970cd it first answered PREREQUISITE NOT MET, because 8 packages outside the dogfood closure had no dist/. That was a run that measured nothing, not a red. After turbo run build over ./packages/* and ./packages/*/*, it reported 106 entry points across 66 packages load.
  • The artifact-roster block. It is printed outside the derived total, unchanged after the merge, and has 53 commands. 50 exit 0. Three need a PR's context and answered NOT WIRED or NOT MEASURED locally: check-closing-target-claim, check-partof-closing-keyword and check-single-claim-paths. check-partof-closing-keyword passes on this body when given it as PR_BODY. The other two run against this PR once it exists, and their results are in the dev report.
  • The four symbol-anchor sweeps. check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors and check:adr-anchors all exit 0.
  • ESLint, narrowed. pnpm exec eslint --no-inline-config --format json over the 9 touched .ts files reports 9 files, 0 errors, 0 warnings. All 9 are in the population eslint.config.mjs declares (packages/**/*.{ts,tsx,mts,cts}). The config never enables type-aware linting (no parserOptions.project, no projectService), so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.

Acceptance notes

  • Cost. A masked read of a document with an objectOverride param costs one more security-service read per object those params name. Today that means two documents, sys_user and sys_member, one related object each. It applies to every project posture, including a caller who is denied nothing on the served object, because that caller can still be denied the field on the other object.
  • What the contract cannot express. The contract's security double answers the same set for every object, so it cannot express "denied here, readable there", which is this card's own case. The unit pins and the dogfood hold that case. The contract holds that every exit relates.
  • Lane. The edits to packages/rest/src/meta-item-read-gate.ts and packages/rest/src/rest-server.ts are outside the declared lane. They are where most of the exits are (see above). sys-user.object.ts is untouched: the declaration was right and the mask was wrong.

Generated by Claude Code

claude added 6 commits October 5, 2026 16:51
…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>
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-core, @objectstack/rest, @objectstack/runtime, touching 29 documentable anchor(s).

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

  • content/docs/kernel/contracts/metadata-service.mdx (via getPublished (sdk, the bare tail of client method meta.getPublished, bound to GET /api/v1/meta/:type/:name/published; the bare tail of client method meta.getPublished, bound to GET /meta/:type/:name/published))

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

  • content/docs/releases/implementation-status.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v12.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v17/17-3.mdx (via RestServer (symbol, a top-level class), /:type/:name/published (route, bridged from symbol relateObjectSchemaMaskPosture — its route source's handler names it))
  • content/docs/releases/v17/17-5.mdx (via RestServer (symbol, a top-level class))

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

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

Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a62ec4be341bf7fc1c47b5a25cd99b34e5e3947f — the merge of head e5e2792cf1b7492afcf2426c519ae7070308728f into base 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 25eb7de8ad49ca5c943a677e5ee22034afb8ba8e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT (seat review) — PR #21904 at head e5e2792cf1

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-05T18:14Z. The os-dev report is on #21884 (6000301028). Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main.
    • The first lines are Fixes #21884 and Clause-②: yes (widening).
    • Assignee: os-project-manager.
  • Clause-② revised. The claim (5998699624) first read no. The dev measured the built declarations: relateObjectSchemaMaskPosture is a new export, the project posture gains optional related / relate, and ./testing's FLS_CONTRACT_OBJECT gains two actions. That widens @objectstack/metadata-core's public surface. The seat revised the claim in place to yes (widening) and added the packages/rest exits to its file surface. A contract review at tier is owed before enqueue.
  • Scope: 10 files, +522/-57.
    • metadata-core: object-schema-fls-references.ts (the rule), object-schema-fls.ts (the relate step), the contract fixture, and tests.
    • rest: meta-item-read-gate.ts and rest-server.ts.
    • runtime: meta.ts.
    • The dogfood file.
    • The changeset.
    • The packages/rest writes go beyond the first declaration. Each is the same one-line relate call at an exit that masks a fetched object schema. They are declared to domain:cli after the fact (6000363931), which is a deviation the dev reported, not a hidden one.
  • The diff, read: triage's direction (5996976481), as ruled.
    • A param whose objectOverride names another object reads that object's field. It is judged against the caller's readable set THERE, and it is not a reference to the served object's fields.
    • ⛔ No special case for invite_user.
    • Fail closed, read in code. relate answers undefined when the security service throws or does not answer an array (with a warn and the undetermined counter). related.get(object)?.has(field) then reads false, and the action is dropped. A project posture that no exit related drops every objectOverride action.
    • Withheld related reads join the ETag fingerprint as object.field.
  • Every exit is wired, read at the head. Every applyObjectSchemaMask / projectMetaObjectSchema / maskObjectDocument / maskObjectSchema call site relates its posture first:
    • meta-item-read-gate.ts list, item and layered chains;
    • rest-server.ts cached read and published read;
    • runtime's maskObjectSchema.
    • The exception is the /meta diff route (rest-server.ts about :8270). It masks { fields } only, so objectOverrideReads finds nothing there.
    • The shared ADR-0106 contract fixture now carries two objectOverride actions, so an exit that skips the step fails the contract by name.
  • Census (showcase boot, 78 objects × 5 principals × by-name and list reads; base = the six touched sources at 607463d736):
  • Pins:
    • unit pins in metadata-core for the three triage cases, fail-closed, name / defaultFromRow, exempt, fingerprint and relate;
    • the dogfood file's MASKED_BELOW_TENANT_ADMIN = ['sys_user.invite_user'] pin flips, and one case is added on its existing boot.
  • Reverse verification, from committed af06da75ac, each restore proved by blob equality and an empty git diff HEAD:
    • (1) The rule back to the base reading: 4 red, while "a denied field of THIS object is still dropped" stays green.
    • (2) The relate step removed from createMetaItemAnswer: 4 red, all under that exit in rest's ADR-0106 contract suite.
  • Changeset, checked sentence by sentence:
    • @objectstack/metadata-core: minor, rest and runtime patch, with Clause-②: yes (widening).
    • The rule, the fail-closed list, the additive API and the "every exit" paragraph each match the diff.
  • Evidence:
    • At e5e2792cf1: metadata-core passes 397 tests in 18 files; the dogfood passes 17 in 2 files; rest's ADR-0106 and gate suites pass 123; runtime's ADR-0106 suite passes 85.
    • At 0f9ce970cd, the full rest (5075) and runtime (5390) suites and their typecheck pass. The later main merge touched neither package.
    • Narrowed eslint over the 9 touched .ts files: 0 errors, 0 warnings.
  • Gates: dispatch-gates --ran: 68 of 68 exit 0. The artifact roster (53), the four symbol-anchor sweeps, and the 3 PR-context guards against this PR all pass.
  • Overlap: PR fix(rest,metadata-protocol): a public form's intake withdrawal at any metadata layer holds; layering can only narrow intake #21864 and PR fix(plugin-security): the packaged-permission-set lock refusal carries its guidance as userMessage #21902 touch rest-server.ts in other regions, read from their diffs. git merge-tree against main is clean.
  • Owed before landing: the contract review at tier once CI settles, and CI itself.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e5e2792cf1b7492afcf2426c519ae7070308728f
Local-runs: none

① Derived judgments

Inputs: card #21884 (body; comments 5996976481 triage, 5998699624 claim as revised, 6000301028 os-dev-report), PR #21904 (body, file list, net diff against main at merge-base 1e18a0735c: 10 files, +522/−57), and the 35 check-runs on the head. The seat's own ACCEPT comment was not read as an input. Line numbers below are at the head unless a ref is named.

Security direction (ADR-0106 D1 field disclosure), judged first.

  1. The rule. actionParamReadsDenied (packages/metadata-core/src/object-schema-fls-references.ts:456) reads a param with no objectOverride, or one naming the served object, exactly as before (:461). With an override naming another object it drops the action when the caller's readable set on that object is absent or lacks the field (:466) — fail closed. Only after that does it stop reading field, and a name equal to it, as a reference to the served object (:467-469). Every other key of the param — visible, an option's visibleWhen, defaultValue, an explicit name that differs, and field itself under defaultFromRow — is still read against the served object (:471). The override's value is an object name and is removed before that walk (:464). RIGHT. Nothing is served under objectOverride unless the caller reads that field on the named object, and nothing of the served object's denied set is newly served. The spec backs the reading: objectOverride is "Object that owns the referenced field" (packages/spec/src/ui/action.zod.ts:194) and defaultFromRow pulls "from the current row record (key = the resolved field name)" (:391), a read of the served object's row.

  2. A walk with nothing denied. maskDeniedFieldReferences now walks a document with zero denied fields when it carries override reads (object-schema-fls-references.ts:753). RIGHT: that is what drops the action when the other object denies the field while the served object is whole (pinned at object-schema-fls-references.test.ts:528, the thisWhole half), and a caller whole on both objects still gets the same reference (test :580, whole.document is SYS_USER; the contract's unrestricted-caller/byte-identical case is green in CI).

  3. The fingerprint. applyObjectSchemaMask folds each withheld override read as object.field into the fingerprint (object-schema-fls.ts:481-484, :504). RIGHT for D3: the body varies with those reads, so the validator must. An unrestricted caller still returns unchanged with the empty fingerprint (:484). The cached read folds it after the relate step (packages/rest/src/rest-server.ts:6714-6719, :6755) and compares If-None-Match against the folded value (:6764), so two cohorts that differ only on the other object never share a 304. The ADR anchor's invariant (the fingerprint hashes the DENIED set, never the readable one) is kept.

  4. The posture. relate on the project posture (object-schema-fls.ts:360-384) asks the posture's own ask (the D7 preference) about another object with the same context. On a throw or a non-array answer it returns undefined and logs a warn with decision: 'withhold-actions' (:368, :376). RIGHT direction. This is not the served object's D6 ladder: D6 governs the served object's field universe, where failing closed bricks every render; here failing closed withholds only the actions that read the other object, and nothing about that object is served on a guess. Exempt callers return passthrough before relate exists (:323), pinned at test :551 (the service is never asked). Non-blocking note: the undetermined branch increments OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC labelled with the related object (:378), so one counter now counts two decisions; the warn tells them apart by decision.

  5. relateObjectSchemaMaskPosture (object-schema-fls.ts:402). A no-op for any posture but project and for a document without override reads; asks each object once across documents; never throws. RIGHT, pinned at test :591.

  6. Every exit relates. The mask's call sites at the head, by grep over packages/*/src excluding tests: projectMetaObjectSchema is called at packages/rest/src/meta-item-read-gate.ts:2375 (list), :2732 (item) and :2919 under a posture related over every layer (:2915); maskObjectDocument at rest-server.ts:6715 (cached read) and :8597 (published read) takes a related posture; the runtime's maskObjectSchema relates at packages/runtime/src/domains/meta.ts:396, and both its callers (:1158 published, :2000 legacy one-segment) go through it; the runtime's item, list and layered reads use the rest chains (:569, :647, :813). The one remaining call, rest-server.ts:8270, masks { fields: value } on the diff route — a document with no actions, for which objectOverrideReads is empty and nothing changes. RIGHT: D5 coverage is complete; no exit serves an override action without relating, and none over-serves.

  7. The contract fixture. FLS_CONTRACT_OBJECT gains invite (reads contact.name) and escalate (reads contact.bonus_formula) (object-schema-fls-contract.ts:109-110); the retention facts require invite served for id+name (:208), both served with bonus readable (:245), four actions unmasked (:259). RIGHT: an exit that skips the relate step withholds invite and fails by exit name, which the dev's second ablation measured (comment 6000301028, "Ablation 2": 4 failed, all under the uncached item exit). The double answers one set for every object, so the contract cannot express "denied here, readable there"; the unit pins and the dogfood hold that case. Accepted as the dev states it.

  8. The pins. Triage's three (comment 5996976481) are present: the delegated_admin shape is served (object-schema-fls-references.test.ts:509); a denied field of THIS object still drops the action, including an override naming the served object (:517); a field denied on the named object still drops it (:528). Also fail-closed for undetermined, throwing, unknown and unrelated (:537), exempt (:551), name and defaultFromRow (:565), fingerprint cohorts (:580), relate-once (:591). Dogfood (packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts): the defect pin MASKED_BELOW_TENANT_ADMIN is gone and every site is served to every grade (:252); the new case asserts both grades are not served sys_user.role, are served sys_member.role, are served the action, the delegated_admin is offered it and the member is not (:257-271). RIGHT. Serving the action to the member discloses nothing: the member was already served sys_member.invite_user and sys_invitation.invite_user with the same target (census rows in comment 6000301028), and the field the param names is one the member reads.

  9. toEqual to toMatchObject at object-schema-fls.test.ts:281, :291. RIGHT: the posture now carries a function member; no production code serializes or deep-compares a posture (grep at the head: none).

  10. Accept set. No contract accept set narrows. On a real showcase boot the only verdicts that moved across 78 objects × 5 principals × 2 reads are the two dropped to served cells for sys_user.invite_user (comment 6000301028, census). The workspace's other override param, sys_member.invite_user's email on sys_invitation (packages/platform-objects/src/identity/sys-member.object.ts:108), was served to all five before and after.

  11. Public surface of @objectstack/metadata-core. Added: relateObjectSchemaMaskPosture (object-schema-fls.ts:402); optional related and relate on the project member of ObjectSchemaMaskPosture (:176, :185); two actions in the ./testing fixture. MaskScope, actionParamReadsDenied, objectOverrideReads and the new optional third parameter of maskDeniedFieldReferences are exported from object-schema-fls-references.ts, but the barrel does not re-export that module (packages/metadata-core/src/index.ts:86 exports object-schema-fls.js only), so they are not public. Nothing removed or narrowed. The dev's measurement (comment 6000301028, exported_surface) is RIGHT. @objectstack/rest and @objectstack/runtime keep their exported signatures (projectMetaObjectSchema, meta-item-read-gate.ts:2339).

  12. Non-blocking corner. A schema declaring zero fields that carries a withheld override read now passes object-schema-fls.ts:484 and reports emptied: true (:505), where before it returned unchanged; the exit would answer a mask fault. A degenerate shape (the registry always injects system columns), closed rather than open. Noted for a follow-up if it is ever observed; not a defect of this card.

Hygiene: no tracker number reaches a runtime string (the two warn lines carry the ADR id only; test titles were cleaned in commit f0bf6fe). No governed surface in the file list; head repo equals base repo; 579 changed lines; Governed Surface Queue Guard and Check PR Size are success.

② Semver level

.changeset/21884-fls-mask-object-override.md:1-5 declares @objectstack/metadata-core minor, @objectstack/rest patch, @objectstack/runtime patch; line 9 reads Clause-②: yes (widening), and the PR body carries the same line. Judged against the diff: metadata-core adds one export and two optional type members (① item 11) — a widening, so yes (widening) is the right arm and minor meets the "at least minor" floor. rest and runtime change behaviour (they relate) with no exported signature change, so patch is right for a bug fix in a released package. All three are published packages (private unset, version 17.6.0 at the head). Not breaking, so no ADR-0087 disposition marker is owed. The declaration matches the claim as revised at 2026-10-05T18:11Z (comment 5998699624). Check Changeset: success.

③ Boundary flags

The os-dev-report (comment 6000301028) has open_questions: [] and out_of_scope_findings: []. Its seven deviations, each answered:

  1. Lane — packages/rest/src/meta-item-read-gate.ts and rest-server.ts edited. ANSWERED: the exits live there (① item 6), ADR-0106 D5 binds every exit alike, and the claim's revision names both files (comment 5998699624, "The file surface also gains").
  2. object-schema-fls-contract.ts extended, not named by the order. ANSWERED: the claim's revision names it (same comment); the extension is what makes a forgotten exit fail by name (① item 7).
  3. Dogfood case added to the existing file instead of a new one. ANSWERED: the claim asks for "a dogfood test on a real boot under packages/qa/dogfood/test/"; that file pinned the defect itself (MASKED_BELOW_TENANT_ADMIN), so it had to change, and one showcase boot serves both pins.
  4. Clause-② measured yes (widening) against the claim's first no. ANSWERED: the claim was revised in place to yes (widening) on the dev's measurement, and ② above agrees.
  5. Full rest and runtime suites and typecheck measured at 0f9ce970cd, not at the final head. ANSWERED by the head's check-runs: Test Core (6/6 shards), Dogfood Regression Gate (3/3), TypeScript Type Check, Build Core, Lint & Repo Gates, Temporal Conformance (live PG + MySQL) and Governed Surface Queue Guard are all success; 35 runs, 0 failures, 3 path-skipped advisory jobs (Console Pin Gate, Build Docs, Packed-tarball smoke).
  6. Attribution — the model-free trailer pair chosen over the harness reminder. ANSWERED: AGENTS.md governs; the four authored commits carry Claude-Session and the bare Co-authored-by (efa298a, f858694, af06da7, f0bf6fe); the two merges carry git's default message; the PR body footer is the session-URL form.
  7. No permission refusals. Noted.

Triage's constraints (comment 5996976481) hold: no special case for invite_user (the rule is generic, object-schema-fls-references.ts:456); no second eligibility rule; sys-user.object.ts is untouched (file list); the serial PR #21883 landed as 607463d736 before the branch (claim). Nothing to escalate.

Implemented-by: claude/issue-21884-fls-mask-object-override
Reviewed-by: session_017ErfyP2Rx7XWHJA27QjyUi

VERDICT: PASS

Adopted by domain:engine#1 (session_017ErfyP2Rx7XWHJA27QjyUi) at 2026-10-05T18:56Z as the record of head e5e2792cf1, the current head.

The record's two non-blocking notes stay recorded here and are not filed, for these reasons:

  • One undetermined counter now counts two decisions. That is observability wording, not a defect.
  • The zero-field emptied: true corner is closed, not open: it fails toward withholding. The seat read it at object-schema-fls.ts:484–:505.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 18:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 18:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 37360556017 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • (没拿到 job 级信息,点上面的 run 链接看)

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 0 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Merge queue: re-queued after runner loss · domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · 2026-10-05T21:29Z.

The queue removed this PR for CI_FAILURE at 2026-10-05T19:47Z. The queue's CI run (37360556017, on gh-readonly-queue/main/pr-21904-… at 2312a0dfab) failed on one job only: the Test Core aggregate. Its annotation reads "The job was not acquired by Runner of type hosted even after multiple attempts". It ran no step.

  • All six Test Core shards passed in that run.
  • So did Build Core, every Dogfood Regression Gate shard, Temporal Conformance and the other jobs.
  • This is runner loss before any test body ran, not this PR's failure.

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 (e5e2792cf1) and its readings are unchanged. git merge-tree against the current main is clean. A second failure in the queue is treated as real.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants