Skip to content
47 changes: 47 additions & 0 deletions .changeset/meta-history-diff-read-doors-org-partition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/rest": patch
---

fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406)

`GET /api/v1/meta/:type/:name/history` answered `{ events: [] }`, and
`GET /api/v1/meta/:type/:name/diff` answered an all-empty diff, for metadata
whose overlay was authored **org-scoped** — while `sys_metadata_history` held
the full log. Both doors named no organization, and `sys_metadata_history` is a
**per-org** table: `SysMetadataRepository.history()` and `diffMetaItem` filter
`organization_id` by **strict equality** (no `$or`), so a door that states no
organization does not read "everything" — it reads the **env** partition
(`request.organizationId ?? null`). The write door has stated the org since
#8805; only these two read doors had not.

Direction is **fail-closed**: the caller's OWN org data was under-served. There
was no cross-org read, and the pins added here keep it that way (an org-less
caller, and a second organization, are both refused the rows).

**Call-side only.** `packages/spec` and the protocol implementation are
untouched: `organizationId` was already declared on the request contract
(`HistoryMetaItemRequestSchema`), and `request.organizationId ?? null` is the
legitimate expression of env scope that every correct caller depends on.

**The scope predicate is `organizationIdForMetaRead`, not the audit twin's raw
`ctx?.tenantId ?? null`** — measured, not stylistic. `auditMetaItem` reads with
`$or: [{organization_id: org}, {organization_id: null}]`, a union, so naming a
tenant there can only add rows. Under these doors' strict equality, a raw tenant
id would ask the **org** partition for the history of every
`allowOrgOverride: false` type that is still runtime-writable (`object`, `hook`,
`page`, `app`, `dataset`) — types whose rows `organizationIdForMetaWrite`
deliberately lands **env-wide** under the #6190 ruling. That would answer
`{ events: [] }` for them: this same defect, newly minted one type family over.
Gating the read on the same registry predicate the write uses is what keeps the
two sides incapable of drifting.

The key is **spread, never `organizationId: x ?? null`**:
`HistoryMetaItemRequestSchema` declares `z.string().optional()` — optional plain
`string`, not nullable, mirroring the implementation's `organizationId?: string`
— so `?? null` is a compile error on the history door, and on the diff door
(reached through a cast) it type-checks and is a silent runtime no-op.

Users of a single-DB multi-org deployment (`OS_TENANCY_POSTURE=isolated`) now
see the change log and version diffs for overlays their own organization
authored. Org-less callers, and every `allowOrgOverride: false` type, read
exactly what they read before.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
28 changes: 22 additions & 6 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
it('75 invocation sites, 95 mentions — the thread\'s two control numbers hold', () => {
// [#13406] 73 → 75 sites / 92 → 95 mentions. The `/meta/:type/:name/
// history` and `/diff` read doors resolved NO identity, so neither
// could state which organization's `sys_metadata_history` partition it
// was reading — they read the env one and answered an empty change log
// for org-scoped overlays. Both join as LOCALLY CAUGHT sites (the
// continuation-line `.catch(rethrowAuthzStoreUnavailable)` spelling),
// which is the family the next case describes: neither door sits behind
// the shared anonymous floor, so each must decide the outage for
// itself — the same shape the `/audit` twin and the `/layers` door
// already carry.
//
// ⚠️ Again the two numbers moved by DIFFERENT amounts (+2 and +3): two
// call sites, and ONE prose mention in the history door's new
// doc-comment recording that `resolveExecCtx` is memoised per request
// and so this is not a new org-resolution seam.
//
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
Expand All@@ -324,11 +340,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
expect(SITES.length).toBe(75);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(95);
});

it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
it('the split is 22 locally caught / 53 bare — NOT 16 / 53, which does not add to 75', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
Expand All@@ -338,12 +354,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(CAUGHT.length).toBe(22);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 22 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406) by os-steve · Pull Request #13756 · objectstack-ai/objectstack · GitHub
Skip to content
47 changes: 47 additions & 0 deletions .changeset/meta-history-diff-read-doors-org-partition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/rest": patch
---

fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406)

`GET /api/v1/meta/:type/:name/history` answered `{ events: [] }`, and
`GET /api/v1/meta/:type/:name/diff` answered an all-empty diff, for metadata
whose overlay was authored **org-scoped** — while `sys_metadata_history` held
the full log. Both doors named no organization, and `sys_metadata_history` is a
**per-org** table: `SysMetadataRepository.history()` and `diffMetaItem` filter
`organization_id` by **strict equality** (no `$or`), so a door that states no
organization does not read "everything" — it reads the **env** partition
(`request.organizationId ?? null`). The write door has stated the org since
#8805; only these two read doors had not.

Direction is **fail-closed**: the caller's OWN org data was under-served. There
was no cross-org read, and the pins added here keep it that way (an org-less
caller, and a second organization, are both refused the rows).

**Call-side only.** `packages/spec` and the protocol implementation are
untouched: `organizationId` was already declared on the request contract
(`HistoryMetaItemRequestSchema`), and `request.organizationId ?? null` is the
legitimate expression of env scope that every correct caller depends on.

**The scope predicate is `organizationIdForMetaRead`, not the audit twin's raw
`ctx?.tenantId ?? null`** — measured, not stylistic. `auditMetaItem` reads with
`$or: [{organization_id: org}, {organization_id: null}]`, a union, so naming a
tenant there can only add rows. Under these doors' strict equality, a raw tenant
id would ask the **org** partition for the history of every
`allowOrgOverride: false` type that is still runtime-writable (`object`, `hook`,
`page`, `app`, `dataset`) — types whose rows `organizationIdForMetaWrite`
deliberately lands **env-wide** under the #6190 ruling. That would answer
`{ events: [] }` for them: this same defect, newly minted one type family over.
Gating the read on the same registry predicate the write uses is what keeps the
two sides incapable of drifting.

The key is **spread, never `organizationId: x ?? null`**:
`HistoryMetaItemRequestSchema` declares `z.string().optional()` — optional plain
`string`, not nullable, mirroring the implementation's `organizationId?: string`
— so `?? null` is a compile error on the history door, and on the diff door
(reached through a cast) it type-checks and is a silent runtime no-op.

Users of a single-DB multi-org deployment (`OS_TENANCY_POSTURE=isolated`) now
see the change log and version diffs for overlays their own organization
authored. Org-less callers, and every `allowOrgOverride: false` type, read
exactly what they read before.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
28 changes: 22 additions & 6 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
it('75 invocation sites, 95 mentions — the thread\'s two control numbers hold', () => {
// [#13406] 73 → 75 sites / 92 → 95 mentions. The `/meta/:type/:name/
// history` and `/diff` read doors resolved NO identity, so neither
// could state which organization's `sys_metadata_history` partition it
// was reading — they read the env one and answered an empty change log
// for org-scoped overlays. Both join as LOCALLY CAUGHT sites (the
// continuation-line `.catch(rethrowAuthzStoreUnavailable)` spelling),
// which is the family the next case describes: neither door sits behind
// the shared anonymous floor, so each must decide the outage for
// itself — the same shape the `/audit` twin and the `/layers` door
// already carry.
//
// ⚠️ Again the two numbers moved by DIFFERENT amounts (+2 and +3): two
// call sites, and ONE prose mention in the history door's new
// doc-comment recording that `resolveExecCtx` is memoised per request
// and so this is not a new org-resolution seam.
//
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
Expand All@@ -324,11 +340,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
expect(SITES.length).toBe(75);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(95);
});

it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
it('the split is 22 locally caught / 53 bare — NOT 16 / 53, which does not add to 75', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
Expand All@@ -338,12 +354,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(CAUGHT.length).toBe(22);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 22 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406) by os-steve · Pull Request #13756 · objectstack-ai/objectstack · GitHub
Skip to content
47 changes: 47 additions & 0 deletions .changeset/meta-history-diff-read-doors-org-partition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/rest": patch
---

fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406)

`GET /api/v1/meta/:type/:name/history` answered `{ events: [] }`, and
`GET /api/v1/meta/:type/:name/diff` answered an all-empty diff, for metadata
whose overlay was authored **org-scoped** — while `sys_metadata_history` held
the full log. Both doors named no organization, and `sys_metadata_history` is a
**per-org** table: `SysMetadataRepository.history()` and `diffMetaItem` filter
`organization_id` by **strict equality** (no `$or`), so a door that states no
organization does not read "everything" — it reads the **env** partition
(`request.organizationId ?? null`). The write door has stated the org since
#8805; only these two read doors had not.

Direction is **fail-closed**: the caller's OWN org data was under-served. There
was no cross-org read, and the pins added here keep it that way (an org-less
caller, and a second organization, are both refused the rows).

**Call-side only.** `packages/spec` and the protocol implementation are
untouched: `organizationId` was already declared on the request contract
(`HistoryMetaItemRequestSchema`), and `request.organizationId ?? null` is the
legitimate expression of env scope that every correct caller depends on.

**The scope predicate is `organizationIdForMetaRead`, not the audit twin's raw
`ctx?.tenantId ?? null`** — measured, not stylistic. `auditMetaItem` reads with
`$or: [{organization_id: org}, {organization_id: null}]`, a union, so naming a
tenant there can only add rows. Under these doors' strict equality, a raw tenant
id would ask the **org** partition for the history of every
`allowOrgOverride: false` type that is still runtime-writable (`object`, `hook`,
`page`, `app`, `dataset`) — types whose rows `organizationIdForMetaWrite`
deliberately lands **env-wide** under the #6190 ruling. That would answer
`{ events: [] }` for them: this same defect, newly minted one type family over.
Gating the read on the same registry predicate the write uses is what keeps the
two sides incapable of drifting.

The key is **spread, never `organizationId: x ?? null`**:
`HistoryMetaItemRequestSchema` declares `z.string().optional()` — optional plain
`string`, not nullable, mirroring the implementation's `organizationId?: string`
— so `?? null` is a compile error on the history door, and on the diff door
(reached through a cast) it type-checks and is a silent runtime no-op.

Users of a single-DB multi-org deployment (`OS_TENANCY_POSTURE=isolated`) now
see the change log and version diffs for overlays their own organization
authored. Org-less callers, and every `allowOrgOverride: false` type, read
exactly what they read before.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
28 changes: 22 additions & 6 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
it('75 invocation sites, 95 mentions — the thread\'s two control numbers hold', () => {
// [#13406] 73 → 75 sites / 92 → 95 mentions. The `/meta/:type/:name/
// history` and `/diff` read doors resolved NO identity, so neither
// could state which organization's `sys_metadata_history` partition it
// was reading — they read the env one and answered an empty change log
// for org-scoped overlays. Both join as LOCALLY CAUGHT sites (the
// continuation-line `.catch(rethrowAuthzStoreUnavailable)` spelling),
// which is the family the next case describes: neither door sits behind
// the shared anonymous floor, so each must decide the outage for
// itself — the same shape the `/audit` twin and the `/layers` door
// already carry.
//
// ⚠️ Again the two numbers moved by DIFFERENT amounts (+2 and +3): two
// call sites, and ONE prose mention in the history door's new
// doc-comment recording that `resolveExecCtx` is memoised per request
// and so this is not a new org-resolution seam.
//
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
Expand All@@ -324,11 +340,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
expect(SITES.length).toBe(75);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(95);
});

it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
it('the split is 22 locally caught / 53 bare — NOT 16 / 53, which does not add to 75', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
Expand All@@ -338,12 +354,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(CAUGHT.length).toBe(22);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 22 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406) by os-steve · Pull Request #13756 · objectstack-ai/objectstack · GitHub
Skip to content
47 changes: 47 additions & 0 deletions .changeset/meta-history-diff-read-doors-org-partition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/rest": patch
---

fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406)

`GET /api/v1/meta/:type/:name/history` answered `{ events: [] }`, and
`GET /api/v1/meta/:type/:name/diff` answered an all-empty diff, for metadata
whose overlay was authored **org-scoped** — while `sys_metadata_history` held
the full log. Both doors named no organization, and `sys_metadata_history` is a
**per-org** table: `SysMetadataRepository.history()` and `diffMetaItem` filter
`organization_id` by **strict equality** (no `$or`), so a door that states no
organization does not read "everything" — it reads the **env** partition
(`request.organizationId ?? null`). The write door has stated the org since
#8805; only these two read doors had not.

Direction is **fail-closed**: the caller's OWN org data was under-served. There
was no cross-org read, and the pins added here keep it that way (an org-less
caller, and a second organization, are both refused the rows).

**Call-side only.** `packages/spec` and the protocol implementation are
untouched: `organizationId` was already declared on the request contract
(`HistoryMetaItemRequestSchema`), and `request.organizationId ?? null` is the
legitimate expression of env scope that every correct caller depends on.

**The scope predicate is `organizationIdForMetaRead`, not the audit twin's raw
`ctx?.tenantId ?? null`** — measured, not stylistic. `auditMetaItem` reads with
`$or: [{organization_id: org}, {organization_id: null}]`, a union, so naming a
tenant there can only add rows. Under these doors' strict equality, a raw tenant
id would ask the **org** partition for the history of every
`allowOrgOverride: false` type that is still runtime-writable (`object`, `hook`,
`page`, `app`, `dataset`) — types whose rows `organizationIdForMetaWrite`
deliberately lands **env-wide** under the #6190 ruling. That would answer
`{ events: [] }` for them: this same defect, newly minted one type family over.
Gating the read on the same registry predicate the write uses is what keeps the
two sides incapable of drifting.

The key is **spread, never `organizationId: x ?? null`**:
`HistoryMetaItemRequestSchema` declares `z.string().optional()` — optional plain
`string`, not nullable, mirroring the implementation's `organizationId?: string`
— so `?? null` is a compile error on the history door, and on the diff door
(reached through a cast) it type-checks and is a silent runtime no-op.

Users of a single-DB multi-org deployment (`OS_TENANCY_POSTURE=isolated`) now
see the change log and version diffs for overlays their own organization
authored. Org-less callers, and every `allowOrgOverride: false` type, read
exactly what they read before.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
28 changes: 22 additions & 6 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
it('75 invocation sites, 95 mentions — the thread\'s two control numbers hold', () => {
// [#13406] 73 → 75 sites / 92 → 95 mentions. The `/meta/:type/:name/
// history` and `/diff` read doors resolved NO identity, so neither
// could state which organization's `sys_metadata_history` partition it
// was reading — they read the env one and answered an empty change log
// for org-scoped overlays. Both join as LOCALLY CAUGHT sites (the
// continuation-line `.catch(rethrowAuthzStoreUnavailable)` spelling),
// which is the family the next case describes: neither door sits behind
// the shared anonymous floor, so each must decide the outage for
// itself — the same shape the `/audit` twin and the `/layers` door
// already carry.
//
// ⚠️ Again the two numbers moved by DIFFERENT amounts (+2 and +3): two
// call sites, and ONE prose mention in the history door's new
// doc-comment recording that `resolveExecCtx` is memoised per request
// and so this is not a new org-resolution seam.
//
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
Expand All@@ -324,11 +340,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
expect(SITES.length).toBe(75);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(95);
});

it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
it('the split is 22 locally caught / 53 bare — NOT 16 / 53, which does not add to 75', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
Expand All@@ -338,12 +354,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(CAUGHT.length).toBe(22);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 22 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406) by os-steve · Pull Request #13756 · objectstack-ai/objectstack · GitHub
Skip to content
47 changes: 47 additions & 0 deletions .changeset/meta-history-diff-read-doors-org-partition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/rest": patch
---

fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406)

`GET /api/v1/meta/:type/:name/history` answered `{ events: [] }`, and
`GET /api/v1/meta/:type/:name/diff` answered an all-empty diff, for metadata
whose overlay was authored **org-scoped** — while `sys_metadata_history` held
the full log. Both doors named no organization, and `sys_metadata_history` is a
**per-org** table: `SysMetadataRepository.history()` and `diffMetaItem` filter
`organization_id` by **strict equality** (no `$or`), so a door that states no
organization does not read "everything" — it reads the **env** partition
(`request.organizationId ?? null`). The write door has stated the org since
#8805; only these two read doors had not.

Direction is **fail-closed**: the caller's OWN org data was under-served. There
was no cross-org read, and the pins added here keep it that way (an org-less
caller, and a second organization, are both refused the rows).

**Call-side only.** `packages/spec` and the protocol implementation are
untouched: `organizationId` was already declared on the request contract
(`HistoryMetaItemRequestSchema`), and `request.organizationId ?? null` is the
legitimate expression of env scope that every correct caller depends on.

**The scope predicate is `organizationIdForMetaRead`, not the audit twin's raw
`ctx?.tenantId ?? null`** — measured, not stylistic. `auditMetaItem` reads with
`$or: [{organization_id: org}, {organization_id: null}]`, a union, so naming a
tenant there can only add rows. Under these doors' strict equality, a raw tenant
id would ask the **org** partition for the history of every
`allowOrgOverride: false` type that is still runtime-writable (`object`, `hook`,
`page`, `app`, `dataset`) — types whose rows `organizationIdForMetaWrite`
deliberately lands **env-wide** under the #6190 ruling. That would answer
`{ events: [] }` for them: this same defect, newly minted one type family over.
Gating the read on the same registry predicate the write uses is what keeps the
two sides incapable of drifting.

The key is **spread, never `organizationId: x ?? null`**:
`HistoryMetaItemRequestSchema` declares `z.string().optional()` — optional plain
`string`, not nullable, mirroring the implementation's `organizationId?: string`
— so `?? null` is a compile error on the history door, and on the diff door
(reached through a cast) it type-checks and is a silent runtime no-op.

Users of a single-DB multi-org deployment (`OS_TENANCY_POSTURE=isolated`) now
see the change log and version diffs for overlays their own organization
authored. Org-less callers, and every `allowOrgOverride: false` type, read
exactly what they read before.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
28 changes: 22 additions & 6 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
it('75 invocation sites, 95 mentions — the thread\'s two control numbers hold', () => {
// [#13406] 73 → 75 sites / 92 → 95 mentions. The `/meta/:type/:name/
// history` and `/diff` read doors resolved NO identity, so neither
// could state which organization's `sys_metadata_history` partition it
// was reading — they read the env one and answered an empty change log
// for org-scoped overlays. Both join as LOCALLY CAUGHT sites (the
// continuation-line `.catch(rethrowAuthzStoreUnavailable)` spelling),
// which is the family the next case describes: neither door sits behind
// the shared anonymous floor, so each must decide the outage for
// itself — the same shape the `/audit` twin and the `/layers` door
// already carry.
//
// ⚠️ Again the two numbers moved by DIFFERENT amounts (+2 and +3): two
// call sites, and ONE prose mention in the history door's new
// doc-comment recording that `resolveExecCtx` is memoised per request
// and so this is not a new org-resolution seam.
//
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
Expand All@@ -324,11 +340,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
expect(SITES.length).toBe(75);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(95);
});

it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
it('the split is 22 locally caught / 53 bare — NOT 16 / 53, which does not add to 75', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
Expand All@@ -338,12 +354,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(CAUGHT.length).toBe(22);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 22 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406) by os-steve · Pull Request #13756 · objectstack-ai/objectstack · GitHub
Skip to content
47 changes: 47 additions & 0 deletions .changeset/meta-history-diff-read-doors-org-partition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/rest": patch
---

fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406)

`GET /api/v1/meta/:type/:name/history` answered `{ events: [] }`, and
`GET /api/v1/meta/:type/:name/diff` answered an all-empty diff, for metadata
whose overlay was authored **org-scoped** — while `sys_metadata_history` held
the full log. Both doors named no organization, and `sys_metadata_history` is a
**per-org** table: `SysMetadataRepository.history()` and `diffMetaItem` filter
`organization_id` by **strict equality** (no `$or`), so a door that states no
organization does not read "everything" — it reads the **env** partition
(`request.organizationId ?? null`). The write door has stated the org since
#8805; only these two read doors had not.

Direction is **fail-closed**: the caller's OWN org data was under-served. There
was no cross-org read, and the pins added here keep it that way (an org-less
caller, and a second organization, are both refused the rows).

**Call-side only.** `packages/spec` and the protocol implementation are
untouched: `organizationId` was already declared on the request contract
(`HistoryMetaItemRequestSchema`), and `request.organizationId ?? null` is the
legitimate expression of env scope that every correct caller depends on.

**The scope predicate is `organizationIdForMetaRead`, not the audit twin's raw
`ctx?.tenantId ?? null`** — measured, not stylistic. `auditMetaItem` reads with
`$or: [{organization_id: org}, {organization_id: null}]`, a union, so naming a
tenant there can only add rows. Under these doors' strict equality, a raw tenant
id would ask the **org** partition for the history of every
`allowOrgOverride: false` type that is still runtime-writable (`object`, `hook`,
`page`, `app`, `dataset`) — types whose rows `organizationIdForMetaWrite`
deliberately lands **env-wide** under the #6190 ruling. That would answer
`{ events: [] }` for them: this same defect, newly minted one type family over.
Gating the read on the same registry predicate the write uses is what keeps the
two sides incapable of drifting.

The key is **spread, never `organizationId: x ?? null`**:
`HistoryMetaItemRequestSchema` declares `z.string().optional()` — optional plain
`string`, not nullable, mirroring the implementation's `organizationId?: string`
— so `?? null` is a compile error on the history door, and on the diff door
(reached through a cast) it type-checks and is a silent runtime no-op.

Users of a single-DB multi-org deployment (`OS_TENANCY_POSTURE=isolated`) now
see the change log and version diffs for overlays their own organization
authored. Org-less callers, and every `allowOrgOverride: false` type, read
exactly what they read before.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
28 changes: 22 additions & 6 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
it('75 invocation sites, 95 mentions — the thread\'s two control numbers hold', () => {
// [#13406] 73 → 75 sites / 92 → 95 mentions. The `/meta/:type/:name/
// history` and `/diff` read doors resolved NO identity, so neither
// could state which organization's `sys_metadata_history` partition it
// was reading — they read the env one and answered an empty change log
// for org-scoped overlays. Both join as LOCALLY CAUGHT sites (the
// continuation-line `.catch(rethrowAuthzStoreUnavailable)` spelling),
// which is the family the next case describes: neither door sits behind
// the shared anonymous floor, so each must decide the outage for
// itself — the same shape the `/audit` twin and the `/layers` door
// already carry.
//
// ⚠️ Again the two numbers moved by DIFFERENT amounts (+2 and +3): two
// call sites, and ONE prose mention in the history door's new
// doc-comment recording that `resolveExecCtx` is memoised per request
// and so this is not a new org-resolution seam.
//
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
Expand All@@ -324,11 +340,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
expect(SITES.length).toBe(75);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(95);
});

it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
it('the split is 22 locally caught / 53 bare — NOT 16 / 53, which does not add to 75', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
Expand All@@ -338,12 +354,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(CAUGHT.length).toBe(22);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 22 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406) by os-steve · Pull Request #13756 · objectstack-ai/objectstack · GitHub
Skip to content
47 changes: 47 additions & 0 deletions .changeset/meta-history-diff-read-doors-org-partition.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
"@objectstack/rest": patch
---

fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406)

`GET /api/v1/meta/:type/:name/history` answered `{ events: [] }`, and
`GET /api/v1/meta/:type/:name/diff` answered an all-empty diff, for metadata
whose overlay was authored **org-scoped** — while `sys_metadata_history` held
the full log. Both doors named no organization, and `sys_metadata_history` is a
**per-org** table: `SysMetadataRepository.history()` and `diffMetaItem` filter
`organization_id` by **strict equality** (no `$or`), so a door that states no
organization does not read "everything" — it reads the **env** partition
(`request.organizationId ?? null`). The write door has stated the org since
#8805; only these two read doors had not.

Direction is **fail-closed**: the caller's OWN org data was under-served. There
was no cross-org read, and the pins added here keep it that way (an org-less
caller, and a second organization, are both refused the rows).

**Call-side only.** `packages/spec` and the protocol implementation are
untouched: `organizationId` was already declared on the request contract
(`HistoryMetaItemRequestSchema`), and `request.organizationId ?? null` is the
legitimate expression of env scope that every correct caller depends on.

**The scope predicate is `organizationIdForMetaRead`, not the audit twin's raw
`ctx?.tenantId ?? null`** — measured, not stylistic. `auditMetaItem` reads with
`$or: [{organization_id: org}, {organization_id: null}]`, a union, so naming a
tenant there can only add rows. Under these doors' strict equality, a raw tenant
id would ask the **org** partition for the history of every
`allowOrgOverride: false` type that is still runtime-writable (`object`, `hook`,
`page`, `app`, `dataset`) — types whose rows `organizationIdForMetaWrite`
deliberately lands **env-wide** under the #6190 ruling. That would answer
`{ events: [] }` for them: this same defect, newly minted one type family over.
Gating the read on the same registry predicate the write uses is what keeps the
two sides incapable of drifting.

The key is **spread, never `organizationId: x ?? null`**:
`HistoryMetaItemRequestSchema` declares `z.string().optional()` — optional plain
`string`, not nullable, mirroring the implementation's `organizationId?: string`
— so `?? null` is a compile error on the history door, and on the diff door
(reached through a cast) it type-checks and is a silent runtime no-op.

Users of a single-DB multi-org deployment (`OS_TENANCY_POSTURE=isolated`) now
see the change log and version diffs for overlays their own organization
authored. Org-less callers, and every `allowOrgOverride: false` type, read
exactly what they read before.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
28 changes: 22 additions & 6 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
it('75 invocation sites, 95 mentions — the thread\'s two control numbers hold', () => {
// [#13406] 73 → 75 sites / 92 → 95 mentions. The `/meta/:type/:name/
// history` and `/diff` read doors resolved NO identity, so neither
// could state which organization's `sys_metadata_history` partition it
// was reading — they read the env one and answered an empty change log
// for org-scoped overlays. Both join as LOCALLY CAUGHT sites (the
// continuation-line `.catch(rethrowAuthzStoreUnavailable)` spelling),
// which is the family the next case describes: neither door sits behind
// the shared anonymous floor, so each must decide the outage for
// itself — the same shape the `/audit` twin and the `/layers` door
// already carry.
//
// ⚠️ Again the two numbers moved by DIFFERENT amounts (+2 and +3): two
// call sites, and ONE prose mention in the history door's new
// doc-comment recording that `resolveExecCtx` is memoised per request
// and so this is not a new org-resolution seam.
//
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
Expand All@@ -324,11 +340,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
expect(SITES.length).toBe(75);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(95);
});

it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
it('the split is 22 locally caught / 53 bare — NOT 16 / 53, which does not add to 75', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
Expand All@@ -338,12 +354,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(CAUGHT.length).toBe(22);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 22 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
Expand Down
Loading
Loading