Skip to content

Commit 083b60f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23050-s13b-meta-item-read
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
2 parents 7b56222 + 2bcd406 commit 083b60f

17 files changed

Lines changed: 1046 additions & 29 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): an OAuth MCP agent acts in the organization the user's consent bound, so the user's organization-scoped grants count for it
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** On the `/mcp` door, `resolveExecutionContext` built a verified OAuth access token's principal with no organization. The agent's delegator (the user it acts for, ADR-0090 D10) was then resolved with no active organization and held only its global grants. Every grant an administrator assigns is scoped to an organization, so an ordinary user's agent was refused every object operation: `on-behalf-of principal may not 'find' … the delegator lacks that grant (ADR-0090 D10 intersection)`.
10+
- **What changed.** The principal is resolved in the organization the consent bound: the `organizationId` that `IAuthService.verifyMcpAccessToken` returns from the token's `organization_id` claim. It is handed to the shared resolver where a session carries its active organization, so it is vetted on every request by the rule a browser session's organization gets (`vetOrganizationClaim`, `@objectstack/core`). The user's grants scoped to that organization count for the agent; grants scoped to the user's other organizations do not. It is never the union of the user's organizations, never the organization the user's browser session has selected now, and never a widened global grant.
11+
- **A membership that is gone.** Under a walled tenancy posture (`isolated`, `group`), a token whose organization the user no longer belongs to resolves with no organization, so only global grants apply. Under `isolated`, a former member's agent gets an empty `200` read and a refused tenant write. The same holds for an organization the user never belonged to, whatever put it on the token. Under `single`, or with no `tenancy` service, the organization stands as claimed, exactly as a browser session's does there.
12+
- **Unchanged.** A token with no `organization_id` (minted before consents bound one, or by a user with no active organization at consent) resolves with no organization, as before: global grants only, until the user re-confirms the consent once. A platform admin's global grant applies as before. The API-key and session tracks are untouched.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: the by-name `/meta` read answers a disabled package's item of every type but `object` and `package` as absent, as the list already omits it
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of a runtime read door, getMetaItem for an item whose owning package is disabled: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the read -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
Disabling a package already took its apps, views, pages, dashboards, flows and every other item out of `GET /meta/:type`. The by-name read `GET /meta/:type/:name` still served them. It now answers such an item the way it answers a name with nothing behind it.
14+
15+
- **Absent now:** `getMetaItem({ type, name })` for an item whose `_packageId` names a disabled package, for every type except `object` and `package`. The active read and `previewDrafts` serve no `item`, which both transports answer `404 RESOURCE_NOT_FOUND`, byte for byte the answer for a missing name. The strict draft read (`state: 'draft'`) throws the missing draft's `404 NO_DRAFT`. The list read and the by-name read ask one predicate, so they hide the same items.
16+
- **Kept:** the protection envelope (`lock`, `editable`, `deletable`, `version` and the rest) still reports the write doors' verdicts about the address, because a disable does not change how the lock gate judges a save there. Only `item` and a flow's `registration` are withheld.
17+
- **Unchanged:** objects, which keep their `404 OBJECT_PACKAGE_DISABLED`; packages, which are never hidden, so a disabled package can still be read and re-enabled; items of enabled packages; and `getMetaItemLayered` (`?layers=true`, `/layers`) and `/published`, which still serve a disabled package's non-object item.
18+
19+
What changes for you: a client that read a disabled package's app, view, page or other item by name now gets `404 RESOURCE_NOT_FOUND`, as for a missing name. Re-enable the package to read it again.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): the security catalog echo keeps naming a disabled package's permission set or position as that package's
6+
7+
The echo reads which package owns a stored overlay from the protocol's by-name read. That read now withholds a disabled package's item and keeps its protection envelope, so the echo reads the envelope's `packageId` instead of `item._packageId`. Before, a disabled package's set was echoed unowned, lost its ADR-0090 D5 audience-binding suggestion, and stayed unowned after the package was re-enabled until the next save, publish or boot. Enforcement is unchanged.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest)!: `/history`, `/audit` and `/diff` answer a disabled package's item with the plain read's absence, never past the per-caller gate
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of three runtime read doors for an item whose owning package is disabled: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the read -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The by-name read now answers a disabled package's item as absent. The event and diff doors judge the item the plain read would judge, and they read "no item" as "nothing behind the name": events served whole, and a view's or dashboard's diff served whole, to a caller the item's own gate refuses.
14+
15+
- **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name/history`, `/audit` and `/diff` for an `app`, `view`, `dashboard`, `doc` or `book` the plain read withholds while its layers still resolve, which is a disabled package's item. No events and no diff are read.
16+
- **Unchanged:** an item that is really gone (every layer null, such as a deleted item) keeps its answer: its events, and its diff as before. Items of enabled packages keep the per-caller gate as before.
17+
18+
What changes for you: re-enable the package to read its items' history, audit trail and diff again.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: the dispatcher's `/meta` doors that read past the protocol answer a disabled package's object or item as the protocol's by-name read does
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of runtime read doors on the dispatcher transport: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the read -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The registry keeps a disabled package's objects on purpose, and the metadata service knows nothing of a disable. Four dispatcher doors read one of them for a disabled package's object or item and served it.
14+
15+
- **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/state/:field`, and the legacy one-segment `GET /meta/:name` exit, for an object whose owning package is disabled. Both read `registry.getObject`; they now answer the protocol's refusal, the answer `GET /meta/object/:name` gives.
16+
- **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/published`. When the layered read refused the object, this door fell through to the published snapshot and served it. The refusal is now the answer, as on `RestServer`'s `/published`.
17+
- **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name` for a non-object item of a disabled package. The protocol answers it as absent, and the door no longer serves the metadata service's copy in its place. The body is byte for byte the answer for a missing name.
18+
- **Unchanged:** objects and items of enabled packages, and every other fall-through.
19+
20+
What changes for you: re-enable the package to read the object or item again.

