Skip to content

Commit d11fa8c

Browse files
feat(platform-objects)!: the identity objects declare their default row scope, and the registry refuses an extend that sets one (#24623)
Fixes #24341 Clause-②: no (narrowing) Under ruling E′ (6250748677 on #24164) and ruling A′ (6261536443 on #24449). This resumes the branch from `1bf6312ed9` (release 6259391225), with `main` merged three times (merge commits only, no rebase; the last at `bfc1539fe`), under claim 6267997774. Fix round 1 builds ruling B (6278784335). ## What changes - **`@objectstack/platform-objects` and `@objectstack/plugin-audit`.** The 23 identity objects ruling E′ item 3 names now declare `defaultRowScope`. Each value is the policy the shipped permission sets carry for that object, moved verbatim. `sys_audit_log`'s declaration lives in `plugin-audit`, which owns the ledger (ADR-0029 K2). - `sys_user` is the one merge, per ruling A′ and rider 6255156624. It declares one `select` policy: `id == current_user.id || id in current_user.org_user_ids`. - The platform-global objects declare none (item 5). - The shipped sets keep their spreads. #24484 removes them after this card. - **`@objectstack/objectql` registry.** An `extend` contribution that carries `defaultRowScope` is refused at `registerObject`, whatever its value and whatever its target. The answer is `INVALID_METADATA`, status 422, and the error is named `ExtensionDefaultRowScopeRefusedError`. A contribution without the key registers as before. - **`@objectstack/plugin-security`.** Under ruling 6278784335 (letter B), `collectRLSPolicies` does not add an object's declared default on a delegated principal's own (ceiling) leg; the delegator's leg collects it as any principal's does, and a principal that acts for no one keeps ruling E′ item 2 whole. The test is the read door's own (`onBehalfOf.userId`), in an unexported module function; the method takes the context it collects for as a private parameter, threaded from its six callers. No export moves. - **The declarations' comments** now say that the collection applies the key. #24340's reader is on `main`, so the old "not read yet" was false. - **Changesets.** Three changesets. Two are `minor` and **BREAKING** under the launch-window convention, each with `Clause-②: no (narrowing)` and an `adr-0087: not-required (no-migration-prescription)` marker, which `check-adr-0087-registration` accepts. The third, `24341-delegated-ceiling-no-object-default.md`, covers `plugin-security`, `patch`, `Clause-②: no`: measured against `18.0.0-next.5`, which shipped the reader, it only restores to a delegated agent the rows and explain it had before any object declared a default. - `24341-identity-objects-default-row-scope.md` covers `platform-objects` and `plugin-audit`. - `24341-extend-default-row-scope-refused.md` covers `objectql`. ## Measured on the merged tree **1. The branch's pins after merging `main`.** The enumerating pin (28 tests) and the refusal pin (6 tests) did not move. The plugin-security suite then had 3 tests red out of 3,827. Two pins outside the branch moved, and each was corrected to stay load-bearing: - `object-default-row-scope.test.ts` (#24340's pin). Its "undeclared" shape spread the real `SysUser`, which now carries the key, so its CONTROL read under a default. The undeclared shape now drops the key. - `scim-projection-row-scope.test.ts`. The `mcp_agent_data_write` persona carried `userId: 'agent_x'`, and no producer emits that shape. The one producer of an agent principal is core's `assembleExecutionContext`, which keeps `userId` as the delegating human. With `agent_x`, the agent's own leg applied each SCIM default as `agent_x` and read nothing. With the producer's shape it reads what it read before. The persona now carries the delegator's `userId`. **2. `sys_user`'s one `||` policy on every user-context producer** (`service-automation/src/sys-user-default-row-scope.integration.test.ts`). - **The stack.** The real ObjectKernel, ObjectQL with driver-sql, the identity objects as declared, the real SecurityPlugin, and the real AutomationEngine. No baseline is composed. `usr_me` holds one authored `'*'` read set with no row policy, so the declared default is what applies. Explain names `sys_user_self_or_org_members` as the object default. - **The fixture.** `usr_me` is in organisations A and B and acts in A. `usr_peer` is in A. `usr_other` is in B only. `usr_loner` is in none. - **The check.** Each producer must read the user's own row plus A's members. Each read's captured context must carry `org_user_ids`. Each must read exactly the rows the two shipped literals admit together on that same context. | producer | acting in A | acting in no organisation | |:--|:--|:--| | request (`resolveUserAuthzGrants`, as `resolveAuthzContext` copies it) | `usr_me, usr_peer` | `usr_me` | | `runAs: 'user'` flow run (#24495) | `usr_me, usr_peer` | `usr_me` | | `runAs: 'user'` hook fired from a hand-built `{ isSystem: true, userId }` (#24518) | `usr_me, usr_peer` | `usr_me` | In every row the captured `org_user_ids` is an array (`['usr_me']` with no organisation), and the literals' union equals the served rows. **The one edge** is a grants-resolver failure. A flow run suspended before #24495 shipped is in the same class. Either one leaves the context with no `org_user_ids`. The merged predicate then admits `[]`, own row included. The two literals apart would admit `[u_me]`. The unit pin on the branch measures this divergence. Record 6265937412 ① (hooks) and review 6263414965 ③ (flows) accepted it. It bites only where the default applies, never for a holder of the shipped baseline (see 3). **No escalation.** I found no other context that reads `sys_user` with no `org_user_ids`. The producers of `isSystem: false` contexts are core's `assembleExecutionContext`, the sharing plugin's `{ ...authz }`, explain's `buildContextForUser`, `resolveRunDataContext` and `withRunAs('user')` with its seeding. Each one carries `org_user_ids` from the core resolver, or seeds it. **3. #24340's review ③.11: a deployment with the shipped baseline, on the real declarations** (`plugin-security/src/objects/default-row-scope-declarations.test.ts`). - **Setup.** The real SecurityPlugin with the shipped sets and the platform baseline composed, booted twice: once with all 23 declarers as declared, and once with the key dropped. The fixture binds a security catalog of the shipped sets, and the delegators hold real grants (the viewer `viewer_readonly`, the organisation admin `organization_admin`, the platform administrator `admin_full_access`), so each delegator leg resolves as in production. Nine personas: the five humans (a member, a viewer, an organisation admin, a user in no organisation, the platform admin), and an MCP agent (`mcp_agent_data_read`, the producer's shape) acting for the member, the viewer, the organisation admin and the platform administrator. - **Rows.** Every persona reads every declarer **byte-for-byte as before**. For each delegated agent the served ids are also asserted positively on both trees; the platform administrator's agent reads every row of `sys_user`, `sys_session` and `sys_scim_user` on both. - **Explain.** `read` explain is byte-for-byte as before for every persona, the four delegated agents included. - **Census.** No persona collects a declared default on any of the 23 objects for any operation: each human holds `member_default`, which carries a policy for each declarer on every operation, and a delegated agent's own leg collects none (letter B). CONTROL: the same agent context with no delegator (a hand-built shape no producer emits) collects each declarer's default on exactly the operations it names (all four on 8 objects, `select` on the other 15). - **The superuser bypass** is decided before the collection, so the platform admin collects nothing. - **Cost.** The comparison takes 6.3 s of the file's 6.6 s test time on the shared box. Explain is compared for `read` only, because the census covers the collection for all four operations, and the collection is the one source every door and every explain composes from. **4. The `extend` rule on the real tree.** - **Census by grep.** Of every `objectExtensions` entry and every `registerObject(..., 'extend')` caller in `packages/` and `examples/`, the one `extend` of a platform object is `@objectstack/plugin-auth`'s `sys_sso_provider` extension. The other two extend `showcase_account` and `dc_account`. - **Booted.** The integration test boots the composition and lists every `extend` contributor the registry holds: exactly `com.objectstack.plugin-auth → sys_sso_provider`, folded, with `oidc_client_secret` present. - **Refused by name.** A contribution that sets `defaultRowScope` on `sys_user` in that same booted registry is refused: `INVALID_METADATA`, 422, `ExtensionDefaultRowScopeRefusedError`. `sys_user`'s declared default stands, and the contributor is not recorded. **5. Public surface.** - No new symbol on either `objectql` entry. The built `dist/index.js` and `dist/core.js` expose neither `EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE` nor `ExtensionDefaultRowScopeRefusedError`, with `SchemaRegistry` present as the positive control. Both `.d.ts` entries name neither. - The error class is unexported. The code constant is a module-level export of `registry.ts`, read by its own pin only, and no entry re-exports it. - No new public member on an exported class. ## Ablations (each committed first, then restored with `git checkout HEAD`, blob and porcelain checked) 1. **`sys_user`'s declaration with the peer arm removed.** `using` became `id == current_user.id && id != null`. I rebuilt `platform-objects`, and `ablation-dist-preflight` found the marker in 6 dist files. The integration pin went **4 failed / 6 passed**: the premise test, plus the acting-in-A case for each of the three producers. The no-organisation cases stayed green, as expected. After the restore and rebuild, the marker was absent from all 66 dist files and the run was 10 / 10. 2. **The ③.11 pin's teeth.** I deleted the `sys_scim_user_self` literal from `scimProjectionRowScope`, which simulates a spread removed while the default carries the row scope. The declarations file went **3 failed / 27 passed**: the literal pin, the byte-for-byte rows, and the census. After the restore, the blob matched HEAD, the porcelain was empty, and the run was 30 / 30. 3. **The B guard removed** (`isDelegatedPrincipalLeg` dropped from the empty-collection return). The declarations file went **3 failed / 28 passed**: the byte-for-byte rows and explain, the delegated-agent pin (`mcp agent for the platform admin on sys_user, after: expected [ 'p_admin' ]` against all six), and the census. Restored: blob `d31b3ba9c4` == HEAD, porcelain empty, 31 / 31. The refusal's own ablation is the branch's (release 6259391225): 4 red and 2 green, then restored. ## Pin sweep - **Same-semantic pins.** I grepped `ExtensionDefaultRowScopeRefusedError`, the refusal message, and `defaultRowScope` beside `'extend'`. They are pinned in `objectql/src/registry-extension-default-row-scope-refused.test.ts` and the new integration test only. - **Pins on `sys_user`'s two literals** all pass unchanged. They hold only while the sets keep their spreads, which they do: - `member-default-explicit-allow`, `identity-admin-field-group`, `default-permission-sets`, `rbac-objects`, `security-plugin`, `authz-matrix-gate` and `platform-ownership-policies` (plugin-security); - `sys-user-self-service-route` (plugin-auth); - the two #24495 and #24518 twins (service-automation); - `identity-admin-fields-org-peer.dogfood` and `zero-set-masking.dogfood`, run locally: 13 / 13. - **Pins on served identity rows in no-baseline compositions** pass: `resolve-execution-context.oauth-organization` (runtime) and `serve-verify-security-parity.contract` (cli). - **What moved** is the two pins in 1 above. ## Verification record **Fix round 1, head `f1049494f`** (after merging `main` at `bfc1539fe`): - `plugin-security` 189 files / 3,785 tests (+45 skipped); `platform-objects` 78 / 1,192; `plugin-audit` 57 / 930; `objectql` 449 / 8,453. - The `service-automation` integration (the new pin and both twins): 19 / 19, against a rebuilt `plugin-security` dist. - Core `test:repo` 6 / 82. - Typecheck exit 0 for `plugin-security`, `platform-objects`, `objectql`, `plugin-audit` and `service-automation`. - Gates: 87 derived, 87 run, all exit 0; `--ran` reports a derived zero. - `main` has moved since `bfc1539fe`; `git merge-tree` against it is clean. **Round 0, head `efaf66a51`** (after the second merge of `main` at `5a871e4e4`): - **Builds.** `pnpm turbo run build --concurrency=2 --filter='!@objectstack/docs'`: 76 / 76. - **Typecheck,** exit 0 for each of `objectql`, `platform-objects`, `plugin-audit`, `plugin-security` and `service-automation`. `service-automation`'s `tsc --listFiles` includes the new integration test. `plugin-security` and `objectql` exclude tests from `tsc`, and their `check:test-typecheck` passed inside the same script. - **Tests,** exit 0: - `objectql`: 447 files, 8,405 tests; - `platform-objects`: 77 files, 1,184 tests; - `plugin-audit`: 56 files, 898 tests; - `plugin-security`: 189 files, 3,784 tests, 45 skipped; - `service-automation`: 192 files, 2,556 tests; - `pnpm --filter @objectstack/core test:repo`: 6 files, 82 tests. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` with no paths derives 87 families, and all 87 ran on `efaf66a51`, each exit 0, captured before any pipe. `--ran` reconciles: **87 derived, 87 run, 0 NOT-MEASURED, 0 UNRUN**, a derived zero. On the first head, two families exited 3 (PREREQUISITE NOT MET), `check:dual-build-cjs-loads` and `check:i18n`. After the full build both exit 0. - **Ratchets fixed on this branch before the PR.** - `check:query-options-erasure`, test surface 236 to 237: one `as any` on a `find` options bag in the new test. - `check:slot-lookup`: one `getService` lookup typed `any`. - Both are now typed, and both ratchets hold at their baselines. ## Acceptance notes - **Contract review.** `CONTRACT_REVIEW_TIER` record 6273333026 was FAIL on `efaf66a51` (a delegated platform administrator's agent narrowed, undeclared). The maintainer ruled B (6278784335). Delta record 6280315230 is PASS on `f1049494f`. - **Packages beyond the claim's two.** `@objectstack/plugin-audit` is bumped in the declarations changeset, because `sys_audit_log`'s declaration ships there. - **Pin locations outside the claim's file surface.** These are pins only, and no source outside the fence moved: - the new integration test in `service-automation`, which is the package that composes request, flow and hook on one stack, like its two twins; - the two corrected plugin-security pins in 1 above. - **`private-credential-row-scope.test.ts`** also carries an agent persona with `userId: 'agent_x'`, a shape no producer emits. It passes, because no row is readable there under either reading, so it was left as is. - **The second merge of `main`** brought in Changesets pre-mode exit (`c46b5f14c`). The changesets' "pre mode" aside was dropped to keep them true. --- _Generated by [Claude Code](https://claude.ai/code/session_01FatVyDAFEST48jZDQcbheU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d117382 commit d11fa8c

36 files changed

Lines changed: 1535 additions & 7 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a delegated agent's own leg does not collect an object's declared default row scope (#24341)
6+
7+
Clause-②: no
8+
9+
An agent acting on behalf of a user (ADR-0090 D10) is judged on two legs that the read door ANDs together: its own sets, which are a capability ceiling and carry no row policy, and its delegator's sets. Since the row-policy collection began applying an object's declared `defaultRowScope` where the held sets carry no row policy, the ceiling's silent leg collected that default too. Where the delegator's own leg is wider than the default, the agent then read fewer rows than its delegator. The platform administrator is the case in point: the administrator's own leg skips row policies on the identity objects, so an agent acting for the administrator would have read only the administrator's own rows there, where it read every row before.
10+
11+
The collection no longer adds an object's default on a delegated principal's own leg, so a ceiling's silence is not read as the object's default. The delegator's leg collects the default exactly as any principal's does, and a principal that acts for no one keeps the default whenever its sets are silent.
12+
13+
What moves, against `18.0.0-next.5`: on an object that declares `defaultRowScope`, an agent acting for a user reads the rows its delegator's side admits, and its explain output names no object default on the agent's side. Before this release's reader, that was already so.
14+
15+
**Unchanged.**
16+
17+
- A principal that acts for no one, a human or otherwise, collects an object's default when its sets carry no row policy for the object and operation, as before.
18+
- The delegator's side, the superuser bypass, and every authored policy are judged as before.
19+
- No export, option or type moves.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(objectql)!: the registry refuses an `extend` contribution that carries `defaultRowScope` (#24341)
6+
7+
Clause-②: no (narrowing)
8+
9+
An object's default row scope is declared by the object's owner alone. `SchemaRegistry.registerObject` now refuses an `extend` contribution that carries `defaultRowScope`, whatever its value and whatever the target, with `INVALID_METADATA` (`status: 422`) and the error name `ExtensionDefaultRowScopeRefusedError`, naming the object and the extending package. It is refused before the contribution is recorded, so nothing of it lands, and the owner's declaration stays in force.
10+
11+
Every value is refused because no narrower line holds. Whether one row predicate admits more rows than another cannot be decided in general, so a "loosening" cannot be told from a "narrowing". And the extension merge never carried the key onto the object, so an accepted one restricted nothing while its author believed it did.
12+
13+
**BREAKING — what moves for consumers.** This is an accept-set narrowing on the registry's `extend` door, shipped as `minor` under the launch-window convention for accept-set narrowings.
14+
15+
- FROM: `registerObject(def, packageId, undefined, 'extend')` with `def.defaultRowScope` set registered the contribution and dropped the key.
16+
- TO: it throws `INVALID_METADATA` (`422`, `ExtensionDefaultRowScopeRefusedError`).
17+
- Fix: remove `defaultRowScope` from the extension. To scope the rows the holders of a permission set read on the object, give that set a `rowLevelSecurity` policy for it.
18+
19+
**Unchanged.**
20+
21+
- An `extend` contribution that leaves the key out, or sets it to `undefined`, registers and folds as before. The one shipped `extend` of a platform object in this repository (`@objectstack/plugin-auth`'s `sys_sso_provider` extension) registers, measured on a booted composition.
22+
- A manifest's `objectExtensions` entry never reaches this refusal: its schema is strict and refuses the key at authoring, and `ObjectQL.registerApp` copies a closed key list off each entry.
23+
- The owner's own `defaultRowScope` registers and re-registers as before.
24+
- No export moves: the error class is not exported, and its code is the existing `INVALID_METADATA`.
25+
26+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at the registry's extend door of a key the extension merge never carried: no authorable key, spelling, export or stored shape moves, and the manifest path already refuses the key at authoring. The repair is removing the key from a programmatic extend call, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not an interface, a type or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/plugin-audit': minor
4+
---
5+
6+
feat(platform-objects)!: the platform identity objects declare their default row scope (#24341)
7+
8+
Clause-②: no (narrowing)
9+
10+
Twenty-three platform objects now declare `defaultRowScope`, each with the row policy the shipped permission sets of `@objectstack/plugin-security` carry for it, moved verbatim: the seven SCIM projections (`sys_scim_user`, `sys_scim_subject`, `sys_scim_projection_grant`, `sys_scim_identity_tombstone`, `sys_scim_group`, `sys_scim_group_member`, `sys_scim_connection_binding`), `sys_verification` and `sys_jwks`, `sys_organization`, `sys_user`, `sys_session`, `sys_account`, `sys_team_member`, `sys_two_factor`, `sys_user_preference`, `sys_api_key`, `sys_device_code`, `sys_oauth_access_token`, `sys_oauth_refresh_token`, `sys_oauth_consent`, `sys_oauth_application` (`@objectstack/platform-objects`) and `sys_audit_log` (`@objectstack/plugin-audit`, which owns the ledger). `sys_user` is the one merge: the key holds one policy, so its two shipped policies are joined into `id == current_user.id || id in current_user.org_user_ids`, a `select` policy. The platform-global objects (`sys_metadata*`, `sys_job*`, `sys_migration*`, `sys_platform_setting`, `sys_presence`, `sys_flow_dispatch`) declare none.
11+
12+
The security plugin's row-policy collection applies an object's declared default when the permission sets the caller holds carry no row policy for that object and operation. The shipped sets still carry the same policies, so a holder of them reads exactly what it read before.
13+
14+
**BREAKING — what moves for consumers.** This narrows what a principal reads on these objects when its sets carry no row policy for them, shipped as `minor` under the launch-window convention for accept-set narrowings.
15+
16+
- FROM: a principal holding no platform baseline (a deployment that sets the security plugin's `fallbackPermissionSet: null`) whose sets grant read on an identity object and carry no row policy for it read every row of that object.
17+
- TO: it reads the object's declared default: its own rows (`user_id == current_user.id`); on `sys_user`, its own row plus the members of the organisation it acts in; on `sys_organization`, its own organisation; no row on `sys_verification`, `sys_jwks`, `sys_scim_group`, `sys_scim_group_member` and `sys_scim_connection_binding`; on `sys_audit_log`, its organisation's entries where organisation scoping is on (the platform's tenant policies, this one included, are stripped where it is off).
18+
- Fix: to give such a principal more rows, give one of its permission sets a `rowLevelSecurity` policy for the object. A policy a held set carries is used as authored, and the default is not added to it.
19+
20+
**Unchanged.**
21+
22+
- A deployment with the platform baseline (`member_default`, the default) serves every human exactly what it served before, on every one of the 23 objects and every operation: each human holds the baseline, and the baseline carries a policy for each object and operation. Measured on a booted composition with a member, a viewer, an organisation admin, a user in no organisation and the platform administrator, before and after the declarations.
23+
- An MCP agent acting for a user reads the same rows, and gets the same explain output, as before. Its ceiling set carries no row policy, and the security plugin does not collect an object's declared default on a delegated principal's own side of the intersection (see the `@objectstack/plugin-security` entry), so the agent's rows stay bounded by its delegator's side alone. Measured on a booted composition, before and after the declarations, for the agents of a member, a viewer, an organisation admin and the platform administrator; the platform administrator's agent still reads every row.
24+
- The platform administrator's superuser bypass is decided before any row policy is collected.
25+
- No export, option or stored shape moves.
26+
27+
<!-- adr-0087: not-required (no-migration-prescription) declarations of a row scope on platform objects, each the policy the shipped permission sets already carry: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. What narrows is the rows a principal with no baseline and no row policy of its own reads, and the repair is a deployer's choice of a `rowLevelSecurity` policy, which no ledger entry can derive. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers row scopes (not already-registered); and the change is a runtime row scope, not an interface, a type or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0095 D1, 2026-10-18 note; ruling of record 6250748677 on #24164, letter
5+
* E′ item 4] An `extend` contribution cannot set or loosen an object's declared
6+
* default row scope: `registerObject` refuses one that carries
7+
* `defaultRowScope`, by name, before it opens the contributor list.
8+
*
9+
* The refusal is pinned on its envelope (`code` + `status`) and its `name`, the
10+
* condition `INVALID_METADATA` shares with other refusals in this package —
11+
* never on a bare throw. Its controls: an extension that leaves the key alone
12+
* still registers and still folds, and the owner's own declaration of the key
13+
* is untouched by either.
14+
*/
15+
16+
import { describe, it, expect } from 'vitest';
17+
import { EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE, SchemaRegistry } from './registry.js';
18+
19+
const OWNER_PKG = 'com.objectstack.platform-objects';
20+
const EXTENDER_PKG = 'app.extender';
21+
const OBJECT_NAME = 'sys_user';
22+
23+
/** The owner's declaration — `sys_user`'s own default row scope. */
24+
const OWNER_SCOPE = {
25+
name: 'sys_user_self_or_org_members',
26+
operation: 'select',
27+
using: 'id == current_user.id || id in current_user.org_user_ids',
28+
};
29+
30+
function registryWithOwner(): SchemaRegistry {
31+
const r = new SchemaRegistry({ multiTenant: false });
32+
r.logLevel = 'silent';
33+
r.registerObject(
34+
{
35+
name: OBJECT_NAME,
36+
label: 'User',
37+
defaultRowScope: OWNER_SCOPE,
38+
fields: { email: { name: 'email', type: 'text', label: 'Email' } },
39+
} as any,
40+
OWNER_PKG,
41+
);
42+
return r;
43+
}
44+
45+
function refusalOf(fn: () => unknown): any {
46+
try {
47+
fn();
48+
} catch (e) {
49+
return e;
50+
}
51+
return undefined;
52+
}
53+
54+
describe('registerObject refuses an `extend` contribution that carries `defaultRowScope`', () => {
55+
it.each([
56+
['loosens it to every row', { name: 'everyone', operation: 'select', using: 'id != null' }],
57+
['repeats the owner\'s own scope', OWNER_SCOPE],
58+
['narrows it to no row', { name: 'nobody', operation: 'all', using: 'id == null' }],
59+
])('refuses an extension that %s, by name, and the object keeps the owner\'s scope', (_label, scope) => {
60+
const r = registryWithOwner();
61+
const err = refusalOf(() =>
62+
r.registerObject(
63+
{
64+
name: OBJECT_NAME,
65+
defaultRowScope: scope,
66+
fields: { nickname: { name: 'nickname', type: 'text', label: 'Nickname' } },
67+
} as any,
68+
EXTENDER_PKG,
69+
undefined,
70+
'extend',
71+
),
72+
);
73+
74+
expect(err).toBeDefined();
75+
expect({ code: err.code, status: err.status, name: err.name }).toEqual({
76+
code: EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE,
77+
status: 422,
78+
name: 'ExtensionDefaultRowScopeRefusedError',
79+
});
80+
expect(EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE).toBe('INVALID_METADATA');
81+
expect({ objectName: err.objectName, packageId: err.packageId }).toEqual({
82+
objectName: OBJECT_NAME,
83+
packageId: EXTENDER_PKG,
84+
});
85+
86+
// Nothing of the refused contribution landed: no contributor, no field.
87+
expect(r.getObjectContributors(OBJECT_NAME).map((c) => c.packageId)).toEqual([OWNER_PKG]);
88+
const resolved = r.getObject(OBJECT_NAME) as any;
89+
expect(resolved.defaultRowScope).toEqual(OWNER_SCOPE);
90+
expect(resolved.fields.nickname).toBeUndefined();
91+
});
92+
93+
it('refuses it on an object whose owner declares no default too: the fold would never carry it', () => {
94+
const r = new SchemaRegistry({ multiTenant: false });
95+
r.logLevel = 'silent';
96+
r.registerObject(
97+
{ name: 'app_ticket', label: 'Ticket', fields: { title: { name: 'title', type: 'text', label: 'Title' } } } as any,
98+
'app.owner',
99+
);
100+
const err = refusalOf(() =>
101+
r.registerObject(
102+
{ name: 'app_ticket', defaultRowScope: { name: 'own', operation: 'select', using: 'id != null' } } as any,
103+
EXTENDER_PKG,
104+
undefined,
105+
'extend',
106+
),
107+
);
108+
expect({ code: err?.code, status: err?.status, name: err?.name }).toEqual({
109+
code: 'INVALID_METADATA',
110+
status: 422,
111+
name: 'ExtensionDefaultRowScopeRefusedError',
112+
});
113+
expect((r.getObject('app_ticket') as any).defaultRowScope).toBeUndefined();
114+
});
115+
116+
it('control: an extension that leaves `defaultRowScope` alone registers and folds as before', () => {
117+
const r = registryWithOwner();
118+
r.registerObject(
119+
{
120+
name: OBJECT_NAME,
121+
label: 'Person',
122+
fields: { nickname: { name: 'nickname', type: 'text', label: 'Nickname' } },
123+
} as any,
124+
EXTENDER_PKG,
125+
undefined,
126+
'extend',
127+
);
128+
// An explicitly-undefined key sets nothing, so it is not a contribution of one.
129+
r.registerObject(
130+
{ name: OBJECT_NAME, defaultRowScope: undefined, fields: { pronouns: { name: 'pronouns', type: 'text', label: 'Pronouns' } } } as any,
131+
'app.second-extender',
132+
undefined,
133+
'extend',
134+
);
135+
136+
expect(r.getObjectContributors(OBJECT_NAME).map((c) => c.packageId)).toEqual([
137+
OWNER_PKG,
138+
EXTENDER_PKG,
139+
'app.second-extender',
140+
]);
141+
const resolved = r.getObject(OBJECT_NAME) as any;
142+
expect(resolved.label).toBe('Person');
143+
expect(Object.keys(resolved.fields)).toEqual(expect.arrayContaining(['email', 'nickname', 'pronouns']));
144+
expect(resolved.defaultRowScope).toEqual(OWNER_SCOPE);
145+
});
146+
147+
it('control: the owner\'s own `defaultRowScope` registers, and re-registers, unrefused', () => {
148+
const r = registryWithOwner();
149+
expect(() =>
150+
r.registerObject(
151+
{ name: OBJECT_NAME, label: 'User', defaultRowScope: OWNER_SCOPE, fields: {} } as any,
152+
OWNER_PKG,
153+
),
154+
).not.toThrow();
155+
expect((r.getObject(OBJECT_NAME) as any).defaultRowScope).toEqual(OWNER_SCOPE);
156+
});
157+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,65 @@ class ObjectFieldTypeRefusedError extends Error implements ObjectFieldTypeRefusa
14651465
}
14661466
}
14671467

1468+
/**
1469+
* {@link ExtensionDefaultRowScopeRefusedError}'s code — `INVALID_METADATA`,
1470+
* this package's spelling for "a programmatically supplied metadata body does
1471+
* not satisfy the spec schema" (see {@link OBJECT_FIELD_TYPE_REFUSED_CODE}):
1472+
* `ObjectExtensionSchema` is strict and declares no `defaultRowScope`, so the
1473+
* parse refuses the same body at authoring. ⛔ Not a new code. The condition is
1474+
* named by the error's `name`.
1475+
*/
1476+
export const EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE = 'INVALID_METADATA' as const;
1477+
1478+
/**
1479+
* [ADR-0095 D1, 2026-10-18 note; ruling of record 6250748677 on #24164, letter
1480+
* E′ item 4] An `extend` contribution carried `defaultRowScope`. An object's
1481+
* default row scope is the owning declaration's alone: the platform identity
1482+
* objects declare theirs (Layer 1 silence means that scope, not no filter), and
1483+
* an extender "cannot set or loosen a platform object's declared default;
1484+
* refused at registration with a named reason".
1485+
*
1486+
* Refused whatever the value and whatever the target, because no other line is
1487+
* decidable or honest: whether one row predicate admits more rows than another
1488+
* is not decidable for CEL predicates in general, so "loosens" cannot be judged
1489+
* pairwise, and the ruling's own verb is "set"; and the fold
1490+
* ({@link mergeObjectDefinitions}) never carries the key onto the resolved
1491+
* object, so accepting it on any target would be a row restriction its author
1492+
* believes is in force and nothing applies. A contribution that leaves the key
1493+
* alone registers exactly as before.
1494+
*
1495+
* Reachable only by a caller that skips the parse — a direct
1496+
* `registerObject(def, pkg, undefined, 'extend')` call: the manifest path
1497+
* (`ObjectQL.registerApp`) copies a closed key list off each
1498+
* `objectExtensions` entry, and the entry's schema refuses the key at
1499+
* authoring.
1500+
*
1501+
* The ADR-0112 envelope (`code` + `status: 422`), never a bare throw.
1502+
*/
1503+
class ExtensionDefaultRowScopeRefusedError extends Error {
1504+
readonly code = EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE;
1505+
readonly status = 422;
1506+
/** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */
1507+
readonly httpStatus = 422;
1508+
/** The object the extension targets. */
1509+
readonly objectName: string;
1510+
/** The package whose `extend` contribution this refusal stopped. */
1511+
readonly packageId: string | undefined;
1512+
1513+
constructor(objectName: string, packageId: string | undefined) {
1514+
super(
1515+
`Object "${objectName}": the \`extend\` contribution from package "${packageId}" carries ` +
1516+
'`defaultRowScope`. An object\'s default row scope is declared only by the object\'s owner; an ' +
1517+
'extension can neither set nor loosen it, and the extension merge never carries it onto the ' +
1518+
'object. Remove `defaultRowScope` from the extension. To scope the rows a permission set\'s ' +
1519+
'holders read, give that set a `rowLevelSecurity` policy for the object.',
1520+
);
1521+
this.name = 'ExtensionDefaultRowScopeRefusedError';
1522+
this.objectName = objectName;
1523+
this.packageId = packageId;
1524+
}
1525+
}
1526+
14681527
/**
14691528
* [#16319] The registration-door field-`type` check — the single point of
14701529
* closure the ruling names.
@@ -3246,6 +3305,15 @@ export class SchemaRegistry {
32463305
const fieldTypeRefusal = objectFieldTypeRefusal(schema);
32473306
if (fieldTypeRefusal) throw fieldTypeRefusal;
32483307

3308+
// [ADR-0095 D1 note; ruling E′ item 4] An `extend` contribution never sets
3309+
// the object's default row scope — see
3310+
// {@link ExtensionDefaultRowScopeRefusedError}. Here, beside the field-`type`
3311+
// door and for the same reason: before any materialization and before the
3312+
// contributor list is opened, so a refused contribution leaves nothing behind.
3313+
if (ownership === 'extend' && (schema as { defaultRowScope?: unknown }).defaultRowScope !== undefined) {
3314+
throw new ExtensionDefaultRowScopeRefusedError(schema.name, packageId);
3315+
}
3316+
32493317
// [#23065] A `tree` field that names no `reference` references the object
32503318
// that declares it, and this is where that is written down for every door
32513319
// the parse does not cover: a metadata save stores the authored body

0 commit comments

Comments
 (0)