Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/runtime-meta-write-org-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"@objectstack/runtime": patch
---

fix(runtime): a metadata write carries the session's organization only for types that declare `allowOrgOverride` (#7018)

The dispatcher threaded the caller's active organization into
`protocol.saveMetaItem` **unconditionally**, and `SysMetadataRepository.put`
stamps `organization_id` for every type. So any session with an active
organization minted an org-scoped `sys_metadata` row even for types the registry
declares NOT per-org overridable — and cold boot (`loadMetaFromDb`) hydrates
`organization_id IS NULL` only.

Those rows were **phantom writes**: correct for the life of the process, silently
absent after the next restart. The measured specimens are the ones #6190 filed —
a `flow` authored in Studio binds its triggers, fires all day, and stops firing
after a restart with nothing said; an `object` written the same way 404s every
record. For `allowOrgOverride: true` types (`view`, `dashboard`, `report`,
`translation`, `email_template`) the same skip is the ADR-0005 design, because
those overlays are loaded on demand by `getMetaItem`/`getMetaItems`.

Both runtime write sites now consult the type's registry declaration:

- `PUT /api/v1/meta/:type/:name` — the active organization rides the write only
when the target type declares `allowOrgOverride: true`. Otherwise it is
dropped and the write lands env-wide, producing exactly the row (and exactly
the receipt) a session with no active organization already produces today.
- `POST /api/v1/packages/:id/publish-drafts` — the ADR-0045 §3 visibility flip
writes `app` (`allowOrgOverride: false`), so it now lands env-wide, on the row
cold boot hydrates and the App Switcher reads. The org-scoped flip was itself a
phantom: the app looked published until the next restart and then went back to
`_unpublished: true`, because the env-wide row it left untouched is the only
one boot loads.

The predicate is derived from `DEFAULT_METADATA_TYPE_REGISTRY`, so a registry
entry flipping `allowOrgOverride` moves the runtime with it — there is no second
list to keep in sync. It deliberately does **not** consult the
`OS_METADATA_WRITABLE` escape hatch: that hatch unlocks the *write*, and an
env-unlocked type's org rows are hydrated no more than any other's, which is the
same call `reportUnhydratableOrgScopedRows` already made on the read side.

No authoring change and no new refusal: writes that succeeded still succeed, with
the same response body. What changes is which partition the row lands in for
types that never had a per-org read channel.

Part of the #6190 maintainer ruling (Option A, runtime half).
16 changes: 15 additions & 1 deletion packages/runtime/src/domains/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
resolveObjectSchemaMaskPosture,
type ObjectSchemaMaskPosture,
} from '@objectstack/metadata-core';
import { organizationIdForMetaWrite } from '../meta-write-org-scope.js';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';

Expand Down Expand Up @@ -301,7 +302,20 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin

if (protocol && typeof protocol.saveMetaItem === 'function') {
try {
const organizationId = await deps.resolveActiveOrganizationId(_context);
// [#7018 / the #6190 ruling, Option A] The session's active
// organization rides this write ONLY for types the registry
// declares `allowOrgOverride: true`. For every other type it
// is dropped and the write lands env-wide — byte-identical to
// what a no-active-org session already produces today.
//
// Threading it unconditionally is how the runtime minted rows
// boot never reads: `SysMetadataRepository.put` stamps
// `organization_id` for EVERY type, while `loadMetaFromDb`
// hydrates `organization_id IS NULL` only. See
// `../meta-write-org-scope.js` for why the predicate is the
// static registry flag and not `isOverlayAllowed`.
const activeOrganizationId = await deps.resolveActiveOrganizationId(_context);
const organizationId = organizationIdForMetaWrite(type, activeOrganizationId);
const result = await protocol.saveMetaItem({ type, name, item: body, organizationId, ...(packageId ? { packageId } : {}) });
return { handled: true, response: deps.success(result) };
} catch (e: any) {
Expand Down
15 changes: 14 additions & 1 deletion packages/runtime/src/domains/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { CoreServiceName } from '@objectstack/spec/system';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import { organizationIdForMetaWrite } from '../meta-write-org-scope.js';
import { setPackageDisabled } from '../package-state-store.js';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
Expand Down Expand Up @@ -227,7 +228,19 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
// that cannot verify or update that consumer — would be a
// silent break of the exact kind #4829 is about. The rename
// rides the objectui follow-up card, together.
//
// [#7018 / the #6190 ruling, Option A] `app` declares
// `allowOrgOverride: false`, so this flip does NOT carry the
// session's active organization — it lands env-wide, on the
// very row boot hydrates and the App Switcher reads. An
// org-scoped flip was a phantom: the app looked published for
// the life of the process and went back to `_unpublished:
// true` on the next restart, because the env-wide row it left
// untouched is the only one cold boot loads. The READ above is
// left org-aware on purpose — a layered read is a superset,
// never a loss.
const flipped: string[] = [];
const flipOrganizationId = organizationIdForMetaWrite('app', organizationId);
try {
if (
typeof (protocol as any).getMetaItems === 'function' &&
Expand All @@ -254,7 +267,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
// app carries is copied through untouched.
item: { ...app, _unpublished: false },
packageId: id,
...(organizationId ? { organizationId } : {}),
...(flipOrganizationId ? { organizationId: flipOrganizationId } : {}),
...(body?.actor ? { actor: body.actor } : {}),
});
flipped.push(app.name);
Expand Down
Loading
Loading