‎content/docs/ai/agents.mdx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,20 @@ SKILL.md download (`GET /api/v1/mcp/skill`), and API-key minting.
6565
private deployments work out of the box**. You authorize the client in the
6666
browser; it then acts as an **agent on your behalf** (`principalKind: 'agent'`),
6767
its **data access** bounded by the **intersection** of the consent scopes and
68-
your own permissions/RLS — it can never exceed either. For object CRUD the
68+
your own permissions/RLS — it can never exceed either. The agent acts in
69+
**one organization**: the one you had selected when you consented, carried
70+
in the token as its `organization_id` claim. Your grants scoped to that
71+
organization count for it; grants scoped to your other organizations do not
72+
(select the other organization and authorize the client again to work
73+
there). Your membership is re-checked on every request by the rule a
74+
browser session's organization gets: under an organization wall (`isolated`
75+
or `group` tenancy) a token whose organization you no longer belong to falls
76+
back to **no organization**, so only your global grants apply — under
77+
`isolated`, a former member's agent gets an **empty `200`** on reads and a
78+
refusal on writes to organization data. Under `single` (no wall) the
79+
organization stands as consented, as a session's does. A token issued
80+
before consents carried an organization holds your global grants only until
81+
you re-confirm the consent once. For object CRUD the
6982
consent scopes (`data:read`, `data:write`) are a real ceiling, not just a
7083
tool-family filter: they narrow the exposed tools **and** cap what the agent
7184
can do at the data layer — a `data:read` token can never write a record even

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,35 @@ function isObjectOfDisabledPackage(registry: any, name: string): boolean {
533533
&& registry.isObjectPackageDisabled(name) === true;
534534
}
535535

536+
/**
537+
* [#7557, #24210] Whether `item` belongs to a disabled package: its
538+
* `_packageId`, asked of `registry.isPackageDisabled`. The ONE predicate for a
539+
* non-object item, asked by the list read ({@link
540+
* ObjectStackProtocolImplementation.getMetaItems}, which omits the item) and
541+
* the by-name read ({@link ObjectStackProtocolImplementation.getMetaItem},
542+
* which answers as for a name with nothing behind it), so the two cannot
543+
* disagree about which items a disable hides. Optional-called, as {@link
544+
* isObjectOfDisabledPackage} is: a host whose registry cannot answer has no
545+
* item hidden.
546+
*/
547+
function isItemOfDisabledPackage(registry: any, item: unknown): boolean {
548+
return typeof registry?.isPackageDisabled === 'function'
549+
&& registry.isPackageDisabled((item as { _packageId?: unknown } | null | undefined)?._packageId) === true;
550+
}
551+
552+
/**
553+
* The strict draft read's `404 NO_DRAFT`: no pending draft at this address
554+
* (ADR-0005 §draft-lifecycle). [#24210] One constructor for the read that
555+
* finds no draft row and the read of a disabled package's draft, so the two
556+
* answers are byte-identical.
557+
*/
558+
function noPendingDraftError(type: string, name: string): Error & { code: 'NO_DRAFT'; status: 404 } {
559+
const err = new Error(`No pending draft exists for ${type}/${name}.`) as Error & { code: 'NO_DRAFT'; status: 404 };
560+
err.code = 'NO_DRAFT';
561+
err.status = 404;
562+
return err;
563+
}
564+
536565
/**
537566
* [#17501] Does a converted JSON Schema carry any authorable surface at all?
538567
*
@@ -10375,10 +10404,11 @@ export class ObjectStackProtocolImplementation implements
1037510404
// `assertObjectRegistered` (`OBJECT_PACKAGE_DISABLED`) — absence here
1037610405
// and refusal there are the two halves of one answer, pinned together
1037710406
// by `protocol.package-disable-enforcement.test.ts`.
10407+
//
10408+
// [#24210] The predicate is {@link isItemOfDisabledPackage}, the one the
10409+
// by-name read asks, so the list and the by-name read hide the same items.
1037810410
if (request.type !== 'package') {
10379-
items = (items as any[]).filter(
10380-
(it) => !this.engine.registry.isPackageDisabled((it as any)?._packageId),
10381-
);
10411+
items = (items as any[]).filter((it) => !isItemOfDisabledPackage(this.engine.registry, it));
1038210412
}
1038310413

1038410414
// Canonical-shape exposure (ADR-0017, "Object has-many View"): a
@@ -11046,6 +11076,19 @@ export class ObjectStackProtocolImplementation implements
1104611076
* that must still see the object (the save's destructive-change check)
1104711077
* read {@link readMetaItem}, the shared body, instead: the reader-class
1104811078
* split `package-disable-enforcement.test.ts` pins.
11079+
*
11080+
* [#7557, #24210] Every other type but `package` has no data plane, so a
11081+
* disabled package's item is answered as ABSENT, the answer a name with
11082+
* nothing behind it gets: the list read omits that item, by the same
11083+
* predicate ({@link isItemOfDisabledPackage}) asked of the item this read
11084+
* resolved, in every state. The strict draft read throws the missing
11085+
* draft's `404 NO_DRAFT`. The other reads serve no `item`, which both
11086+
* transports answer `404 RESOURCE_NOT_FOUND`, byte for byte the missing
11087+
* name's answer, and drop `registration`, the engine's verdict on a served
11088+
* flow. The protection envelope stays: it reports the write doors'
11089+
* verdicts about this address (#21670), and a disable does not change how
11090+
* the lock gate judges a save there. `package` is never hidden, so a
11091+
* disabled package can still be read and re-enabled.
1104911092
*/
1105011093
async getMetaItem(request: {
1105111094
type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean,
@@ -11057,7 +11100,14 @@ export class ObjectStackProtocolImplementation implements
1105711100
if (request.type === 'object' && isObjectOfDisabledPackage(this.engine?.registry, request.name)) {
1105811101
throw objectPackageDisabledError(request.name);
1105911102
}
11060-
return this.readMetaItem(request);
11103+
const answer = await this.readMetaItem(request);
11104+
if (request.type === 'object' || request.type === 'package'
11105+
|| !isItemOfDisabledPackage(this.engine?.registry, answer?.item)) {
11106+
return answer;
11107+
}
11108+
if (request.state === 'draft') throw noPendingDraftError(request.type, request.name);
11109+
const { item: _withheld, registration: _servedOnly, ...envelope } = answer as typeof answer & { registration?: unknown };
11110+
return { ...envelope, item: undefined };
1106111111
}
1106211112

1106311113
/**
@@ -11292,14 +11342,7 @@ export class ObjectStackProtocolImplementation implements
1129211342
// status code to decide "no pending edit" without sniffing
1129311343
// envelope shape. See ADR-0005 §draft-lifecycle.
1129411344
if (readState === 'draft') {
11295-
if (item === undefined) {
11296-
const err: any = new Error(
11297-
`No pending draft exists for ${request.type}/${request.name}.`,
11298-
);
11299-
err.code = 'NO_DRAFT';
11300-
err.status = 404;
11301-
throw err;
11302-
}
11345+
if (item === undefined) throw noPendingDraftError(request.type, request.name);
1130311346
return {
1130411347
type: request.type,
1130511348
name: request.name,

0 commit comments

Comments
 (0)